feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696)
This commit is contained in:
parent
8fbf2c6018
commit
d7acb5aa48
25 changed files with 13986 additions and 51 deletions
|
|
@ -6,7 +6,11 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
|
||||
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
|
||||
|
||||
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
|
||||
|
||||
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects/<workspace>/canvases/plan-<id>-ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/<slug>-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `<section data-shot="<rótulo>">` (ex. `mobile`, `desktop-vazio`) → `docs/plans/<slug>-ui-draft-<rótulo>.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`.
|
||||
**Recently resolved (2026-08-11):** **OPS40** (Issue #682) — `pnpm dev` honra o `PORT` do `.env.local`: `next dev` resolve a porta **antes** de `@next/env` carregar env-files (commander `.env('PORT')` no `bin/next` — causa confirmada no fonte 15.4.11), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta `PORT` no env real). **`pnpm dev`/`devsafe` agora passam por `scripts/dev.mjs`** (wrapper): importa o `guard-dev-db.mjs` (preflight fail-closed inline, mesma saída), `loadCliEnv()` (`.env.local` → `.env`, `override:false`) e entrega a porta ao `next dev` como **flag CLI `-p`** — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → `.env.local` (worktree) → `.env` → sem `-p` (default 3000 + `allowRetry` do Next intactos no main repo). Derivação pura unit-testada em `scripts/lib/cli.mjs` (`resolveDevPort` — null quando ausente, throw fail-closed em PORT inválida; `nextDevArgs`); spawn `node next/dist/bin/next` com `NODE_OPTIONS=--no-deprecation` (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (`--turbopack`) repassados após o `-p` derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode `campaignAuth` 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: `AGENTS.md` em `main` carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado **fora** da região conflitada. Intenção [`worktree-dev-port-nao-honrado.md`](plans/worktree-dev-port-nao-honrado.md); impl [`worktree-dev-port-nao-honrado-impl.md`](plans/worktree-dev-port-nao-honrado-impl.md).
|
||||
|
|
|
|||
108
docs/plans/pessoas-nome-de-legenda-dobradinha-impl.md
Normal file
108
docs/plans/pessoas-nome-de-legenda-dobradinha-impl.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Impl: Pessoas — nome de legenda da dobradinha (campo + display discreto sob o nome)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #696
|
||||
Intenção: docs/plans/pessoas-nome-de-legenda-dobradinha.md
|
||||
Appetite restante: ~0,5–1 dia eng; um campo + display em 3 superfícies (pessoas, dobradinhas lista/ficha)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a dobradinha ganha um campo opcional **nome de legenda**; na tabela de `/campanha/pessoas` ele aparece discreto sob o nome real; é editável onde hoje se edita dado de dobradinha (lista de Dobradinhas, precedente B163) e display-only na tabela de pessoas.
|
||||
- **O que NÃO negociar:** o nome real continua sendo a chave (sem busca/ordenação/facets por legenda); sem legenda a linha não muda (sem linha vazia, sem traço); **não** inline na célula de Nome de `/campanha/pessoas` (gate 2026-08-11: dois inputs na mesma célula não funcionam); regra da 2ª linha compartilhada com C130 — precedência combinada legenda sobrepõe base.
|
||||
- **O que reavaliar:** a hipótese de "exibir na lista/ficha de Dobradinhas se barato" — decidido no gate: aparece lá, mesmo padrão discreto sob o nome. Onde editar na ficha (seção Contato vs form "Editar dobradinha") não estava decidido — resolvido aqui (D4).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M[Migration: ballotName text nullable, sem backfill] --> C[StateDeputy: campo ballotName]
|
||||
C --> S[Schemas: create/update/inline ballotName]
|
||||
S --> A[Action updateStateDeputyBallotName + formAction]
|
||||
A --> L[Dobradinhas: coluna Nome de legenda default-hidden + sublinha no Nome]
|
||||
C --> P[peopleData: ballotName no merge + peopleNameSubline]
|
||||
P --> U[Pessoas: 2ª linha do Nome = peopleNameSubline]
|
||||
C --> F[Ficha: sublinha no Nome + campo no StateDeputyForm]
|
||||
A --> R[Revalida: dobradinhas lista/ficha + liderancas + municipios]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — campo novo `ballotName` na collection `stateDeputy` (texto, opcional), plumbado no merge de pessoas e nas duas superfícies, reusando a máquina de célula B163 (mesmo padrão do partido) para edição na lista de Dobradinhas; display "discreto sob o nome" em pessoas (2ª linha), lista e ficha de Dobradinhas. Custo concentrado em: migration + campo, schema/action espelhando o partido, e a regra da 2ª linha num único lugar nomeado (`peopleNameSubline`) para C130.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
- **D1 — Campo `ballotName`** (`StateDeputy`): text opcional, `maxLength: 30` (limite real do nome de urna do TSE — é o dado que a mesa registra), **sem index** (nunca é consultado/filtrado; partido é indexado porque tem facet). Label pt-BR "Nome de legenda". Sem access próprio: herda `canManageStateDeputy` como `party`.
|
||||
- Opções: A `ballotName` | B `urnaName` | C `legendName`.
|
||||
- Recomendação: A — termo inglês padrão para "nome de urna"; `legendName` colide semanticamente com "legenda" = sigla partidária no TSE (confusão real).
|
||||
- Rejeitadas: B porque a mesa chama de "nome de legenda" e o domínio interno não usa "urna"; C porque "legenda" em eleitoral brasileiro é o partido (ambiguidade).
|
||||
- **D2 — Regra da 2ª linha do Nome em `/campanha/pessoas`: legenda-only nesta branch, numa costura nomeada.** `peopleNameSubline(row)` (puro, em `peopleData.ts`) retorna `row.ballotName`; comentário declara que C130 estende com `?? row.city` (regra final combinada: legenda sobrepõe base — registrada nos dois planos). Render: `{subline ? <span className="truncate text-xs text-muted-foreground">{subline}</span> : null}` — mesma marcação que a branch C130 já usa para a base, então o merge final é trivial.
|
||||
- Opções: A legenda-only com função nomeada | B `legenda ?? base` já agora | C markup inline.
|
||||
- Recomendação: A — cumpre o aceite "sem nome de legenda, nada muda na linha" nesta branch (com `?? base` a cidade apareceria duas vezes: sublinha + coluna Base, que só sai em C130) e dá a C130 um seam de uma expressão só.
|
||||
- Rejeitadas: B porque viola o aceite da intenção e duplica a base no interim; C porque inline espalharia a regra em 2 lugares sem ponto único para C130.
|
||||
- **D3 — Lista de Dobradinhas: sublinha sob o Nome (display decidido no gate) + coluna "Nome de legenda" default-hidden.** A coluna usa `CampaignInlineEditableCell` (B163, `editTrigger="cell"`, `saveOnChange={false}`) como Partido; entra em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` (precedente da coluna E-mail, B197). A sublinha fica **fora** da região click-to-edit da célula do Nome (a célula é um div autossuficiente; a sublinha é irmã dela num wrapper flex-col), então clicar na sublinha não dispara edição do nome.
|
||||
- Opções: A coluna própria default-hidden + sublinha | B sublinha como editor (click na sublinha → input) | C só sublinha, edição apenas na ficha | D coluna default-visible.
|
||||
- Recomendação: A — display "discreto sob o nome" (decidido) e edição no lugar onde a lista já edita dado de dobradinha (B163), sem duplicar o valor duas vezes na largura padrão; quem edita acha a coluna no picker.
|
||||
- Rejeitadas: B porque reintroduz "dois inputs na mesma célula" (o princípio do gate); C porque "edita onde hoje se edita dado de dobradinha" aponta para a lista (B163); D porque duplica o valor em toda linha com legenda — sublinha + coluna.
|
||||
- **D4 — Ficha: sublinha no Nome da seção Contato (display) + campo no `StateDeputyForm` (edição).** O `StateDeputyForm` é o form dono dos campos de `stateDeputy` (partido/observações) e serve criar + editar — `ballotName` entra lá (create e update schemas). A seção Contato (células B163) fica de fora: ela edita campos de `Contact`, e `ballotName` é campo da dobradinha.
|
||||
- Opções: A form dono | B seção Contato | C ambos.
|
||||
- Recomendação: A — uma superfície de edição só para o campo, no lugar que já edita os demais campos da entidade.
|
||||
- Rejeitadas: B porque a seção Contato é a fronteira dos campos de Contact (B163 separou as duas); C porque duas superfícies de edição para um campo opcional infla o appetite sem ganho.
|
||||
- **D5 — Escrita: espelhar a máquina do partido, sem transação/locks/Consent novos.** `stateDeputyBallotNameUpdateSchema` (= `stateDeputyPartyUpdateSchema`, com `trimmedNullableText(30)` — vazio limpa o campo), `updateStateDeputyBallotNameRecord` via `runStaffEntityMutation` (escrita single-collection, staff-only), form action própria no `dobradinhas/formActions.ts`, `revalidateStateDeputyPaths` no wrapper público. `CampaignInlineEditableField` ganha `'ballotName'` (aditivo no union compartilhado).
|
||||
- Rejeitadas: rota/action nova de célula genérica (não há 3º consumidor); lock advisory (sem unicidade/concorrência de escrita multi-collection).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/collections/StateDeputy.ts`**: campo `ballotName` (text, `maxLength: 30`, label "Nome de legenda", sem index) ao lado de `party`.
|
||||
- **Migration:** `pnpm migrate:create add_state_deputy_ballot_name` — coluna nullable, sem backfill, sem índice; down drop. Revisar SQL e snapshot antes de rodar.
|
||||
- **`src/lib/schemas/stateDeputy.ts`**: `ballotName: trimmedOptionalText(30)` no `stateDeputyCreateSchema` e no `stateDeputyUpdateSchema`; novo `stateDeputyBallotNameUpdateSchema` + tipos.
|
||||
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`**: `updateStateDeputyBallotNameRecord` (clone do party), `updateStateDeputyBallotName` público com `revalidateStateDeputyPaths`; `StateDeputyCreationData` + `createStateDeputyWithContact` propagam `ballotName`; update form flui pelo schema.
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts`**: `updateStateDeputyBallotNameFormAction` (clone do party).
|
||||
- **`src/utilities/people/peopleData.ts`**: `ballotName` em `PeopleDeputySource`, `toPeopleDeputySource`, `MergedPerson`, `personFor`, `mergePeopleSources`, `toPeopleRowViewModel`, `PeopleRowViewModel`; `select: { …, ballotName: true }` no `loadPeopleListPageData`; novo `peopleNameSubline` (puro, seam C130).
|
||||
- **`src/utilities/stateDeputyData.ts`**: `ballotName` em `StateDeputyRowViewModel` + loader select/map; `StateDeputyDetailViewModel` + `loadStateDeputyDetail`.
|
||||
- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: `'ballotName'` no union `CampaignInlineEditableField` (display/type pass-through — sem formatação).
|
||||
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: célula de Nome vira flex-col; 2ª linha = `peopleNameSubline(row)` (mesma classe do C130).
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`**: coluna `ballotName` ("Nome de legenda", cell B163, `editTrigger="cell"`) + wrapper flex-col no Nome com sublinha.
|
||||
- **`src/lib/campaignColumnVisibility.ts`**: `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` += `'ballotName'`.
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx` + `StateDeputyContactSection.tsx`**: sublinha sob o Nome da seção Contato.
|
||||
- **`src/components/campaign/stateDeputy/StateDeputyForm.tsx`**: campo "Nome de legenda" (Input, maxLength 30) no form criar/editar.
|
||||
- **Access / Consent:** nenhum — dado interno de staff; herda `canManageStateDeputy`; sem `Consent` (sem PII nova de opt-in).
|
||||
- **UI:** Impeccable B — encaixe nas células existentes; sem rota nova; mesma família tipográfica, `text-xs text-muted-foreground`.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- **Vou apresentar dados?** Não — texto de identidade (nome de urna), sem métrica (pergunta 3 de data-presentation não se aplica). Forma: texto discreto sob o nome, sem label (a posição é o rótulo).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** (quota principal): migration + campo + schemas + action + form action; `pnpm generate:types`; criar/editar dobradinha com legenda via int spec.
|
||||
2. **Dados**: plumb `ballotName` em `peopleData`/`stateDeputyData` + `peopleNameSubline`; unit tests (merge carrega o valor; subline legenda-only).
|
||||
3. **UI**: sublinha em pessoas; coluna + sublinha em dobradinhas (+ default-hidden); ficha (sublinha + form).
|
||||
4. **Gates**: `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (se viável) e `pnpm build` local; int specs novos/focados.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Busca/ordenação/facets por legenda (aceite: nome real é a chave) — nem `q` nem sort tocam `ballotName`.
|
||||
- Nome de legenda para liderança/staff (pedido atual é só dobradinha).
|
||||
- Redesign das superfícies de dobradinha (só sublinha + coluna).
|
||||
- Index em `ballotName`, admin column picker customizado, campo na seção Contato da ficha (D4).
|
||||
- Edição inline na célula de Nome de `/campanha/pessoas` (gate).
|
||||
- Mobile cards de pessoas (intenção: tabela desktop).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **C130 em paralelo toca a mesma célula de Nome e `peopleData`** (branch C130 em progresso, com mudanças uncommitted): a sublinha nasce na função nomeada `peopleNameSubline` com a MESMA marcação do diff C130 (`truncate text-xs text-muted-foreground`), então o rebase pós-merge resolve substituindo o bloco da base pela sublinha combinada `legenda ?? base` (regra documentada nos dois planos de intenção). Se C130 mergir primeiro, o rebase converte o bloco `{row.city…}` na sublinha combinada.
|
||||
- **Union `CampaignInlineEditableField` compartilhado**: aditivo; `tsc --noEmit` pega qualquer consumidor quebrar.
|
||||
- **Migration em prod via build**: nullable, sem backfill, sem índice — reversível e não-destrutiva.
|
||||
- **Cookie de colunas**: id novo é inerte em cookies antigos → visível para quem nunca mexeu no picker (contrato documentado em `toggleHiddenColumn`); default-hidden só vale para picker intacto — comportamento esperado (precedente E-mail).
|
||||
- **Clicar na sublinha da lista de Dobradinhas disparar edição do Nome**: a sublinha é irmã da célula no wrapper flex-col, fora da região do cell editor — sem `stopPropagation` necessário.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (campo opcional; display discreto; editável na lista de Dobradinhas; display-only em pessoas; sem linha vazia)
|
||||
- [ ] Identificadores em inglês (`ballotName`), labels pt-BR ("Nome de legenda")
|
||||
- [ ] Escrita staff-only via `runStaffEntityMutation` (precedente partido), sem alargar access
|
||||
- [ ] Seam `peopleNameSubline` documentado para C130 (regra combinada legenda sobrepõe base)
|
||||
- [ ] Sem migration destrutiva; `pnpm generate:types` após migration
|
||||
- [ ] Testes de domínio: unit (merge + subline) e int (update ballotName por coordenador/assessor; líder negado; limpeza do campo)
|
||||
|
||||
**Self-score decision-quality: 5/5** — decisões caras (D1–D5) têm alternativas rejeitadas; appetite bounded (~0,5–1 dia); rabbit holes nomeados; depth check reusa B163, `runStaffEntityMutation`, `DEFAULT_HIDDEN_COLUMN_IDS` e a máquina de partido; outcome da intenção intacto (display-only em pessoas, edição na lista, legenda não vira chave de busca).
|
||||
|
|
@ -29,6 +29,7 @@ export const updateStateDeputyFormAction = async (
|
|||
await updateStateDeputy({
|
||||
id: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
party: nullableFormText(formData, 'party'),
|
||||
ballotName: nullableFormText(formData, 'ballotName'),
|
||||
notes: nullableFormText(formData, 'notes'),
|
||||
})
|
||||
revalidatePath('/campanha/dobradinhas/[id]', 'page')
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
|
|||
<StateDeputyContactSection
|
||||
stateDeputyId={stateDeputy.id}
|
||||
name={stateDeputy.name}
|
||||
ballotName={stateDeputy.ballotName}
|
||||
email={stateDeputy.email}
|
||||
phones={stateDeputy.phones}
|
||||
/>
|
||||
|
|
@ -182,6 +183,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
|
|||
initial={{
|
||||
id: stateDeputy.id,
|
||||
party: stateDeputy.party,
|
||||
ballotName: stateDeputy.ballotName,
|
||||
notes: stateDeputy.notes,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/ac
|
|||
import {
|
||||
setStateDeputyAdvisorMembership,
|
||||
setStateDeputyMunicipalitiesBatch,
|
||||
updateStateDeputyBallotName,
|
||||
updateStateDeputyContact,
|
||||
updateStateDeputyParty,
|
||||
} from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
|
|
@ -42,6 +43,8 @@ const stateDeputyContactSafeMessages = [
|
|||
|
||||
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
|
||||
|
||||
const stateDeputyBallotNameSafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
|
||||
|
||||
/** Per-field Contact edit for B163 (lista + ficha). */
|
||||
export const updateStateDeputyContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
@ -103,6 +106,24 @@ export const updateStateDeputyPartyFormAction = async (
|
|||
genericMessage: 'Não foi possível salvar o partido. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/** Inline "Nome de legenda" edit for C129 (lista de dobradinhas, B163 machinery). */
|
||||
export const updateStateDeputyBallotNameFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await updateStateDeputyBallotName({
|
||||
id: requiredRelationshipFormValue(formData, 'stateDeputyId'),
|
||||
ballotName: nullableFormText(formData, 'ballotName'),
|
||||
})
|
||||
return { message: 'Salvo.' }
|
||||
},
|
||||
safeMessages: stateDeputyBallotNameSafeMessages,
|
||||
genericMessage:
|
||||
'Não foi possível salvar o nome de legenda. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
|
||||
export const setLeadershipStateDeputyMembershipFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const createStateDeputyFormAction = async (
|
|||
createStateDeputy({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
party: optionalFormText(formData, 'party'),
|
||||
ballotName: optionalFormText(formData, 'ballotName'),
|
||||
notes: optionalFormText(formData, 'notes'),
|
||||
}),
|
||||
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.id}`,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import {
|
|||
setLeadershipStateDeputyMembershipFormAction,
|
||||
setStateDeputyAdvisorMembershipFormAction,
|
||||
setStateDeputyMunicipalitiesFormAction,
|
||||
updateStateDeputyBallotNameFormAction,
|
||||
updateStateDeputyContactFormAction,
|
||||
updateStateDeputyPartyFormAction,
|
||||
} from './formActions'
|
||||
|
|
@ -121,17 +122,25 @@ const stateDeputyColumns = (
|
|||
mandatory: true,
|
||||
head: <StateDeputySortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.id}
|
||||
recordIdField="stateDeputyId"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${row.id}`}
|
||||
editTrigger="cell"
|
||||
saveOnChange={false}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.id}
|
||||
recordIdField="stateDeputyId"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${row.id}`}
|
||||
editTrigger="cell"
|
||||
saveOnChange={false}
|
||||
/>
|
||||
{/* C129 — the ballot name sits OUTSIDE the name cell's click-to-edit
|
||||
region (sibling, not child), so clicking it never opens the name
|
||||
editor. */}
|
||||
{row.ballotName ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{row.ballotName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -159,6 +168,26 @@ const stateDeputyColumns = (
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
// C129 — the "Nome de legenda" column, hidden by default (email precedent):
|
||||
// the subline under the name is the always-on display; the column is where
|
||||
// the mesa edits it (B163 machinery, same as Partido).
|
||||
id: 'ballotName',
|
||||
label: 'Nome de legenda',
|
||||
cellClassName: 'min-w-32',
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.id}
|
||||
recordIdField="stateDeputyId"
|
||||
field="ballotName"
|
||||
value={row.ballotName}
|
||||
label="Nome de legenda"
|
||||
formAction={updateStateDeputyBallotNameFormAction}
|
||||
editTrigger="cell"
|
||||
saveOnChange={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibili
|
|||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
|
||||
import {
|
||||
loadPeopleListPageData,
|
||||
peopleNameSubline,
|
||||
type PeopleRowViewModel,
|
||||
} from '@/utilities/people/peopleData'
|
||||
import {
|
||||
buildPeopleFilterHref,
|
||||
clearPeopleListFilters,
|
||||
|
|
@ -170,21 +174,31 @@ const peopleColumns = ({
|
|||
label: 'Nome',
|
||||
mandatory: true,
|
||||
head: <PeopleSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updatePersonContactFormAction}
|
||||
href={`/campanha/pessoas/${row.contactID}`}
|
||||
permanent
|
||||
/>
|
||||
{row.party ? <span className="shrink-0 text-muted-foreground">({row.party})</span> : null}
|
||||
</div>
|
||||
),
|
||||
cell: (row) => {
|
||||
const subline = peopleNameSubline(row)
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updatePersonContactFormAction}
|
||||
href={`/campanha/pessoas/${row.contactID}`}
|
||||
permanent
|
||||
/>
|
||||
{row.party ? (
|
||||
<span className="shrink-0 text-muted-foreground">({row.party})</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subline ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subline}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import {
|
|||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
municipalityStateDeputyCreateSchema,
|
||||
stateDeputyAdvisorMembershipSchema,
|
||||
stateDeputyBallotNameUpdateSchema,
|
||||
stateDeputyCreateSchema,
|
||||
stateDeputyMunicipalitiesBatchSchema,
|
||||
stateDeputyPartyUpdateSchema,
|
||||
stateDeputyUpdateSchema,
|
||||
type MunicipalityStateDeputyCreateInput,
|
||||
type StateDeputyAdvisorMembershipInput,
|
||||
type StateDeputyBallotNameUpdateInput,
|
||||
type StateDeputyCreateInput,
|
||||
type StateDeputyMunicipalitiesBatchInput,
|
||||
type StateDeputyPartyUpdateInput,
|
||||
|
|
@ -52,6 +54,7 @@ const stateDeputyPolicy: StaffEntityPolicy = {
|
|||
type StateDeputyCreationData = {
|
||||
name: string
|
||||
party?: string | null
|
||||
ballotName?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +88,7 @@ const createStateDeputyWithContact = async (
|
|||
data: hookFilledCreateData<'stateDeputy'>({
|
||||
contact: contact.id,
|
||||
...(data.party === undefined ? {} : { party: data.party }),
|
||||
...(data.ballotName === undefined ? {} : { ballotName: data.ballotName }),
|
||||
...(data.notes === undefined ? {} : { notes: data.notes }),
|
||||
}),
|
||||
depth: 0,
|
||||
|
|
@ -152,6 +156,25 @@ export const updateStateDeputyPartyRecord = async (
|
|||
)
|
||||
}
|
||||
|
||||
/** C129 — inline "Nome de legenda" edit, same single-field shape as the party twin. */
|
||||
export const updateStateDeputyBallotNameRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: StateDeputyBallotNameUpdateInput,
|
||||
) => {
|
||||
const { id, ballotName } = stateDeputyBallotNameUpdateSchema.parse(input)
|
||||
return runStaffEntityMutation(payload, actor, stateDeputyPolicy, (currentActor) =>
|
||||
payload.update({
|
||||
collection: 'stateDeputy',
|
||||
id,
|
||||
data: { ballotName },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const updateStateDeputyContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -239,6 +262,13 @@ export const updateStateDeputyParty = async (input: StateDeputyPartyUpdateInput)
|
|||
return updated
|
||||
}
|
||||
|
||||
export const updateStateDeputyBallotName = async (input: StateDeputyBallotNameUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const updated = await updateStateDeputyBallotNameRecord(payload, actor, input)
|
||||
revalidateStateDeputyPaths(updated.id)
|
||||
return updated
|
||||
}
|
||||
|
||||
export const updateStateDeputyContact = async (input: ContactFieldUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const updated = await updateStateDeputyContactRecord(payload, actor, input)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,19 @@ export const StateDeputy: CollectionConfig = {
|
|||
maxLength: 32,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
// C129 — the ballot name ("nome de legenda") the campaign will use on the
|
||||
// urna. Optional identity text, never a search/sort/facet key: the real
|
||||
// name stays the person's key everywhere. No index — nothing queries it.
|
||||
name: 'ballotName',
|
||||
type: 'text',
|
||||
label: 'Nome de legenda',
|
||||
maxLength: 30,
|
||||
admin: {
|
||||
description:
|
||||
'Como o nome da dobradinha deve aparecer na urna (ex.: apelido ou abreviação).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'city'
|
||||
export type CampaignInlineEditableField =
|
||||
| 'name'
|
||||
| 'email'
|
||||
| 'phone'
|
||||
| 'party'
|
||||
| 'ballotName'
|
||||
| 'city'
|
||||
|
||||
type CampaignInlineEditableCellProps = {
|
||||
recordId: number
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ type StateDeputyContactSectionProps = {
|
|||
stateDeputyId: number
|
||||
name: string
|
||||
email: string | null
|
||||
/** C129 — the "nome de legenda" (ballot name), discreet under the name. */
|
||||
ballotName: string | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
phones: string[]
|
||||
}
|
||||
|
|
@ -15,6 +17,7 @@ type StateDeputyContactSectionProps = {
|
|||
export const StateDeputyContactSection = ({
|
||||
stateDeputyId,
|
||||
name,
|
||||
ballotName,
|
||||
email,
|
||||
phones,
|
||||
}: StateDeputyContactSectionProps) => (
|
||||
|
|
@ -26,15 +29,20 @@ export const StateDeputyContactSection = ({
|
|||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Nome</dt>
|
||||
<dd>
|
||||
<CampaignInlineEditableCell
|
||||
recordId={stateDeputyId}
|
||||
recordIdField="stateDeputyId"
|
||||
field="name"
|
||||
value={name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${stateDeputyId}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<CampaignInlineEditableCell
|
||||
recordId={stateDeputyId}
|
||||
recordIdField="stateDeputyId"
|
||||
field="name"
|
||||
value={name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${stateDeputyId}`}
|
||||
/>
|
||||
{ballotName ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{ballotName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type StateDeputyFormProps = {
|
|||
initial?: {
|
||||
id: number
|
||||
party: string | null
|
||||
ballotName: string | null
|
||||
notes: string | null
|
||||
}
|
||||
initialName?: string
|
||||
|
|
@ -59,6 +60,17 @@ export const StateDeputyForm = ({ formAction, initial, initialName }: StateDeput
|
|||
placeholder="Ex.: PT"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="state-deputy-ballot-name">Nome de legenda</FieldLabel>
|
||||
<Input
|
||||
id="state-deputy-ballot-name"
|
||||
name="ballotName"
|
||||
maxLength={30}
|
||||
defaultValue={initial?.ballotName ?? undefined}
|
||||
className="min-h-11"
|
||||
placeholder="Ex.: Sollinha"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="state-deputy-notes">Observações</FieldLabel>
|
||||
<Textarea
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[
|
|||
// day is phone/WhatsApp. Anyone can re-enable it from the picker, and a
|
||||
// device with an existing choice keeps it (stored wins over default).
|
||||
liderancas: ['email'],
|
||||
dobradinhas: ['email'],
|
||||
// C129 — the "Nome de legenda" column starts hidden: the subline under the
|
||||
// name is the always-on display, and the column is the opt-in edit surface
|
||||
// (same email-column precedent: display carries the day, editing stays reachable).
|
||||
dobradinhas: ['email', 'ballotName'],
|
||||
pessoas: ['email'],
|
||||
apoiadores: ['email'],
|
||||
assessores: ['email'],
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
|
|||
export const stateDeputyCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
party: trimmedOptionalText(32),
|
||||
ballotName: trimmedOptionalText(30),
|
||||
notes: trimmedOptionalText(4000),
|
||||
})
|
||||
|
||||
export const stateDeputyUpdateSchema = z.object({
|
||||
id: positiveRelationshipId,
|
||||
party: trimmedNullableText(32),
|
||||
ballotName: trimmedNullableText(30),
|
||||
notes: trimmedNullableText(4000),
|
||||
})
|
||||
|
||||
|
|
@ -28,6 +30,12 @@ export const stateDeputyPartyUpdateSchema = z.object({
|
|||
party: trimmedNullableText(32),
|
||||
})
|
||||
|
||||
/** C129 — delta write for the "Nome de legenda" column of `/campanha/dobradinhas`. */
|
||||
export const stateDeputyBallotNameUpdateSchema = z.object({
|
||||
id: positiveRelationshipId,
|
||||
ballotName: trimmedNullableText(30),
|
||||
})
|
||||
|
||||
export const STATE_DEPUTY_INVALID_CONTACT_MESSAGE = 'Contato da dobradinha inválido.'
|
||||
export const STATE_DEPUTY_INVALID_FIELD_MESSAGE = 'Campo de dobradinha inválido.'
|
||||
|
||||
|
|
@ -128,6 +136,7 @@ export const STATE_DEPUTY_ADVISOR_SAFE_MESSAGES = [
|
|||
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
|
||||
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
|
||||
export type StateDeputyPartyUpdateInput = z.input<typeof stateDeputyPartyUpdateSchema>
|
||||
export type StateDeputyBallotNameUpdateInput = z.input<typeof stateDeputyBallotNameUpdateSchema>
|
||||
export type StateDeputyMunicipalitiesBatchInput = z.input<
|
||||
typeof stateDeputyMunicipalitiesBatchSchema
|
||||
>
|
||||
|
|
|
|||
13502
src/migrations/20260811_234822_add_state_deputy_ballot_name.json
Normal file
13502
src/migrations/20260811_234822_add_state_deputy_ballot_name.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "state_deputy" ADD COLUMN "ballot_name" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "state_deputy" DROP COLUMN "ballot_name";`)
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ import * as migration_20260811_155116_add_lembranca_support_status from './20260
|
|||
import * as migration_20260811_183016_drop_activity_task_due from './20260811_183016_drop_activity_task_due';
|
||||
import * as migration_20260811_205157_add_google_calendar_push_channel from './20260811_205157_add_google_calendar_push_channel';
|
||||
import * as migration_20260811_213648_add_activity_last_mirrored_change_at from './20260811_213648_add_activity_last_mirrored_change_at';
|
||||
import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260811_234822_add_state_deputy_ballot_name';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -330,4 +331,9 @@ export const migrations = [
|
|||
down: migration_20260811_213648_add_activity_last_mirrored_change_at.down,
|
||||
name: '20260811_213648_add_activity_last_mirrored_change_at',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_234822_add_state_deputy_ballot_name.up,
|
||||
down: migration_20260811_234822_add_state_deputy_ballot_name.down,
|
||||
name: '20260811_234822_add_state_deputy_ballot_name',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -508,6 +508,10 @@ export interface StateDeputy {
|
|||
contact: number | Contact;
|
||||
slug: string;
|
||||
party?: string | null;
|
||||
/**
|
||||
* Como o nome da dobradinha deve aparecer na urna (ex.: apelido ou abreviação).
|
||||
*/
|
||||
ballotName?: string | null;
|
||||
notes?: string | null;
|
||||
advisors?: (number | CampaignUser)[] | null;
|
||||
createdBy?: (number | null) | CampaignUser;
|
||||
|
|
@ -1714,6 +1718,7 @@ export interface StateDeputySelect<T extends boolean = true> {
|
|||
contact?: T;
|
||||
slug?: T;
|
||||
party?: T;
|
||||
ballotName?: T;
|
||||
notes?: T;
|
||||
advisors?: T;
|
||||
createdBy?: T;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export type PeopleRowViewModel = {
|
|||
city: string | null
|
||||
/** `stateDeputy.party` — the one party source (at most one dobradinha per ficha). */
|
||||
party: string | null
|
||||
/** C129 — `stateDeputy.ballotName`, the "nome de legenda" for the urna. */
|
||||
ballotName: string | null
|
||||
leadershipID: number | null
|
||||
leadershipMunicipalityIDs: number[]
|
||||
supportStatus: SupportStatus | null
|
||||
|
|
@ -73,7 +75,21 @@ export type PeopleRowViewModel = {
|
|||
assessorados: Array<{ id: number; name: string }>
|
||||
}
|
||||
|
||||
/** Merge intermediate: the ids the loader resolves into `assessoradoNames`. */
|
||||
/**
|
||||
* C129 — the Name-cell subline of `/campanha/pessoas`: the dobradinha's
|
||||
* "nome de legenda" (ballot name), discreet under the real name, when present.
|
||||
*
|
||||
* C130 extends this with the base fallback — the FINAL shared rule for the
|
||||
* second line is `ballotName ?? city` (legenda overrides base), the two plans
|
||||
* (`pessoas-nome-de-legenda-dobradinha.md` / `pessoas-tabela-desktop-ajustes.md`)
|
||||
* pin it, and this function is the single seam: C130 changes one expression
|
||||
* here (and its unit spec), never markup. Until C130 lands, this branch keeps
|
||||
* the accept "sem nome de legenda, nada muda na linha" — null when absent.
|
||||
*/
|
||||
export const peopleNameSubline = (person: MergedPerson | PeopleRowViewModel): string | null =>
|
||||
person.ballotName
|
||||
|
||||
/** The merge intermediate carries every field the list/detail row needs. */
|
||||
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
|
||||
leadershipAdvisorIDs: number[]
|
||||
deputyAdvisorIDs: number[]
|
||||
|
|
@ -100,6 +116,7 @@ export type PeopleDeputySource = {
|
|||
email: string | null
|
||||
city: string | null
|
||||
party: string | null
|
||||
ballotName: string | null
|
||||
municipalityIDs: number[]
|
||||
advisorIDs: number[]
|
||||
}
|
||||
|
|
@ -158,6 +175,7 @@ export const toPeopleDeputySource = (
|
|||
email: contact.email,
|
||||
city: contact.city,
|
||||
party: doc.party ?? null,
|
||||
ballotName: doc.ballotName ?? null,
|
||||
municipalityIDs: municipalityIdsByDeputy.get(doc.id) ?? [],
|
||||
advisorIDs: uniqueRelationshipIds(doc.advisors),
|
||||
}
|
||||
|
|
@ -201,6 +219,7 @@ export const toPeopleRowViewModel = (
|
|||
email: person.email,
|
||||
city: person.city,
|
||||
party: person.party,
|
||||
ballotName: person.ballotName,
|
||||
leadershipID: person.leadershipID,
|
||||
leadershipMunicipalityIDs: person.leadershipMunicipalityIDs,
|
||||
supportStatus: person.supportStatus,
|
||||
|
|
@ -234,6 +253,7 @@ export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[]
|
|||
email: null,
|
||||
city: null,
|
||||
party: null,
|
||||
ballotName: null,
|
||||
leadershipID: null,
|
||||
leadershipMunicipalityIDs: [],
|
||||
supportStatus: null,
|
||||
|
|
@ -273,6 +293,7 @@ export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[]
|
|||
person.city = deputy.city
|
||||
}
|
||||
person.party = deputy.party
|
||||
person.ballotName = deputy.ballotName
|
||||
person.deputyID = deputy.id
|
||||
person.deputyMunicipalityIDs = [...deputy.municipalityIDs]
|
||||
person.deputyAdvisorIDs = [...deputy.advisorIDs]
|
||||
|
|
@ -515,7 +536,7 @@ export const loadPeopleListPageData = async (
|
|||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { contact: true, slug: true, party: true, advisors: true },
|
||||
select: { contact: true, slug: true, party: true, ballotName: true, advisors: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export type StateDeputyRowViewModel = {
|
|||
phone: string | null
|
||||
slug: string
|
||||
party: string | null
|
||||
/** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
|
||||
ballotName: string | null
|
||||
municipalityIDs: number[]
|
||||
leaderships: NamedRelationSummary[]
|
||||
/** B156 — the staff responsible for this dobradinha, names resolved. */
|
||||
|
|
@ -147,7 +149,7 @@ export const loadStateDeputyListPageData = async (
|
|||
limit: stateDeputyPageSize,
|
||||
page: state.page,
|
||||
sort: resolveStateDeputyListPayloadSort(sort, dir),
|
||||
select: { contact: true, slug: true, party: true, advisors: true },
|
||||
select: { contact: true, slug: true, party: true, ballotName: true, advisors: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
|
|
@ -221,6 +223,7 @@ export const loadStateDeputyListPageData = async (
|
|||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
party: doc.party ?? null,
|
||||
ballotName: doc.ballotName ?? null,
|
||||
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
|
||||
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
|
||||
advisors: advisorsByDeputy.get(doc.id) ?? [],
|
||||
|
|
@ -236,6 +239,8 @@ export type StateDeputySummary = {
|
|||
name: string
|
||||
slug: string
|
||||
party: string | null
|
||||
/** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
|
||||
ballotName: string | null
|
||||
}
|
||||
|
||||
export type StateDeputyDetailViewModel = StateDeputySummary & {
|
||||
|
|
@ -312,6 +317,7 @@ export const loadStateDeputyDetail = async (
|
|||
id: stateDeputy.id,
|
||||
slug: stateDeputy.slug,
|
||||
party: stateDeputy.party ?? null,
|
||||
ballotName: stateDeputy.ballotName ?? null,
|
||||
notes: stateDeputy.notes ?? null,
|
||||
municipalities: municipalities.docs.map((municipality) => ({
|
||||
id: municipality.id,
|
||||
|
|
@ -339,7 +345,7 @@ export const loadStateDeputySummaries = async (
|
|||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'contact.name',
|
||||
select: { contact: true, slug: true, party: true },
|
||||
select: { contact: true, slug: true, party: true, ballotName: true },
|
||||
// Intentional admin bypass: id lookups for the home-search card, no
|
||||
// actor-scoping possible or needed (B52 precedent).
|
||||
overrideAccess: true,
|
||||
|
|
@ -353,6 +359,7 @@ export const loadStateDeputySummaries = async (
|
|||
name: stateDeputyContactSummary(doc.contact).name,
|
||||
slug: doc.slug,
|
||||
party: doc.party ?? null,
|
||||
ballotName: doc.ballotName ?? null,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,9 @@ type VotePledgeInput = Partial<
|
|||
type OrganizationInput = Partial<
|
||||
Pick<Organization, 'name' | 'slug' | 'kind' | 'notes' | 'municipalities'>
|
||||
>
|
||||
type StateDeputyInput = Partial<Pick<StateDeputy, 'contact' | 'slug' | 'party' | 'notes'>> & {
|
||||
type StateDeputyInput = Partial<
|
||||
Pick<StateDeputy, 'contact' | 'slug' | 'party' | 'ballotName' | 'notes'>
|
||||
> & {
|
||||
name?: string
|
||||
}
|
||||
type CampaignDemandInput = Partial<
|
||||
|
|
|
|||
92
tests/int/campaignStateDeputyBallotName.int.spec.ts
Normal file
92
tests/int/campaignStateDeputyBallotName.int.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { updateStateDeputyBallotNameRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('updateStateDeputyBallotNameRecord (C129)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('lets staff set the ballot name on a dobradinha', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const updated = await updateStateDeputyBallotNameRecord(payload, coordinator, {
|
||||
id: stateDeputy.id,
|
||||
ballotName: 'Ana do Povo',
|
||||
})
|
||||
|
||||
expect(updated.ballotName).toBe('Ana do Povo')
|
||||
|
||||
const reloaded = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputy.id,
|
||||
depth: 0,
|
||||
select: { ballotName: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(reloaded.ballotName).toBe('Ana do Povo')
|
||||
})
|
||||
|
||||
it('clears the ballot name when sent null (empty cell)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy({ ballotName: 'Nome da Urna' })
|
||||
|
||||
const updated = await updateStateDeputyBallotNameRecord(payload, coordinator, {
|
||||
id: stateDeputy.id,
|
||||
ballotName: null,
|
||||
})
|
||||
|
||||
expect(updated.ballotName).toBeNull()
|
||||
})
|
||||
|
||||
it('lets an advisor edit the ballot name of a dobradinha (staff scope, no carteira gate)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const updated = await updateStateDeputyBallotNameRecord(payload, advisor, {
|
||||
id: stateDeputy.id,
|
||||
ballotName: 'Apelido da Mesa',
|
||||
})
|
||||
|
||||
expect(updated.ballotName).toBe('Apelido da Mesa')
|
||||
})
|
||||
|
||||
it('refuses a leader (staff gate)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
updateStateDeputyBallotNameRecord(payload, leader, {
|
||||
id: stateDeputy.id,
|
||||
ballotName: 'Forçado',
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('creates a dobradinha with the ballot name through the fixture shape', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const stateDeputy = await fixtures.createStateDeputy({ ballotName: 'Nome desde a criação' })
|
||||
|
||||
expect(stateDeputy.ballotName).toBe('Nome desde a criação')
|
||||
})
|
||||
})
|
||||
|
|
@ -94,13 +94,7 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
})
|
||||
|
||||
it('hides the email column by default on every person list (B197)', () => {
|
||||
for (const listId of [
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'pessoas',
|
||||
'apoiadores',
|
||||
'assessores',
|
||||
] as const) {
|
||||
for (const listId of ['liderancas', 'pessoas', 'apoiadores', 'assessores'] as const) {
|
||||
expect(resolveCampaignColumnVisibility(listId, {})).toEqual({
|
||||
listId,
|
||||
hiddenColumnIds: ['email'],
|
||||
|
|
@ -108,6 +102,13 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('hides the Nome de legenda column by default on dobradinhas too (C129)', () => {
|
||||
expect(resolveCampaignColumnVisibility('dobradinhas', {})).toEqual({
|
||||
listId: 'dobradinhas',
|
||||
hiddenColumnIds: ['email', 'ballotName'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a stored choice on a person list winning over the email default (B197)', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', { liderancas: [] })).toEqual({
|
||||
listId: 'liderancas',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
filterPeopleRows,
|
||||
mergePeopleSources,
|
||||
peopleFilterFacetsFromRows,
|
||||
peopleNameSubline,
|
||||
scopePeopleRows,
|
||||
sortPeopleRows,
|
||||
type MergedPerson,
|
||||
|
|
@ -34,6 +35,7 @@ const source: PeopleMergeSources = {
|
|||
email: 'ana@example.com',
|
||||
city: 'Camaçari',
|
||||
party: 'PCdoB',
|
||||
ballotName: 'Ana do Povo',
|
||||
municipalityIDs: [5],
|
||||
advisorIDs: [22, 23],
|
||||
},
|
||||
|
|
@ -106,6 +108,13 @@ describe('mergePeopleSources (C100)', () => {
|
|||
expect(ana.capacityMunicipalityIDs).toEqual([5])
|
||||
})
|
||||
|
||||
it('carries the ballot name from the dobradinha (C129)', () => {
|
||||
const ana = byContact(mergePeopleSources(source), 101)
|
||||
expect(ana.ballotName).toBe('Ana do Povo')
|
||||
const maria = byContact(mergePeopleSources(source), 100)
|
||||
expect(maria.ballotName).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a staff account with an EMPTY carteira as a capacity (gate 2026-08-09)', () => {
|
||||
const joao = byContact(mergePeopleSources(source), 102)
|
||||
expect(joao.staff).toHaveLength(1)
|
||||
|
|
@ -316,6 +325,24 @@ describe('sortPeopleRows (C117)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('peopleNameSubline (C129)', () => {
|
||||
it('returns the ballot name when the dobradinha has one', () => {
|
||||
const ana = byContact(mergePeopleSources(source), 101)
|
||||
expect(peopleNameSubline(ana)).toBe('Ana do Povo')
|
||||
})
|
||||
|
||||
it('returns null when the dobradinha has no ballot name (nothing changes in the line)', () => {
|
||||
const withoutBallotName = mergePeopleSources({
|
||||
...source,
|
||||
deputies: [{ ...source.deputies[0]!, ballotName: null }],
|
||||
})
|
||||
const ana = byContact(withoutBallotName, 101)
|
||||
expect(peopleNameSubline(ana)).toBeNull()
|
||||
const maria = byContact(mergePeopleSources(source), 100)
|
||||
expect(peopleNameSubline(maria)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('peopleFilterFacetsFromRows', () => {
|
||||
it('unions the capacity municipalities and statuses of the scoped rows with the selected values', () => {
|
||||
const rows = mergePeopleSources(source)
|
||||
|
|
|
|||
Loading…
Reference in a new issue