Merge pull request #709 from fsolla/C129-pessoas-nome-de-legenda-da-dobradinha-campo-display-dis

C129: nome de legenda da dobradinha — campo + display discreto sob o nome
This commit is contained in:
Francisco Solla 2026-08-12 13:10:04 -03:00 committed by GitHub
commit ac405ea2ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 14040 additions and 51 deletions

View file

@ -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).

View 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).

View file

@ -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')

View file

@ -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,
}}
/>

View file

@ -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,9 @@ const stateDeputyContactSafeMessages = [
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
/** The party and ballotName inline cells share the same refusal vocabulary. */
const stateDeputyInlineFieldSafeMessages = stateDeputyPartySafeMessages
/** Per-field Contact edit for B163 (lista + ficha). */
export const updateStateDeputyContactFormAction = async (
_state: CampaignFormActionState,
@ -103,6 +107,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: stateDeputyInlineFieldSafeMessages,
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,

View file

@ -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}`,

View file

@ -13,6 +13,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@ -70,6 +71,7 @@ import {
setLeadershipStateDeputyMembershipFormAction,
setStateDeputyAdvisorMembershipFormAction,
setStateDeputyMunicipalitiesFormAction,
updateStateDeputyBallotNameFormAction,
updateStateDeputyContactFormAction,
updateStateDeputyPartyFormAction,
} from './formActions'
@ -121,17 +123,27 @@ 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. A legenda identical to the real name is skipped (same
policy as `peopleNameSubline`). */}
<CampaignNameSubline
value={row.ballotName !== row.name ? row.ballotName : null}
srLabel="Nome de legenda"
/>
</div>
),
},
{
@ -159,6 +171,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',

View file

@ -21,6 +21,7 @@ import {
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import {
CampaignTable,
CampaignTableHead,
@ -54,7 +55,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 +175,29 @@ 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>
<CampaignNameSubline value={subline} srLabel="Nome de legenda" />
</div>
)
},
},
{
id: 'contact',

View file

@ -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)

View file

@ -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',

View file

@ -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

View file

@ -0,0 +1,19 @@
/**
* The "discreto sob o nome" secondary line (C129): the same muted small text
* under a name in every surface — people table, dobradinhas list and ficha.
* The position is the visual label; an optional `srLabel` names the line for
* screen readers (the muted/small styling does not exist in a linear read).
*/
export const CampaignNameSubline = ({
value,
srLabel,
}: {
value: string | null | undefined
srLabel?: string
}) =>
value ? (
<span className="truncate text-xs text-muted-foreground">
{srLabel ? <span className="sr-only">{srLabel} </span> : null}
{value}
</span>
) : null

View file

@ -2,12 +2,15 @@
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { CampaignNameSubline } from '@/components/campaign/shared/CampaignNameSubline'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
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 +18,7 @@ type StateDeputyContactSectionProps = {
export const StateDeputyContactSection = ({
stateDeputyId,
name,
ballotName,
email,
phones,
}: StateDeputyContactSectionProps) => (
@ -26,15 +30,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 !== name ? (
<CampaignNameSubline value={ballotName} srLabel="Nome de legenda" />
) : null}
</div>
</dd>
</div>
<div className="flex flex-col gap-1">

View file

@ -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

View file

@ -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'],

View file

@ -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
>

File diff suppressed because it is too large Load diff

View file

@ -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";`)
}

View file

@ -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',
},
];

View file

@ -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;

View file

@ -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,26 @@ 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.
* A ballot name identical to the real name is redundant noise — it reads as a
* duplicated line, so it is skipped (a mesa registering the full name as urn
* name is plausible data entry).
*
* 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: Pick<MergedPerson, 'name' | 'ballotName'>,
): string | null =>
person.ballotName && person.ballotName !== person.name ? person.ballotName : null
/** The merge intermediate carries every field the list/detail row needs. */
export type MergedPerson = Omit<PeopleRowViewModel, 'assessoradoNames'> & {
leadershipAdvisorIDs: number[]
deputyAdvisorIDs: number[]
@ -100,6 +121,7 @@ export type PeopleDeputySource = {
email: string | null
city: string | null
party: string | null
ballotName: string | null
municipalityIDs: number[]
advisorIDs: number[]
}
@ -158,6 +180,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 +224,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 +258,7 @@ export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[]
email: null,
city: null,
party: null,
ballotName: null,
leadershipID: null,
leadershipMunicipalityIDs: [],
supportStatus: null,
@ -273,6 +298,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 +541,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,
}),

View file

@ -91,7 +91,7 @@ export const loadPersonDetail = 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,
}),

View file

@ -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) ?? [],
@ -241,6 +244,8 @@ export type StateDeputySummary = {
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
/** C129 — the "nome de legenda" (ballot name), shown discreet under the name. */
ballotName: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
notes: string | null
@ -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,

View file

@ -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<

View file

@ -0,0 +1,110 @@
// @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()
const reloaded = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { ballotName: true },
overrideAccess: true,
})
expect(reloaded.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')
const reloaded = await payload.findByID({
collection: 'stateDeputy',
id: stateDeputy.id,
depth: 0,
select: { ballotName: true },
overrideAccess: true,
})
expect(reloaded.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')
})
})

View file

@ -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',

View file

@ -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,33 @@ 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()
})
it('skips a ballot name identical to the real name (redundant duplicate line)', () => {
const withIdenticalBallotName = mergePeopleSources({
...source,
deputies: [{ ...source.deputies[0]!, ballotName: 'Ana Lima' }],
})
const ana = byContact(withIdenticalBallotName, 101)
expect(peopleNameSubline(ana)).toBeNull()
})
})
describe('peopleFilterFacetsFromRows', () => {
it('unions the capacity municipalities and statuses of the scoped rows with the selected values', () => {
const rows = mergePeopleSources(source)