Merge pull request #370 from fsolla/agent/362-dobradinhas-coluna-municipios

B157 — coluna de dobradinhas na lista de municípios (+ criação inline)
This commit is contained in:
Francisco Solla 2026-08-04 04:07:46 -03:00 committed by GitHub
commit 6f81fd3e82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1602 additions and 142 deletions

View file

@ -0,0 +1,145 @@
# Impl: B157 — Coluna de dobradinhas na lista de municípios (+ criação inline)
Status: aprovado
Atualizado em: 2026-08-04
Issue: #362
Intenção: docs/plans/dobradinhas-coluna-municipios.md (mergeada no main em 2026-08-04, PR #363 — **revisado contra ela após sync**)
Appetite restante: ~1–1,25 dia eng (coluna com display estilo assessores + editor popover/sheet + create inline com partido; sem migration)
## Leitura da intenção
- **Outcome:** na lista `/campanha/municipios`, uma coluna **"Dobradinhas"** entre "Assessores" e "Tendência" — vínculos de `municipality.stateDeputies` com **busca por nome e partido**, adicionar/remover por delta (auto-save, otimista com rollback + toast) e **criação inline** ("+ Criar dobradinha 'texto'", com `party` opcional via sintaxe **`Nome (PARTIDO)`**), criada e **automaticamente vinculada ao município**. Canvas: `~/.cursor/projects/teqo/canvases/plan-b157-ui-draft.canvas.tsx`.
- **Decisão de display do usuário (2026-08-04, gate):** o fechamento da célula segue o **estilo da coluna de Assessores** — agregado de círculos de perfil (iniciais, sobrepostos, máx. 3) com **hover mostrando o detalhe** (nome + partido), não chips na célula. Vazio = "—". O editor (adicionar/remover/criar) abre em **popover no desktop / sheet no mobile**, mesmo gesto do assessor (B27/B154).
- **O que NÃO negociar:**
- Coluna **somente para `isCampaignUnrestricted`** (coordenador + candidato). Assessor **não vê** (nem read-only — lê na ficha, `MunicipalityStrategyCard`); `leader` também não.
- `StateDeputy.name` é unique — nome já existente → erro + toast, sem duplicata.
- Não é formulário completo (notas, editar nome/partido inline) → `/campanha/dobradinhas/<slug>`; sem dedup por similaridade; sem filtro/ordenação por dobradinha (gatilho B29).
- Sem migration, sem collection nova, sem Consent novo, contratos de URL intactos; relação continua `municipality.stateDeputies`.
- **O que reavaliar (Direção no codebase é hipótese):** a intenção hipotetiza control bespoke (`MunicipalityListStateDeputiesControl` espelhando o B27/B154) + endpoint JSON `/campanha/municipios/state-deputies` + record `setMunicipalityStateDeputyMembershipRecord` + `loadStateDeputySummaries` extra. O codebase atual já tem a máquina compartilhada (`RelationChipCell`, extraída no B36/B37) e o caminho de escrita delta **já existe** (`setStateDeputyMunicipalitiesBatch`, B37). B157 monta em cima dos dois, com a **máquina compartilhada ganhando um modo "trigger"** (display fechado custom + overlay popover/sheet) para honrar o display estilo assessores sem duplicar ~400 linhas. **Correção de fato:** a intenção afirma que `canManageCampaignStaffField` é "restrito a `isCampaignUnrestricted`" — no código atual ele é **staff-wide** (o int spec do B37 prova assessor escrevendo). A decisão de produto (coluna só para unrestricted) é UI-gate, sem mudança de access; a superfície B37 (lista de dobradinhas) fica como está — fora deste item.
## Abordagem recomendada
```mermaid
flowchart LR
page["municipios/page.tsx<br/>+ loadStateDeputyOptions (1×, staff)"] --> cols["municipalityListColumns(props)<br/>coluna stateDeputies · gate isCampaignUnrestricted"]
cols --> cell["MunicipalityStateDeputyRelationCell<br/>(wrapper · trigger avatar-stack + tooltip)"]
cell -->|"RelationChipCell modo trigger<br/>(popover desktop / sheet mobile)"| fa["setMunicipalityStateDeputiesFormAction<br/>(reusa setStateDeputyMunicipalitiesBatch invertido)"]
cell -->|criar inline| ca["createMunicipalityStateDeputyFormAction<br/>parse Nome (PARTIDO) · create+assign em 1 tx"]
fa --> reval["revalidateStateDeputyMunicipalityPaths<br/>(já existente, B37)"]
ca --> reval
```
**Opções consideradas:** A (modo "trigger" no `RelationChipCell` — display custom + popover/sheet — + reuso do B37 + seams de create) | B (control bespoke `MunicipalityListStateDeputiesControl` espelhando o B27/B154 + endpoint JSON + bridge — a hipótese literal da intenção) | C (chips na célula, container inline+drawer do `RelationChipCell` atual — minha 1ª proposta, **substituída pelo display estilo assessores**)
**Recomendação:** **A** — o display pedido (círculos + hover) é exatamente o trigger do control de assessores, mas a máquina por trás (delta otimista, undo, combobox ARIA, create, floor/cap) já é compartilhada no `RelationChipCell`. Em vez de duplicar o control do assessor, o `RelationChipCell` ganha um modo opcional: `trigger` (nó de display fechado) + `editorVariant` ('popover' | 'sheet') + `triggerTooltip` — o corpo do editor (chips removíveis + busca + criar) é o mesmo do Drawer de hoje, extraído uma vez. Sem as props novas, B36/B37 se comportam exatamente como hoje.
**Rejeitadas:** B — ~400 linhas de máquina duplicada (a mesma que B36/B37 extraíram para matar) + endpoint JSON + bridge de catálogo desnecessária (server action + `revalidatePath` ⇒ refresh RSC atualiza o catálogo de todas as linhas); C — chips na célula contrariam a decisão de display do gate.
### Decisões de engenharia
**D1 — Display fechado: estilo assessores (círculos + hover), sem chips na célula.**
- Trigger do `RelationChipCell` (modo trigger): **círculos de iniciais sobrepostos** (`Avatar`/`AvatarFallback` do kit + `campaignUserInitials` — helper genérico de iniciais; `-space-x-2`, `size-8`, máx. 3 visíveis — mesmo visual do `MunicipalityAdvisorAvatarStack`), com `sr-only` listando os nomes.
- **Hover = detalhe:** `tooltipContent` do overlay (popover) com uma linha por vínculo — **"Nome (Partido)"** (ex.: "Fulaninho (PT)"), mesmo formato do `formatAdvisorNamesTooltip` mas com partido (helper novo no wrapper). Vazio → "—" (aceite da intenção; sem `MissingAdvisorBadge` — aquilo é semântica de assessor/prioridade).
- Link para a ficha `/campanha/dobradinhas/<slug>`: affordance move para o **editor** (os chips removíveis do popover/sheet são links — o `RelationChipCell` já renderiza `href` nos chips), mesmo contrato da coluna de assessores (célula fechada sem link).
- Opções consideradas: A (acima) | B (chips na célula, como na lista de dobradinhas) | C (círculos sem tooltip, nome só no editor). Recomendação: **A** — é o pedido literal do gate ("círculos + hover para ver o detalhe"); B foi a proposta anterior, substituída; C perderia a leitura sem abrir (o hover do assessor existe justamente para isso — B23).
**D2 — Editor: modo "trigger" no `RelationChipCell` (popover desktop / sheet mobile).**
- Novas props opcionais no `RelationChipCell`: `trigger?: (chips, isPending) => ReactNode` (display fechado custom), `editorVariant?: 'popover' | 'sheet'`, `triggerLabel: string`, `triggerTooltip?: ReactNode`. Com `trigger` presente: renderiza o nó custom envolvido num `CampaignCellEditOverlay` do variant pedido (precedente: o control de assessores recebe `variant="popover"` no desktop e `variant="sheet"` nos cards) com o **corpo do editor** (chips removíveis + combobox + criar) — o mesmo JSX do Drawer atual, extraído para um renderer compartilhado `editorBody`; sem `trigger`, comportamento inline+drawer atual intacto.
- Call sites: tabela desktop passa `editorVariant="popover"`; cards mobile `editorVariant="sheet"` (o `CampaignListSheetProvider` do `MunicipalityListMobileSection` já hospeda o sheet — **nenhum provider novo no desktop**, já que o popover não usa Drawer; remove o item de risco da revisão anterior).
- Opções: A (modo trigger na máquina compartilhada) | B (control bespoke completo) | C (manter inline+drawer com chips). Recomendação: **A** — mesmo gesto do assessor (popover/sheet) com a máquina compartilhada; B duplica; C foi superado pelo display do gate.
**D3 — Busca: por nome E partido.**
Opções: A) `matchesNormalizedAtWordStart` sobre `plainName` **ou** `party` normalizados (WeakMap de labels pré-normalizados, padrão do irmão B36) | B) só nome (padrão B31).
Recomendação: **A** — a intenção e o canvas dizem "filtrando por nome e partido"; digitar "PT" acha "Fulano (PT)". Hit label = `Nome (Partido)`.
**D4 — Delta: reusar `setStateDeputyMunicipalitiesBatch` invertido.**
Opções: A) wrapper de form action `setMunicipalityStateDeputiesFormAction` na rota municipios mapeando `{ municipalityId, stateDeputyId, assigned }` → `setStateDeputyMunicipalitiesBatch({ stateDeputyId, municipalityIds: [municipalityId], assigned })` | B) record novo `setMunicipalityStateDeputyMembershipRecord` (a hipótese da intenção).
Recomendação: **A** — o record B37 já faz tudo (`acquireTextAdvisoryLocks(['municipality-state-deputies:{id}'])`, `user` + `overrideAccess: false`, cap via `nextStateDeputyIdsAfterMunicipalityMembership`, no-op sem revalidate, `revalidateStateDeputyMunicipalityPaths`). Wrapper novo ~20 linhas em `municipalityStaffFormActions.ts`. B é twin.
**D5 — Criação inline: parser `Nome (PARTIDO)` + seams no `RelationChipCell` + record atômico.**
- **Parser puro** `parseStateDeputyNameParty(text)` em **`src/lib/stateDeputyNameParty.ts`** (client-safe): extrai UM grupo `(...)` final → `{ name, party }`; sem parênteses → party `null`; partido trimado, ≤32. Usado pelo schema zod (server, autoridade) e pelo chip temp (cliente).
- **`municipalityStateDeputyCreateSchema`** (`src/lib/schemas/stateDeputy.ts`): `{ municipalityId, rawName }` → transform → `{ municipalityId, name, party }`, name 2..160 pós-parse, party ≤32; `(PT)` sozinho → name vazio → erro de validação (toast).
- **`createMunicipalityStateDeputyRecord`** (`actions/stateDeputy.ts`): 1 transação — `reloadStaffActor` (staff; `STATE_DEPUTY_STAFF_MESSAGE`) → lock `municipality-state-deputies:{id}` → `payload.create('stateDeputy', { name, ...(party ? { party } : {}) })` com `user`/`overrideAccess: false`, conflict (unique name/slug) → `STATE_DEPUTY_CONFLICT_MESSAGE` → leitura do município + delta (`nextStateDeputyIdsAfterMunicipalityMembership`) + update — espelho de `createMunicipalityAdvisorRecord` (B154): se a atribuição falhar, o create **rola junto** (sem órfão). Retorna `{ stateDeputy: { id, name, slug, party }, municipalitySlug }`; wrapper revalida via `revalidateStateDeputyMunicipalityPaths`.
- **`RelationChipCell`**: seams opcionais `createAction?` + `buildCreateFormData?`; quando `hits.length === 0` e query 2..160 → item **`Criar dobradinha “{query}”`** no lugar do "Nenhum resultado" (padrão B154), no corpo do editor (popover e drawer); chip temp sem remover (precedente B154); sucesso → swap para id real + delta otimista + mapa de criados (filtro `!ids.includes(id) && effectiveIds.includes(id)` — sem chip fantasma pós-remoção, sem duplicata pós-refresh); erro → revert + feedback/toast existentes. Zero mudança de comportamento sem as props novas.
- **Sem bridge provider** (diferença estrutural vs. B154): server action + `revalidatePath` ⇒ refresh RSC ⇒ `loadStateDeputyOptions` re-carrega ⇒ catálogo atualizado em todas as linhas.
**D6 — Dados: `stateDeputyIDs` no view model + catálogo como fonte única.**
Opções: A) `municipalityListSelect` += `stateDeputies: true`; `MunicipalityListViewModel` += `stateDeputyIDs: number[]`; página carrega `loadStateDeputyOptions(payload, user)` 1× e passa como prop (factory de colunas + mobile) | B) também `loadStateDeputySummaries` (hipótese da intenção).
Recomendação: **A** — o catálogo (dezenas de registros, `plainName`+`party`+`slug`) cobre display, tooltip e busca; zero query por linha; payload = ids inteiros. B é query duplicada (adiada no `/simplify` do B31).
**D7 — Coluna: id/label/description/picker + posição + gate + mobile.**
- `MunicipalityListColumnId` += `'stateDeputies'` (B17: id = chave do picker = cookie = description); `municipalityColumnLabels` += `'Dobradinhas'`; `municipalityColumnDescriptions` += entrada (B22).
- Posição: **entre `advisors` e `trend`** (decisão A da intenção, "grupo rede"); head sem sort/filtro (`CampaignTableHead` + description). **Sem `cellTooltip` na column def** — a célula embrulha o próprio tooltip no overlay (mesmo comentário da coluna de assessores — não dobrar no mesmo gesto).
- **Gate `isCampaignUnrestricted`** (coordenador + candidato — explícito na intenção; diferente do popover de assessores que é `isCoordinator`): coluna desktop, entrada mobile e entrada do picker só nesse branch. `MunicipalityListProps` += `isCampaignUnrestricted: boolean` (a página já computa o mesmo predicado para `canMoveEngagementLevel`).
- Mobile: entrada "Dobradinhas" no `dl` staff de `MunicipalityListMobileCards` com `editorVariant="sheet"` (o `CampaignListSheetProvider` do `MunicipalityListMobileSection` já hospeda o Drawer compartilhado — miss #52 respeitada).
**D8 — Revalidação e locks: nenhuma peça nova.** Delta e create revalidam via `revalidateStateDeputyMunicipalityPaths` (já em `actions/stateDeputy.ts`): lista de municípios + detalhe do município tocado + ficha da dobradinha. Lock `municipality-state-deputies:{id}` serializa toggles concorrentes na mesma linha.
### Componentes / mudanças
- **`src/lib/stateDeputyNameParty.ts`** (novo, puro): `parseStateDeputyNameParty` + unit tests (bordas: sem parênteses, `(PT)` sozinho, parênteses no meio, grupo final com espaços, party >32).
- **`src/lib/schemas/stateDeputy.ts`**: `municipalityStateDeputyCreateSchema` (com transform do parser).
- **`src/utilities/municipality/municipalityViewModels.ts`**: `municipalityListSelect` += `stateDeputies: true`; `MunicipalityListViewModel` += `stateDeputyIDs: number[]`; `toMunicipalityListViewModel` mapeia (padrão `advisorIDs`).
- **`src/utilities/municipality/municipalityLabels.ts`**: `MunicipalityListColumnId` += `'stateDeputies'`; `municipalityColumnDescriptions` += entrada.
- **`src/utilities/municipality/municipalityListUrl.ts`**: `municipalityColumnLabels` += `stateDeputies: 'Dobradinhas'`.
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`**: `createMunicipalityStateDeputyRecord` + wrapper `createMunicipalityStateDeputy` (revalida via `revalidateStateDeputyMunicipalityPaths`).
- **`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`**: `setMunicipalityStateDeputiesFormAction` (delta, reusa o batch) + `createMunicipalityStateDeputyFormAction` (create+assign; success devolve `stateDeputy` via o generic do `runCampaignFormAction`).
- **`src/components/campaign/shared/RelationChipCell.tsx`**: (1) **modo trigger** — props opcionais `trigger?`/`editorVariant?`/`triggerLabel?`/`triggerTooltip?`; corpo do editor extraído para renderer compartilhado (chips removíveis + combobox + criar) usado pelo Drawer atual e pelo overlay do modo trigger (`CampaignCellEditOverlay` com o variant pedido); (2) **seams de create** — `createAction?`/`buildCreateFormData?` + item "Criar…" + chip temp + swap + mapa de criados. Sem as props novas, comportamento atual idêntico.
- **`src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx`** (novo): wrapper fino — `buildChips` (label `Nome (Partido)`, href da ficha), `searchHits` (nome **ou** partido, WeakMap de labels), `buildFormData` (`municipalityId`+`stateDeputyId`+`assigned`), `buildCreateFormData` (`municipalityId`+`rawName`), **trigger display** (círculos de iniciais `Avatar`/`AvatarFallback` + `campaignUserInitials`, `-space-x-2`, máx. 3, `sr-only` com nomes; vazio "—") e **tooltip de detalhe** ("Nome (Partido)" por linha, helper `formatStateDeputyNamesTooltip`), copy pt-BR, `triggerLabel` "Editar dobradinhas em {nome}".
- **`src/components/campaign/municipality/MunicipalityList.tsx`**: coluna `stateDeputies` (gate `isCampaignUnrestricted`, entre `advisors` e `trend`); props novas (`stateDeputyOptions`, `stateDeputyCreateAction`, `isCampaignUnrestricted`); editor `variant="popover"` no desktop.
- **`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`** (+ `MobileSection`): entrada "Dobradinhas" no dl (gate unrestricted, `editorVariant="sheet"`); props repassadas.
- **`src/app/(campaign)/campanha/(app)/municipios/page.tsx`**: `loadStateDeputyOptions` no `Promise.all` (staff view); props na lista.
- **Migration:** sem migration (relação já existe desde E4R).
- **Access / Consent:** nenhuma peça nova; o create herda `canCreateStateDeputy` (staff) e o delta herda `canUpdateMunicipality` — ambos com `user`/`overrideAccess: false`; a intenção fica satisfeita no nível da UI (gate `isCampaignUnrestricted`). **Observação registrada (não escopo):** a API continua permitindo assessor escrever em `municipality.stateDeputies` (B37) — se produto quiser restringir, é item separado.
### Dados → forma (se aplicável)
- Forma: **tabela** — célula fechada com círculos de perfil + hover com o detalhe "Nome (Partido)" (decisão do gate; precedente `MunicipalityAdvisorAvatarStack`). Rejeitado: chips na célula (display anterior, superado); contagem ("3 dobradinhas" esconde o nome que decide); KPI/chart (dado categórico nominal).
- Busca: nome **e** partido (D3); vazio = "—" (aceite da intenção).
## Fases verificáveis
1. **Server + view model + testes de domínio** — parser puro + schemas, select/view model, record create+assign, wrappers de form action, revalidate; int tests (`campaignMunicipalityStateDeputyCreate.int.spec.ts`): coordinator/candidate criam+atribuem; assessor fora do escopo → **rollback do create**; leader recusado; nome duplicado → `STATE_DEPUTY_CONFLICT_MESSAGE`; parse do partido persiste (`party` correto); delta de 1 município via wrapper (no-op sem revalidate). `pnpm gate:fast` parcial.
2. **UI** — modo trigger + seams de create no `RelationChipCell` → wrapper (display + tooltip + busca) → coluna + picker + labels/descriptions + mobile; unit pins (`campaignComponents.unit.spec.ts`: fixtures `stateDeputyIDs: []`; **tooltip count staff segue 10** — a coluna nova só renderiza com `isCampaignUnrestricted: true`, novo teste com count 11; leader segue 4; `municipalityListDefaultProps` ganha `stateDeputyOptions: []` + `isCampaignUnrestricted: false` + stub create action; teste do display: iniciais + sr-only + vazio "—").
3. **Gates** — unit do parser; e2e espelhando o teste B154 (coordenador abre "Editar dobradinhas em {município}", cria "Cicrano (PCdoB)" inline, chip aparece no popover, busca por partido acha, "Criar" some após criar, remover grava) em `campaignMunicipalities.e2e.spec.ts`; `pnpm gate:fast` → `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Sort/filtro por dobradinhas no header** — gatilho B29 registrado na própria intenção; não inventar.
- **Coluna para assessor (read-only)** — corte explícito da intenção; não adicionar `cellTooltip`/variante.
- **Partido com formatação complexa** — regra do parser documentada (um grupo `(...)` final; resto é name); `(PT)` sozinho → erro de validação.
- **B155 (#359, in-progress)** — coluna irmã na mesma tabela; escopos disjuntos (ids/labels/view model próprios); B155 sem branch de implementação — conflitos mecânicos de merge apenas.
- **Bridge provider de criados (padrão B154)** — desnecessário (server action + revalidatePath).
- **Renomear/estender `LeadershipStateDeputyRelationCell`** — código landed B36/B37; sem ganho de produto.
- **`loadStateDeputySummaries` por IDs na lista** — query duplicada; o catálogo cobre.
- **Restringir access de assessor na API (B37)** — item separado; só registro no gate.
- **Adotar o modo trigger no control de assessores (B27/B154)** — o control bespoke continua como está; migrá-lo para o `RelationChipCell` é item próprio (tem seq/reconcile de endpoint JSON que o modo trigger não precisa).
## Riscos e mitigação
- **`RelationChipCell` é compartilhado (B36/B37)** — modo trigger e seams de create só com props opcionais; comportamento atual inalterado; pins de unit/integ cobrem os consumidores.
- **Modo trigger vs. drawer existente** — o corpo do editor é extraído UMA vez e compartilhado; testes existentes do drawer continuam valendo (mesmo renderer).
- **Chip temp × medição de overflow** — em modo trigger não há medição de linha (o display fechado é o trigger); o chip temp vive dentro do editor (chips + busca), sem remove (precedente B154).
- **Colisão de nome na criação inline** — busca vazia ⇒ nome fora do catálogo; conflito só em corrida → `STATE_DEPUTY_CONFLICT_MESSAGE` no `safeMessages`.
- **Guarda `sharedSheetHostConventions`** — só o sheet (mobile) exige provider, e o `MunicipalityListMobileSection` já tem; desktop usa popover, sem provider novo.
## Revisão na entrega (2026-08-04)
O plano acima executou como escrito, com ajustes do gate e do `/simplify` (3 revisores paralelos — qualidade, performance, reuso):
- **Gate do usuário:** display da coluna mudou para o estilo assessores (círculos + hover) — `RelationChipCell` ganhou o modo "trigger" (display fechado custom + overlay popover/sheet com o corpo do editor compartilhado) em vez de chips inline. Sem `CampaignListSheetProvider` novo no desktop (popover não usa Drawer).
- **Correção de fato:** Payload valida unique ANTES do insert com um `ValidationError` localizado ("O campo a seguir está inválido: name") — o pattern do policy não pega o caso normal de duplicata. O mapeamento de conflito foi movido para o dono (`mapStaffEntityConflict` em `campaignEntityActions.ts`, usado por `runStaffEntityMutation` e pelo record novo) — os formulários de organização/dobradinha herdam a mensagem de conflito por tabela.
- **`/simplify` fixados:** (1) raça do `createdChips` (create→remove rápido→re-add duplicava o chip com a mesma key) — o copy agora cai quando o catálogo (`baseIds`) resolve o id, sem depender de `ids` chegar; (2) a11y — os nomes atribuídos vão no `aria-label` do trigger ("Editar dobradinhas em X — Fulano (PT)"), padrão do assessor; `sr-only` morto removido; (3) cleanup do `createdChips` com bail de no-op (B36/B37 não pagam render pass); (4) supressão do "Criar…" por **igualdade normalizada** com chip atribuído (prefixo ainda permite criar nome novo) e gate para não rodar sem wiring de create; (5) fold "Nome (Partido)" em fonte única (`stateDeputyDisplayName` no lib; `option.name` do catálogo reusado); (6) `optionById` compartilhado por WeakMap entre linhas; (7) cap message no `safeMessages` do create; (8) statusMessage com "opção de criar" quando o único item é o create; (9) padding único do corpo do editor (16px nas duas superfícies); (10) `MunicipalityStateDeputyCreateAction` tipo único exportado; (11) `payload-types.ts` fora do diff (drift de regeneração não relacionado, revertido).
- **Adiados com gatilho (ver débitos):** extração de `AvatarStack` genérico (2º call site — regra do repo); migração do `itemLabel` do irmão B36 para `stateDeputyDisplayName` (próximo toque no arquivo); cache do catálogo (medição); bounds do schema via `pick` (wording pt-BR); adapter de busca em `lib/` (próximo par de relação); **observação de produto:** a API continua permitindo assessor escrever `municipality.stateDeputies` (B37) — a intenção supunha `isCampaignUnrestricted`; se produto quiser fechar, é item separado.
Gate completo verde (tsc, lint --max-warnings=0, format:check, check:cycles, knip com o P3 pré-existente, unit 1358/1358, int 573/573, e2e B157 + vizinhos, build). Aikido: CLI não disponível no ambiente — padrões idênticos aos do B37/B154 já escaneados.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (coluna entre Assessores e Tendência, display estilo assessores — círculos + hover com "Nome (Partido)", vazio "—", busca nome+partido, auto-save por delta otimista com rollback+toast, criar inline `Nome (PARTIDO)` com vínculo automático, gate `isCampaignUnrestricted`, unique name → toast)
- [ ] Invariantes AGENTS/engineering-standards (`overrideAccess: false` com `user`, transação no create+assign, sem migration, sem Consent, identificadores en/strings pt-BR, cap respeitado)
- [ ] Testes de domínio: int do create+assign (roles, rollback, conflito, partido), unit do parser e dos pins da lista; e2e do create inline
- [ ] Self-score decision-quality: decisões caras com rejeitadas (D1–D8) · cabe no appetite (~1–1,25 dia) · rabbit holes nomeados · depth check reusa `RelationChipCell`/batch B37/`runCampaignFormAction`/`withPayloadTransaction`/locks · intenção preservada (revisado contra `dobradinhas-coluna-municipios.md` + canvas + decisão de display do gate)

View file

@ -6,20 +6,34 @@ import {
setMunicipalityPoliticalTrend,
} from '@/app/(campaign)/campanha/actions/municipality'
import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import {
createMunicipalityStateDeputy,
setStateDeputyMunicipalitiesBatch,
type MunicipalityStateDeputyCreateResult,
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
optionalFormText,
optionalMunicipalitySlugFromForm,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
requiredRelationshipFormValue,
voteEstimateScenarioFromForm,
} from '@/lib/formData'
import { WIZARD_TREND_SAVED_MESSAGE } from '@/lib/politicalTrendWizardUi'
import {
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
parsePoliticalTrendStatusFormValue,
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
STATE_DEPUTY_NAME_REQUIRED_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import { WIZARD_SIGNAL_SAVED_MESSAGE } from '@/lib/wizardSignalUi'
import {
runCampaignFormAction,
@ -106,3 +120,66 @@ export const createMunicipalityListSignalFormAction = async (
},
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})
/**
* B157 — one chip toggle in the "Dobradinhas" column of `/campanha/municipios`.
* Deliberately NOT a new record: the B37 batch already owns the inverted write
* (`stateDeputyId` + `municipalityIds[]`), so this wrapper maps the owner-shaped
* delta of the cell (`municipalityId` + `stateDeputyId`) onto it — the lock,
* the advisor-scope check, the cap and the revalidation all stay where B37 put
* them (and `setStateDeputyMunicipalitiesBatch` already revalidates the list,
* the touched detail and the deputy's ficha).
*/
export const setMunicipalityStateDeputiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const municipalityId = requiredRelationshipFormValue(formData, 'municipalityId')
const stateDeputyId = requiredRelationshipFormValue(formData, 'stateDeputyId')
const assigned = requiredFormBoolean(formData, 'assigned')
await setStateDeputyMunicipalitiesBatch({
stateDeputyId,
municipalityIds: [municipalityId],
assigned,
})
return { message: assigned ? 'Dobradinha vinculada.' : 'Dobradinha removida.' }
},
safeMessages: STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
genericMessage:
'Não foi possível atualizar as dobradinhas. Verifique seu acesso e tente novamente.',
})
/**
* B157 — inline create from the "Dobradinhas" column: the raw search text
* (`Nome (PARTIDO)` syntax) is parsed and persisted with the automatic link to
* the município. The success state carries the created deputy so the cell can
* swap its optimistic chip without waiting for the RSC refresh.
*/
export const createMunicipalityStateDeputyFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
> =>
runCampaignFormAction({
execute: async () => {
const stateDeputy = await createMunicipalityStateDeputy({
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
rawName: requiredFormText(formData, 'rawName'),
})
return {
message: 'Dobradinha criada e vinculada.',
stateDeputy: stateDeputy.stateDeputy,
}
},
safeMessages: [
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
STATE_DEPUTY_NAME_REQUIRED_MESSAGE,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
],
genericMessage: 'Não foi possível criar a dobradinha. Tente novamente.',
})

View file

@ -25,6 +25,7 @@ import {
} from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
buildMunicipalityListVisitHref,
buildMunicipalityListVisitLabel,
@ -39,7 +40,11 @@ import {
getEligibleLeadershipOptions,
loadAdvisorSummaries,
} from '@/utilities/municipality/municipalityViewModels'
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
import {
createMunicipalityListSignalFormAction,
createMunicipalityStateDeputyFormAction,
setMunicipalityStateDeputiesFormAction,
} from './municipalityStaffFormActions'
export const metadata = campaignPageMetadataFromCatalog('municipios')
@ -82,14 +87,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
...filterFacets.advisorIDs,
]),
]
const [advisorSummaries, advisorOptions, leadershipOptions] = await Promise.all([
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
// Only the coordinator-only assign-advisors control consumes these.
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
// All staff edit the Lideranças column (scoped server-side); the leader
// never reaches this page (`gate: noLeader`).
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
])
const [advisorSummaries, advisorOptions, leadershipOptions, stateDeputyOptions] =
await Promise.all([
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
// Only the coordinator-only assign-advisors control consumes these.
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
// All staff edit the Lideranças column (scoped server-side); the leader
// never reaches this page (`gate: noLeader`).
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
// B157 — the Dobradinhas column is coordinator + candidate only; the
// catalog serves the avatar-stack display, the tooltip and the search.
canMoveEngagementLevel ? loadStateDeputyOptions(payload, user) : [],
])
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
const selectedAdvisorIDs = new Set(state.advisors ?? [])
@ -118,7 +127,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={municipalityListPickerColumns({ isStaffView })}
columns={municipalityListPickerColumns({
isStaffView,
isCampaignUnrestricted: canMoveEngagementLevel,
})}
/>
}
/>
@ -134,9 +146,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
isCampaignUnrestricted={canMoveEngagementLevel}
canMoveEngagementLevel={canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
stateDeputyOptions={stateDeputyOptions}
stateDeputyCommitAction={setMunicipalityStateDeputiesFormAction}
stateDeputyCreateAction={createMunicipalityStateDeputyFormAction}
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListSignalFormAction}
state={state}

View file

@ -8,16 +8,22 @@ import { uniqueRelationshipIds } from '@/lib/relationship'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
municipalityStateDeputyCreateSchema,
stateDeputyCreateSchema,
stateDeputyMunicipalitiesBatchSchema,
stateDeputyUpdateSchema,
type MunicipalityStateDeputyCreateInput,
type StateDeputyCreateInput,
type StateDeputyMunicipalitiesBatchInput,
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import {
mapStaffEntityConflict,
runStaffEntityMutation,
type StaffEntityPolicy,
} from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -204,3 +210,105 @@ export const setStateDeputyMunicipalitiesBatch = async (
}
return result
}
/**
* B157 — name-only (+ party via `Nome (PARTIDO)`) inline create from the
* "Dobradinhas" column of `/campanha/municipios`. Creates the `stateDeputy`
* and assigns it to the município in the SAME transaction: if the assign
* fails (out-of-scope município, cap), the create rolls back with it — no
* orphan, same contract as `createMunicipalityAdvisorRecord` (B154). The
* unique `name`/`slug` violation is mapped to the safe conflict message; the
* município write re-checks `canUpdateMunicipality` through the same
* `user`-threaded read as the B37 batch.
*/
export const createMunicipalityStateDeputyRecord = async (
payload: Payload,
actor: CampaignUser,
input: MunicipalityStateDeputyCreateInput,
) => {
const { municipalityId, name, party } = municipalityStateDeputyCreateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
await acquireTextAdvisoryLocks(payload, req, [
`municipality-state-deputies:${municipalityId}`,
])
let created
try {
created = await payload.create({
collection: 'stateDeputy',
data: hookFilledCreateData<'stateDeputy'>({ name, ...(party ? { party } : {}) }),
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
} catch (error) {
// Same translation as the shared owner (`runStaffEntityMutation`): the
// pattern covers the insert race, `ValidationError` covers Payload's
// pre-insert unique check — in this create path the only validation
// left after zod IS the unique name/slug check.
const conflict = mapStaffEntityConflict(error, stateDeputyPolicy)
if (conflict) throw conflict
throw error
}
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { stateDeputies: true, slug: true },
user: currentActor,
overrideAccess: false,
req,
})
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
uniqueRelationshipIds(municipality.stateDeputies),
created.id,
true,
)
// Unreachable — the created id cannot already be assigned — but mirrors
// the B154 twin's no-write return so the null branch is honest.
if (nextStateDeputyIDs !== null) {
await payload.update({
collection: 'municipality',
id: municipalityId,
data: { stateDeputies: nextStateDeputyIDs },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
}
return {
stateDeputy: {
id: created.id,
name: created.name,
slug: created.slug,
party: created.party ?? null,
},
municipalitySlug: typeof municipality.slug === 'string' ? municipality.slug : undefined,
}
},
{ beginFailureMessage: 'Não foi possível iniciar a criação da dobradinha.' },
)
}
export const createMunicipalityStateDeputy = async (input: MunicipalityStateDeputyCreateInput) => {
const { payload, actor } = await getCampaignActionContext()
const result = await createMunicipalityStateDeputyRecord(payload, actor, input)
if (result.municipalitySlug) {
revalidateStateDeputyMunicipalityPaths(result.stateDeputy.slug, [result.municipalitySlug])
}
return result
}
export type MunicipalityStateDeputyCreateResult = Awaited<
ReturnType<typeof createMunicipalityStateDeputyRecord>
>

View file

@ -1,6 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { STATE_DEPUTY_NAME_REQUIRED_MESSAGE } from '@/lib/schemas/stateDeputy'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import {
@ -21,7 +22,7 @@ const setCanonicalStateDeputySlug: CollectionBeforeValidateHook = ({
const name = trimmedText(data.name ?? originalDoc?.name)
const slug = slugify(name)
if (!slug) {
throw new APIError('Informe um nome com letras ou números.', 400)
throw new APIError(STATE_DEPUTY_NAME_REQUIRED_MESSAGE, 400)
}
if (operation === 'update' && data.name !== undefined && name !== originalDoc?.name) {
throw new APIError('O nome da dobradinha não pode ser alterado após a criação.', 409)

View file

@ -31,6 +31,10 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
@ -48,6 +52,7 @@ import {
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import {
formatTerritorialClassWhy,
municipalityColumnDescriptions,
@ -100,9 +105,15 @@ export type MunicipalityListProps = {
* quietly hiding the control from the candidate.
*/
canMoveEngagementLevel: boolean
/** B157 — the Dobradinhas column is coordinator + candidate only (intention aceite). */
isCampaignUnrestricted: boolean
advisorOptions: EligibleAdvisorOption[]
/** B155 — every leadership the actor may add, for the Lideranças popover. */
leadershipOptions: EligibleLeadershipOption[]
/** B157 — the dobradinha catalog: chips, tooltip and search all read it. */
stateDeputyOptions: StateDeputyRelationOption[]
stateDeputyCommitAction: MunicipalityStaffFormAction
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
columnFilterOptions: MunicipalityColumnFilterOptions
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
@ -152,12 +163,16 @@ const municipalityListColumns = ({
state,
isStaffView,
isCoordinator,
isCampaignUnrestricted,
canMoveEngagementLevel,
columnFilterOptions,
advisorNamesById,
advisorOptions,
leadershipNamesById,
leadershipOptions,
stateDeputyOptions,
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
}: MunicipalityListProps): Array<MunicipalityColumn> => [
{
@ -320,6 +335,35 @@ const municipalityListColumns = ({
? null
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
},
...(isCampaignUnrestricted
? ([
{
// B157 — "who doubles with us here", next to "who takes care of
// it": the advisors-style avatar stack + hover detail, with an
// editor (Popover on desktop / Drawer on mobile) that also
// creates dobradinhas inline. Coordinator + candidate only.
id: 'stateDeputies',
label: municipalityColumnLabels.stateDeputies,
head: (
<CampaignTableHead description={municipalityColumnDescriptions.stateDeputies}>
{municipalityColumnLabels.stateDeputies}
</CampaignTableHead>
),
cellClassName: 'max-w-56 whitespace-normal',
cell: (municipality) => (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
/>
),
},
] satisfies Array<MunicipalityColumn>)
: []),
{
// Right after "Assessores": the two relations of "who" in the
// município sit together, and both are editable by all staff (the
@ -453,7 +497,11 @@ const municipalityListColumns = ({
export const municipalityListPickerColumns = ({
isStaffView,
}: Pick<MunicipalityListProps, 'isStaffView'>): CampaignColumnPickerColumn[] => {
isCampaignUnrestricted,
}: Pick<
MunicipalityListProps,
'isStaffView' | 'isCampaignUnrestricted'
>): CampaignColumnPickerColumn[] => {
const base: CampaignColumnPickerColumn[] = [
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
{ id: 'region', label: municipalityColumnLabels.region },
@ -467,6 +515,9 @@ export const municipalityListPickerColumns = ({
{ id: 'level', label: municipalityColumnLabels.level },
{ id: 'advisors', label: municipalityColumnLabels.advisors },
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
...(isCampaignUnrestricted
? [{ id: 'stateDeputies', label: municipalityColumnLabels.stateDeputies }]
: []),
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
@ -482,8 +533,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
leadershipNamesById,
isStaffView,
isCoordinator,
isCampaignUnrestricted,
advisorOptions,
leadershipOptions,
stateDeputyOptions,
stateDeputyCommitAction,
stateDeputyCreateAction,
state,
} = props
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
@ -505,9 +560,13 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
leadershipNamesById={leadershipNamesById}
isStaffView={isStaffView}
isCoordinator={isCoordinator}
isCampaignUnrestricted={isCampaignUnrestricted}
canMoveEngagementLevel={props.canMoveEngagementLevel}
advisorOptions={advisorOptions}
leadershipOptions={leadershipOptions}
stateDeputyOptions={stateDeputyOptions}
stateDeputyCommitAction={stateDeputyCommitAction}
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={props.signalFormAction}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>

View file

@ -22,7 +22,12 @@ import { MunicipalityListTrendControl } from '@/components/campaign/municipality
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import {
MunicipalityStateDeputyRelationCell,
type MunicipalityStateDeputyCreateAction,
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { municipalityGeographyParts } from '@/utilities/municipality/municipalityLabels'
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
import type {
@ -46,10 +51,14 @@ export type MunicipalityListMobileCardsProps = {
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
isStaffView: boolean
isCoordinator: boolean
isCampaignUnrestricted: boolean
canMoveEngagementLevel: boolean
advisorOptions: EligibleAdvisorOption[]
/** B155 — every leadership the actor may add, for the Lideranças sheet. */
leadershipOptions: EligibleLeadershipOption[]
stateDeputyOptions: StateDeputyRelationOption[]
stateDeputyCommitAction: MunicipalityStaffFormAction
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
signalFormAction: MunicipalityStaffFormAction
emptySlot: ReactNode
}
@ -64,9 +73,13 @@ export const MunicipalityListMobileCards = ({
leadershipNamesById,
isStaffView,
isCoordinator,
isCampaignUnrestricted,
canMoveEngagementLevel,
advisorOptions,
leadershipOptions,
stateDeputyOptions,
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
emptySlot,
}: MunicipalityListMobileCardsProps) => (
@ -217,6 +230,22 @@ export const MunicipalityListMobileCards = ({
/>
</dd>
</div>
{isCampaignUnrestricted ? (
<div className="col-span-2">
<dt className="text-muted-foreground">Dobradinhas</dt>
<dd>
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
stateDeputyIDs={municipality.stateDeputyIDs}
options={stateDeputyOptions}
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
/>
</dd>
</div>
) : null}
</dl>
) : null}
</article>

View file

@ -0,0 +1,281 @@
'use client'
import { useCallback, useMemo, type ReactElement } from 'react'
import type { MunicipalityStateDeputyCreateResult } from '@/app/(campaign)/campanha/actions/stateDeputy'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
const MAX_VISIBLE_AVATARS = 3
/** "Cicrano (PCdoB)" → "C" — parenthesized party never leaks into the initials. */
const deputyInitials = (label: string): string => {
const withoutParty = label.replace(/\([^()]*\)/g, '').trim()
return campaignUserInitials(withoutParty || label)
}
/**
* The closed-cell display of the "Dobradinhas" column (B157): the same avatar
* stack as the advisors column — overlapping initials circles, hover reads the
* names — with "—" for an empty município. The detail ("Fulano (PT)") lives in
* the trigger tooltip and in the editor's removable chips, which link to the
* ficha (same contract as advisors: no links inside the closed cell).
*/
const StateDeputyAvatarStack = ({ chips }: { chips: RelationChip[] }) => {
if (chips.length === 0) return <span className="text-sm text-muted-foreground">—</span>
return (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => (
<Avatar key={chip.key} className="size-8 border-2 border-background">
<AvatarFallback>{deputyInitials(chip.label)}</AvatarFallback>
</Avatar>
))}
</div>
</div>
)
}
/** One "Nome (Partido)" per line — the hover read of the closed cell. */
const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null =>
chips.length === 0 ? null : (
<div className="flex flex-col">
{chips.map((chip) => (
<span key={chip.key}>{chip.label}</span>
))}
</div>
)
/**
* Normalized search labels, once per `options` array rather than per row: the
* RSC builds one array and every row of the table gets that same reference, so
* a `WeakMap` shares the work where a per-row `useMemo` would repeat it (same
* reasoning as `LeadershipStateDeputyRelationCell`).
*/
const normalizedLabelsByOptions = new WeakMap<
readonly StateDeputyRelationOption[],
Map<number, string>
>()
const normalizedOptionLabels = (
options: readonly StateDeputyRelationOption[],
): Map<number, string> => {
const cached = normalizedLabelsByOptions.get(options)
if (cached) return cached
// Name AND party are searchable ("PT" finds "Fulano (PT)" — B157 aceite).
const normalized = new Map(
options.map(
(option) =>
[option.id, normalizeSearchPhrase(`${option.plainName} ${option.party ?? ''}`)] as const,
),
)
normalizedLabelsByOptions.set(options, normalized)
return normalized
}
/** Same WeakMap sharing for the id→option lookup the chips are built from. */
const optionByIdByOptions = new WeakMap<
readonly StateDeputyRelationOption[],
Map<number, StateDeputyRelationOption>
>()
const optionByIdFor = (
options: readonly StateDeputyRelationOption[],
): Map<number, StateDeputyRelationOption> => {
const cached = optionByIdByOptions.get(options)
if (cached) return cached
const byId = new Map(options.map((option) => [option.id, option]))
optionByIdByOptions.set(options, byId)
return byId
}
type MunicipalityStateDeputyRelationCellProps = {
municipalityId: number
municipalityName: string
stateDeputyIDs: number[]
options: StateDeputyRelationOption[]
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
createAction: MunicipalityStateDeputyCreateAction
editorVariant: CampaignCellEditOverlayVariant
}
/**
* The inline-create form action as the cell consumes it — the success state
* carries the created deputy so the cell can swap its optimistic chip without
* waiting for the RSC refresh. Exported once so the list and the mobile cards
* share the same type instead of re-declaring it.
*/
export type MunicipalityStateDeputyCreateAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<
CampaignFormActionState & { stateDeputy?: MunicipalityStateDeputyCreateResult['stateDeputy'] }
>
const CREATE_LABEL = (rawName: string) => `Criar dobradinha “${rawName}”`
const chipOf = (option: StateDeputyRelationOption): RelationChip => ({
key: String(option.id),
// `option.name` already folds the party (the loader's single spelling).
label: option.name,
href: `/campanha/dobradinhas/${option.slug}`,
ids: [option.id],
})
/**
* B157 — the "Dobradinhas" column of `/campanha/municipios`: a thin domain
* shell over `RelationChipCell` in trigger mode. The closed cell is the
* advisors-style avatar stack (hover reads "Nome (Partido)"); the editor opens
* in a Popover (desktop) / Drawer (mobile) with removable chips, a combobox
* that searches name AND party, and inline create ("Criar dobradinha 'texto'",
* `Nome (PARTIDO)` syntax). Every write goes through the same actions the
* `/campanha/dobradinhas` list already uses — nothing new on the server side
* for toggles.
*/
export const MunicipalityStateDeputyRelationCell = ({
municipalityId,
municipalityName,
stateDeputyIDs,
options,
commitAction,
createAction,
editorVariant,
}: MunicipalityStateDeputyRelationCellProps) => {
// Same WeakMap sharing as the normalized labels above: the RSC builds ONE
// options array for every row, so the id→option map is computed once per
// request instead of per row (25 × catalog-size inserts otherwise).
const optionById = optionByIdFor(options)
const buildChips = useCallback(
(ids: number[]): RelationChip[] => {
const chips: RelationChip[] = []
for (const id of ids) {
const option = optionById.get(id)
if (!option) continue
chips.push(chipOf(option))
}
return chips
},
[optionById],
)
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
const normalizedQuery = normalizeSearchPhrase(query)
const normalizedLabels = normalizedOptionLabels(options)
const hits: RelationSearchHit[] = []
for (const option of options) {
if (assignedIds.has(option.id)) continue
if (!matchesNormalizedAtWordStart(normalizedLabels.get(option.id) ?? '', normalizedQuery)) {
continue
}
hits.push({ key: String(option.id), label: option.name, ids: [option.id] })
}
return hits
},
[options],
)
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('stateDeputyId', String(changedIds[0]))
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[municipalityId],
)
const buildCreateFormData = useCallback(
(rawName: string) => {
const formData = new FormData()
formData.set('municipalityId', String(municipalityId))
formData.set('rawName', rawName)
return formData
},
[municipalityId],
)
/** Maps the action's `stateDeputy` payload to the cell's `createdChip`. */
const createActionWithChip = useCallback(
async (
state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState & { createdChip?: RelationChip }> => {
const result = await createAction(state, formData)
if (result.status !== 'success' || !result.stateDeputy) return result
const created = result.stateDeputy
return {
...result,
createdChip: {
key: String(created.id),
label: stateDeputyDisplayName(created.name, created.party),
href: `/campanha/dobradinhas/${created.slug}`,
ids: [created.id],
},
}
},
[createAction],
)
const copy = useMemo<RelationChipCellCopy>(
() => ({
searchPlaceholder: 'Buscar dobradinha…',
searchLabel: 'Buscar dobradinha',
suggestionsLabel: 'Sugestões de dobradinhas',
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
savingMessage: 'Salvando dobradinhas.',
savedMessage: 'Dobradinhas salvas.',
removedMessage: (count) =>
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
createHintMessage: 'Nenhum resultado — opção de criar dobradinha disponível.',
}),
[],
)
return (
<RelationChipCell
ownerId={municipalityId}
ownerName={municipalityName}
ids={stateDeputyIDs}
buildChips={buildChips}
searchHits={searchHits}
buildFormData={buildFormData}
commitAction={commitAction}
drawerTitle="Dobradinhas do município"
// The advisor twin's contract: the names ride in the trigger's label so
// a screen reader reads WHO is assigned without opening the editor (the
// hover tooltip is pointer-only).
triggerLabel={(chips) =>
`Editar dobradinhas em ${municipalityName}${chips.length ? ` — ${chips.map((chip) => chip.label).join(', ')}` : ''}`
}
updateErrorMessage="Não foi possível atualizar as dobradinhas. Tente novamente."
copy={copy}
measureOverflow={false}
trigger={(chips) => <StateDeputyAvatarStack chips={chips} />}
triggerTooltip={formatStateDeputyNamesTooltip}
editorVariant={editorVariant}
createAction={createActionWithChip}
buildCreateFormData={buildCreateFormData}
createLabel={CREATE_LABEL}
createErrorMessage="Não foi possível criar a dobradinha. Tente novamente."
/>
)
}

View file

@ -1,6 +1,6 @@
'use client'
import { XIcon } from 'lucide-react'
import { PlusIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import {
useCallback,
@ -15,13 +15,17 @@ import {
} from 'react'
import { toast } from 'sonner'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
@ -34,6 +38,9 @@ const resultsMessage = (count: number): string => {
return count === 1 ? '1 resultado.' : `${count} resultados.`
}
/** B157 — the "Criar …" suggestion's listbox key (no real id yet). */
const CREATE_HIT_KEY = '__create__'
const withDelta = (
base: readonly number[],
changedIds: readonly number[],
@ -47,7 +54,7 @@ const withDelta = (
return [...next]
}
type SuggestionSurface = 'inline' | 'drawer'
type SuggestionSurface = 'inline' | 'drawer' | 'popover'
export type RelationChip = {
key: string
@ -80,6 +87,8 @@ export type RelationChipCellCopy = {
savingMessage: string
savedMessage: string
removedMessage: (count: number) => string
/** B157 — announced while the only suggestion is the "Criar …" option. */
createHintMessage?: string
/** Only invoked when `minItems` is actually configured. */
floorMessage?: (min: number) => string
/** Only invoked when `maxItems` is actually configured. */
@ -113,11 +122,41 @@ type RelationChipCellProps = {
) => Promise<CampaignFormActionState>
drawerTitle: string
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
triggerLabel: string
triggerLabel: string | ((chips: RelationChip[]) => string)
updateErrorMessage: string
copy: RelationChipCellCopy
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
measureOverflow?: boolean
// Trigger mode (B157) ───────────────────────────────────────────────────────
/**
* Custom closed-cell display (e.g. an avatar stack). When present, the cell
* renders this node as the edit trigger and the editor body — assigned chips
* + search + create — opens in a `CampaignCellEditOverlay` of
* `editorVariant`, instead of the inline row editor / coarse Drawer split.
* `chips` includes the optimistic state (and any pending/created chip).
*/
trigger?: (chips: RelationChip[], isPending: boolean) => ReactNode
/** The overlay variant in trigger mode — the call site knows its viewport. */
editorVariant?: CampaignCellEditOverlayVariant
/** Popover-only hover/focus read without opening the editor. */
triggerTooltip?: (chips: RelationChip[]) => ReactNode
// Inline create (B157) ─────────────────────────────────────────────────────
/**
* When set (with `buildCreateFormData`), a search with no hits and a
* plausible name offers "Criar …" as the only suggestion: the chip goes up
* under a pending marker, the action creates + assigns server-side, and the
* returned `createdChip` swaps in optimistically until the RSC refresh
* brings the real id into `ids`.
*/
createAction?: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState & { createdChip?: RelationChip }>
buildCreateFormData?: (rawName: string) => FormData
createLabel?: (rawName: string) => string
createErrorMessage?: string
}
/**
@ -147,9 +186,18 @@ export const RelationChipCell = ({
updateErrorMessage,
copy,
measureOverflow = true,
trigger,
editorVariant = 'popover',
triggerTooltip,
createAction,
buildCreateFormData,
createLabel,
createErrorMessage = updateErrorMessage,
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
/** B157 — trigger mode's overlay open state (the inline `open` stays inert there). */
const [editorOpen, setEditorOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [optimistic, setOptimistic] = useState<number[] | null>(null)
const [expanded, setExpanded] = useState(false)
@ -161,6 +209,10 @@ export const RelationChipCell = ({
const [feedback, setFeedback] = useState<
{ kind: 'saved' } | { kind: 'error'; message: string } | null
>(null)
/** B157 — the optimistic chip of an in-flight inline create (no remove affordance). */
const [pendingCreate, setPendingCreate] = useState<RelationChip | null>(null)
/** B157 — chips the create action already returned, until `ids` catches up. */
const [createdChips, setCreatedChips] = useState<RelationChip[]>([])
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
const [activeIndex, setActiveIndex] = useState(0)
/** Only keyboard movement should scroll: the mouse is already where it points. */
@ -177,6 +229,11 @@ export const RelationChipCell = ({
[baseId],
)
/** B157 — trigger mode replaces the inline row editor with a custom closed display + overlay. */
const isTriggerMode = trigger !== undefined
const canCreate = createAction !== undefined && buildCreateFormData !== undefined
const effectiveMeasureOverflow = measureOverflow && !isTriggerMode
useEffect(() => {
return () => {
if (blurCloseTimeoutRef.current != null) {
@ -209,28 +266,92 @@ export const RelationChipCell = ({
}, [effectiveIds])
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
const chips = useMemo(() => {
const base = buildChips(effectiveIds)
// B157 — chips the create action already returned render from their own
// data only until the catalog knows them: `base` resolves them as soon as
// the RSC refresh brings the option, so the copy drops out then — without
// depending on `ids` arriving (a refresh superseded by a removal must not
// leave a duplicate copy behind, and the `effectiveIds` check keeps a chip
// the user just removed from coming back).
const baseIds = new Set(base.map((chip) => chip.ids[0]))
const created = createdChips.filter(
(chip) => !baseIds.has(chip.ids[0]) && effectiveIds.includes(chip.ids[0]),
)
const pending = pendingCreate ? [pendingCreate] : []
return [...base, ...created, ...pending]
}, [buildChips, effectiveIds, createdChips, pendingCreate])
// B157 — hygiene: once the server id arrives, the created chip's copy is
// redundant state. Bails on no-op so cells that never create (B36/B37) do
// not pay a fresh-array render pass on every mount / refresh.
useEffect(() => {
setCreatedChips((current) => {
if (current.length === 0) return current
const next = current.filter((chip) => !ids.includes(chip.ids[0]))
return next.length === current.length ? current : next
})
}, [ids])
/** B157 — the trigger's closed display reads the same optimistic chips. */
const triggerNode = trigger?.(chips, isPending)
const triggerTooltipNode = triggerTooltip ? triggerTooltip(chips) : undefined
const resolvedTriggerLabel =
typeof triggerLabel === 'function' ? triggerLabel(chips) : triggerLabel
const searching = query.trim().length > 0
const trimmedQuery = query.trim()
/**
* Per surface, because Radix can close the popover with text still in the
* input — and because the inline list is PORTALED, so `pointer-coarse:hidden`
* on the cell would not hide it while the Drawer is up.
*/
const inlineSuggesting = searching && open && !drawerOpen
const inlineSuggesting = searching && open && !drawerOpen && !isTriggerMode
const drawerSuggesting = searching && drawerOpen
const suggesting = inlineSuggesting || drawerSuggesting
const popoverSuggesting = searching && editorOpen && isTriggerMode
const suggesting = inlineSuggesting || drawerSuggesting || popoverSuggesting
const hits = useMemo(
() => (suggesting ? searchHits(query, assignedIds) : []),
[suggesting, query, searchHits, assignedIds],
)
const hitsKey = hits.map((hit) => hit.key).join('|')
/**
* B157 — when the search matches nothing and a create is wired up, the ONLY
* suggestion is "Criar …" (B154 pattern). It rides the same ARIA listbox and
* the same keyboard contract as the hits: arrows reach it, Enter fires it.
* Suppressed when the query EXACTLY matches an already-assigned chip's
* label (normalized): the search only offers addable items, so an exact
* assigned name would otherwise offer to mint a duplicate of it — while a
* mere prefix ("Fulano" when "Fulano Silva (PT)" is assigned) still allows
* creating a genuinely new name. The `chips.some` runs last so cells
* without create wiring (B36/B37) never pay its per-chip normalization.
*/
const normalizedCreateQuery = normalizeSearchPhrase(trimmedQuery)
const createHit: RelationSearchHit | null =
canCreate &&
suggesting &&
hits.length === 0 &&
trimmedQuery.length >= 2 &&
trimmedQuery.length <= 160 &&
!chips.some((chip) => normalizeSearchPhrase(chip.label) === normalizedCreateQuery)
? {
key: CREATE_HIT_KEY,
label: createLabel ? createLabel(trimmedQuery) : `Criar “${trimmedQuery}”`,
ids: [],
}
: null
const listOptions = createHit ? [...hits, createHit] : hits
const optionsKey = listOptions.map((option) => option.key).join('|')
useEffect(() => {
setActiveIndex(0)
}, [hitsKey])
}, [optionsKey])
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
const activeSurface: SuggestionSurface = isTriggerMode
? 'popover'
: drawerOpen
? 'drawer'
: 'inline'
useEffect(() => {
if (!activeFromKeyboard.current) return
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
@ -253,12 +374,12 @@ export const RelationChipCell = ({
// button is absolutely positioned, so revealing it never changes a chip's box.
const chipsKey = chips.map((chip) => chip.key).join('|')
useEffect(() => {
if (!measureOverflow) return
if (!effectiveMeasureOverflow) return
invalidateMeasurement()
}, [chipsKey, measureOverflow, invalidateMeasurement])
}, [chipsKey, effectiveMeasureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow) return
if (!effectiveMeasureOverflow) return
const row = chipRowRef.current
if (!row) return
// Seeded here so the observer's initial callback isn't read as a resize.
@ -270,7 +391,7 @@ export const RelationChipCell = ({
})
observer.observe(row)
return () => observer.disconnect()
}, [measureOverflow, invalidateMeasurement])
}, [effectiveMeasureOverflow, invalidateMeasurement])
/**
* Runs on the frames where every chip is in the DOM, so the packing of the
@ -278,7 +399,7 @@ export const RelationChipCell = ({
* read from the real layout instead of estimated from label lengths.
*/
useEffect(() => {
if (!measureOverflow) return
if (!effectiveMeasureOverflow) return
if (visibleChipCount !== null) return
const row = chipRowRef.current
if (!row) return
@ -316,7 +437,7 @@ export const RelationChipCell = ({
}
setVisibleChipCount(fitting)
}, [measureOverflow, measureToken, visibleChipCount])
}, [effectiveMeasureOverflow, measureToken, visibleChipCount])
/**
* Every delta is applied functionally, forward and back: two chips toggled in
@ -408,6 +529,49 @@ export const RelationChipCell = ({
commit(hit.ids, true)
}
/**
* B157 — inline create ("Criar …" when the search matches nothing). The chip
* goes up immediately under a pending marker (no remove affordance — the
* create cannot be cancelled mid-flight, same contract as B154); the action
* creates + assigns server-side, and the returned `createdChip` swaps in
* optimistically. A failed create reverts the chip and shows the error.
*/
const create = (rawName: string) => {
if (!createAction || !buildCreateFormData || pendingCreate) return
setQuery('')
setFeedback(null)
setPendingCreate({ key: `create-${rawName}`, label: rawName, ids: [] })
startTransition(async () => {
const result = await createAction({}, buildCreateFormData(rawName))
if (result.status !== 'success' || !result.createdChip) {
setPendingCreate(null)
const message = firstFormActionMessage(result) ?? createErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
return
}
const created = result.createdChip
setPendingCreate(null)
setCreatedChips((current) => [...current, created])
// Adopt the real id optimistically: the chip becomes removable right
// away, and the reconcile effect above clears once the RSC refresh
// brings the id into `ids`.
setOptimistic((current) => withDelta(current ?? latestIds.current, created.ids, true))
})
}
/** One pick path for hits AND the "Criar …" option (same ARIA listbox). */
const pickOption = (index: number) => {
const option = listOptions[index]
if (!option) return
if (option === createHit) {
create(trimmedQuery)
return
}
pickHit(option)
}
const closeSuggestions = () => {
setOpen(false)
setQuery('')
@ -431,29 +595,28 @@ export const RelationChipCell = ({
*/
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (hits.length === 0) return
if (listOptions.length === 0) return
event.preventDefault()
const delta = event.key === 'ArrowDown' ? 1 : -1
activeFromKeyboard.current = true
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
setActiveIndex((current) => (current + delta + listOptions.length) % listOptions.length)
return
}
if (event.key === 'Home' || event.key === 'End') {
if (hits.length === 0) return
if (listOptions.length === 0) return
event.preventDefault()
activeFromKeyboard.current = true
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
setActiveIndex(event.key === 'Home' ? 0 : listOptions.length - 1)
return
}
if (event.key === 'Enter') {
const hit = hits[activeIndex]
if (!hit) return
if (listOptions.length === 0) return
event.preventDefault()
pickHit(hit)
pickOption(activeIndex)
return
}
if (event.key === 'Escape') {
if (drawerOpen) {
if (drawerOpen || isTriggerMode) {
setQuery('')
return
}
@ -463,12 +626,12 @@ export const RelationChipCell = ({
}
// Editing needs every chip reachable, so focusing the cell always expands it.
const showAllChips = !measureOverflow || expanded || open
const measuring = measureOverflow && visibleChipCount === null
const showAllChips = !effectiveMeasureOverflow || expanded || open
const measuring = effectiveMeasureOverflow && visibleChipCount === null
const hasHiddenChips =
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
effectiveMeasureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
const visibleChips =
!measureOverflow || showAllChips || measuring || visibleChipCount === null
!effectiveMeasureOverflow || showAllChips || measuring || visibleChipCount === null
? chips
: chips.slice(0, visibleChipCount)
/**
@ -489,7 +652,9 @@ export const RelationChipCell = ({
: feedback?.kind === 'error'
? feedback.message
: suggesting
? resultsMessage(hits.length)
? createHit
? (copy.createHintMessage ?? 'Nenhum resultado — opção de criar disponível.')
: resultsMessage(hits.length)
: feedback?.kind === 'saved'
? copy.savedMessage
: ''
@ -547,6 +712,9 @@ export const RelationChipCell = ({
*/
const inlineChip = (chip: RelationChip) => {
const floorReason = removalFloorReason(chip)
// A pending-create chip has no remove affordance: the server-side create
// cannot be cancelled mid-flight, so removal waits for the real id (B154).
const isPendingCreate = chip.key === pendingCreate?.key
return (
<span
key={chip.key}
@ -564,34 +732,37 @@ export const RelationChipCell = ({
{chipBody(chip)}
</Badge>
)}
<button
type="button"
data-chip-remove
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
className={cn(
// `border-muted-foreground/70`: the default 12%-alpha `border` token
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
"before:absolute before:-inset-1 before:content-['']",
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
floorReason
? 'cursor-not-allowed'
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
)}
onClick={(event) => {
event.stopPropagation()
attemptRemove(chip)
}}
>
<XIcon className="size-2.5" aria-hidden="true" />
</button>
{isPendingCreate ? null : (
<button
type="button"
data-chip-remove
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
className={cn(
// `border-muted-foreground/70`: the default 12%-alpha `border` token
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
"before:absolute before:-inset-1 before:content-['']",
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
floorReason
? 'cursor-not-allowed'
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
)}
onClick={(event) => {
event.stopPropagation()
attemptRemove(chip)
}}
>
<XIcon className="size-2.5" aria-hidden="true" />
</button>
)}
</span>
)
}
const suggestionOption = (hit: RelationSearchHit, index: number, surface: SuggestionSurface) => {
const capped = additionCapReason(hit.ids) !== null
const isCreateHit = hit.key === CREATE_HIT_KEY
const capped = !isCreateHit && additionCapReason(hit.ids) !== null
const active = index === activeIndex
return (
<button
@ -600,7 +771,6 @@ export const RelationChipCell = ({
type="button"
role="option"
aria-selected={active}
aria-disabled={capped || undefined}
tabIndex={-1}
className={cn(
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
@ -612,8 +782,9 @@ export const RelationChipCell = ({
activeFromKeyboard.current = false
setActiveIndex(index)
}}
onClick={() => pickHit(hit)}
onClick={() => pickOption(index)}
>
{isCreateHit ? <PlusIcon className="mt-0.5 size-4 shrink-0" aria-hidden="true" /> : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{hit.label}</span>
{hit.description ? (
@ -630,20 +801,132 @@ export const RelationChipCell = ({
/**
* The listbox stays mounted while empty (the input's `aria-controls` must
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
* reader would count it as a selectable option.
* reader would count it as a selectable option. The "Criar …" option rides
* INSIDE the listbox: it is actionable, and the keyboard contract reaches it.
*/
const suggestionList = (surface: SuggestionSurface) => (
<>
<div role="listbox" id={listboxId(surface)} aria-label={copy.suggestionsLabel}>
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
{listOptions.map((option, index) => suggestionOption(option, index, surface))}
</div>
{hits.length === 0 ? (
{listOptions.length === 0 ? (
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
) : null}
</>
)
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
const activeOptionId = listOptions.length > 0 ? optionId(activeSurface, activeIndex) : undefined
/** One chip row of the editor list (Drawer or trigger-mode Popover). */
const chipListItem = (chip: RelationChip) => {
const floorReason = removalFloorReason(chip)
const isPendingCreate = chip.key === pendingCreate?.key
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.href ? (
<Link
href={chip.href}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
{isPendingCreate ? null : (
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
)}
</li>
)
}
/**
* The editor body shared by the coarse Drawer and the trigger-mode overlay:
* assigned chips (removable, linked) + the search combobox + suggestions.
*/
const editorBody = (surface: 'drawer' | 'popover', suggesting: boolean) => (
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
) : (
<ul className="flex flex-col gap-1">{chips.map(chipListItem)}</ul>
)}
<Input
value={query}
autoFocus={surface === 'popover'}
role="combobox"
aria-expanded={suggesting}
// Same as the inline input: the list is only rendered while searching,
// so a permanent `aria-controls` would dangle.
aria-controls={suggesting ? listboxId(surface) : undefined}
aria-autocomplete="list"
aria-activedescendant={suggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList(surface)}</div> : null}
</>
)
// B157 trigger mode: the closed cell is the custom display (e.g. an avatar
// stack) and the editor opens in a `CampaignCellEditOverlay` of the call
// site's variant — Popover on the desktop table, shared Drawer on the
// mobile cards — same gesture contract as the advisors column (B27/B154).
if (isTriggerMode) {
return (
<div ref={rootRef} aria-busy={isPending || undefined} className="relative min-w-56">
<CampaignCellEditOverlay
variant={editorVariant}
open={editorOpen}
onOpenChange={(next) => {
setEditorOpen(next)
// Guarded on the editor having actually been open: a closed overlay
// can still report `onOpenChange(false)` from a page-wide dismiss.
if (!next && editorOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={triggerNode}
triggerLabel={resolvedTriggerLabel}
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={triggerTooltipNode}
contentClassName="w-80 p-0"
// The sheet body already carries `px-4 pt-3 pb-2` — zeroing the
// horizontal padding here makes the shared body wrapper the single
// padding owner for both surfaces (16 px in each).
sheetBodyClassName="px-0"
>
<div className="flex flex-col gap-3 px-4 py-3">
{editorBody('popover', popoverSuggesting)}
</div>
</CampaignCellEditOverlay>
</div>
)
}
return (
<div
@ -757,7 +1040,7 @@ export const RelationChipCell = ({
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={triggerLabel}
triggerLabel={resolvedTriggerLabel}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
@ -769,68 +1052,7 @@ export const RelationChipCell = ({
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.href ? (
<Link
href={chip.href}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
// Same as the inline input: the drawer list is only rendered while
// searching, so a permanent `aria-controls` would dangle.
aria-controls={drawerSuggesting ? listboxId('drawer') : undefined}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
</>
<>{editorBody('drawer', drawerSuggesting)}</>
</CampaignCellEditOverlay>
<p className="sr-only" role="status" aria-live="polite">

View file

@ -9,6 +9,7 @@ import {
trimmedNullableText,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
export const stateDeputyCreateSchema = z.object({
name: z.string().trim().min(2).max(160),
@ -34,6 +35,13 @@ export const STATE_DEPUTY_STAFF_MESSAGE =
/** Same exact-string contract as above, shared by the policy and the create route. */
export const STATE_DEPUTY_CONFLICT_MESSAGE = 'Já existe uma dobradinha com este nome.'
/**
* B157 — thrown by `setCanonicalStateDeputySlug` when a name slugifies empty;
* safelisted by the inline-create route (a "Criar …" query of only punctuation
* must not collapse into the generic message).
*/
export const STATE_DEPUTY_NAME_REQUIRED_MESSAGE = 'Informe um nome com letras ou números.'
/**
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
* adds or removes a set of municipalities (one chip, or a whole território/ZE)
@ -57,8 +65,32 @@ export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
] as const
/**
* B157 — inline create from the municípios list ("Criar dobradinha 'texto'"):
* the raw search text is split by `parseStateDeputyNameParty`, so a trailing
* `(PARTIDO)` becomes the party and the rest the name. The pipe re-validates
* AFTER the split — a name that only parses to empty (e.g. "(PT)") is refused
* instead of minting a deputy named "(PT)".
*/
export const municipalityStateDeputyCreateSchema = z
.object({
municipalityId: positiveRelationshipId,
rawName: z.string().min(1).max(200),
})
.transform((input) => {
const { name, party } = parseStateDeputyNameParty(input.rawName)
return { municipalityId: input.municipalityId, name, party }
})
.refine((value) => value.name.length >= 2 && value.name.length <= 160, {
message: 'O nome precisa ter entre 2 e 160 caracteres.',
})
.refine((value) => (value.party ?? '').length <= 32, {
message: 'O partido pode ter no máximo 32 caracteres.',
})
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
export type StateDeputyMunicipalitiesBatchInput = z.input<
typeof stateDeputyMunicipalitiesBatchSchema
>
export type MunicipalityStateDeputyCreateInput = z.input<typeof municipalityStateDeputyCreateSchema>

View file

@ -0,0 +1,38 @@
/**
* B157 — inline create of a dobradinha from the municípios list combobox.
*
* The search text doubles as the create form: a trailing `(PARTIDO)` group is
* extracted as the deputy's party, everything before it is the name. The rule
* is deliberately narrow — ONE parenthesized group at the very end, with no
* nesting — so "Fulano (PT)" → `{ name: 'Fulano', party: 'PT' }` and
* "Fulano (PT) da Silva" (parens mid-string) stays a name with no party.
* When the whole string is a single group, e.g. "(PT)", the name comes back
* empty so the schema can refuse it instead of minting a deputy named "(PT)".
*/
export type StateDeputyNameParty = {
name: string
party: string | null
}
const WHOLE_STRING_IS_A_GROUP = /^\(([^()]+)\)$/
const TRAILING_GROUP = /^(.+?)\s*\(([^()]+)\)\s*$/
export const parseStateDeputyNameParty = (rawName: string): StateDeputyNameParty => {
const trimmed = rawName.trim()
const wholeGroup = WHOLE_STRING_IS_A_GROUP.exec(trimmed)
if (wholeGroup) return { name: '', party: wholeGroup[1].trim() }
const trailing = TRAILING_GROUP.exec(trimmed)
if (!trailing) return { name: trimmed, party: null }
return { name: trailing[1].trim(), party: trailing[2].trim() }
}
/**
* The display fold `"Fulano (PT)"` for a name + party pair — the single
* spelling every dobradinha chip/suggestion uses (client-safe; the server
* catalog already folds it into `StateDeputyRelationOption.name`).
*/
export const stateDeputyDisplayName = (name: string, party: string | null): string =>
party ? `${name} (${party})` : name

View file

@ -654,7 +654,7 @@ export interface AllocationDecision {
municipality: number | Municipality;
patternId: string;
outcome: 'aceita' | 'descarta' | 'movimento' | 'adiada';
rationale: string;
rationale?: string | null;
alternativeReading?: string | null;
/**
* Somente os números e classificações usados no momento da decisão.

View file

@ -1,6 +1,6 @@
import 'server-only'
import type { Payload } from 'payload'
import { ValidationError, type Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { reloadStaffActor } from '@/utilities/campaignActionContext'
@ -19,6 +19,24 @@ export type StaffEntityPolicy = {
conflictMessage: string
}
/**
* Translates a unique-index violation into the policy's safe message, or
* returns `null` when the error is something else. Two shapes cover the same
* failure: the DB constraint the `conflictPattern` matches (an insert race),
* and Payload's `ValidationError` — Payload validates `unique` fields BEFORE
* the insert and throws a localized \"O campo a seguir está inválido: <field>\"
* that the pattern can never match, which is how the normal duplicate case
* surfaces. (After a zod gate, the only validation left on these create/update
* paths IS the unique check, so a `ValidationError` here is a duplicate.)
*/
export const mapStaffEntityConflict = (error: unknown, policy: StaffEntityPolicy): Error | null => {
const message = error instanceof Error ? error.message : String(error)
if (policy.conflictPattern.test(message) || error instanceof ValidationError) {
return new Error(policy.conflictMessage)
}
return null
}
export const runStaffEntityMutation = async <Result>(
payload: Payload,
actor: CampaignUser,
@ -30,10 +48,8 @@ export const runStaffEntityMutation = async <Result>(
try {
return await mutate(currentActor)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (policy.conflictPattern.test(message)) {
throw new Error(policy.conflictMessage)
}
const conflict = mapStaffEntityConflict(error, policy)
if (conflict) throw conflict
throw error
}
}

View file

@ -190,6 +190,7 @@ export type MunicipalityListColumnId =
| 'level'
| 'advisors'
| 'leaderships'
| 'stateDeputies'
| 'trend'
| 'expectedVotes'
| 'lastSignal'
@ -220,6 +221,8 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
level: campaignConceptOneLiner('nivel-de-envolvimento'),
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
leaderships: 'Lideranças vinculadas ao município — a equipe adiciona e remove clicando aqui.',
stateDeputies:
'Deputados estaduais com quem a campanha dobra neste município — coordenação e candidato editam clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,

View file

@ -109,6 +109,8 @@ export const municipalityColumnLabels: Record<MunicipalityListColumnId, string>
advisors: municipalityListSortLabels.coverage,
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
leaderships: 'Lideranças',
/** B157 — nova coluna entre Assessores e Tendência (grupo "rede"). */
stateDeputies: 'Dobradinhas',
trend: municipalityListSortLabels.trend,
expectedVotes: municipalityListSortLabels.expectedVotes,
/** The column shows the signal; `frescor` sorts by how old it is. */

View file

@ -36,6 +36,7 @@ export const municipalityListSelect = {
ibgeCode: true,
zoneNumber: true,
advisors: true,
stateDeputies: true,
priority: true,
lastUpdateAt: true,
expectedVotes: true,
@ -67,6 +68,8 @@ export type MunicipalityListViewModel = {
advisorIDs: number[]
/** B155 — leadership ids linked to this município (reverse read of `leadership.municipalities`). */
leadershipIDs: number[]
/** B157 — vínculos de dobradinhas para a coluna estilo assessores. */
stateDeputyIDs: number[]
priority: 'alta' | 'normal'
lastUpdateAt: string | null
/**
@ -115,6 +118,9 @@ export const toMunicipalityListViewModel = (
.map(relationshipId)
.filter((id): id is number => id !== null),
leadershipIDs,
stateDeputyIDs: (municipality.stateDeputies ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
lastUpdateAt: municipality.lastUpdateAt ?? null,
lastSignalAt: resolveMunicipalityLastSignalAt(

View file

@ -225,6 +225,75 @@ test.describe('Municípios — jornadas por papel', () => {
).toBeVisible()
})
test('coordinator creates a dobradinha inline in the list combobox (B157)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenador Dobradinhas'),
})
const password = coordinator.password
const municipality = await fixtures.claimMunicipality()
// Unique per run (uuid + counter), so no catalog entry matches it — the
// combobox must offer the create option, not an existing dobradinha.
const deputyName = fixtures.value('Deputado Novo')
const party = 'PCdoB'
await campaign.login(page, coordinator.email!, password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
const deputiesTrigger = page.getByRole('button', {
name: `Editar dobradinhas em ${municipality.name}`,
})
await deputiesTrigger.click()
const deputiesPopover = page.locator('[data-slot="popover-content"]')
await expect(deputiesPopover).toBeVisible()
const search = deputiesPopover.getByRole('combobox', { name: 'Buscar dobradinha' })
// "Cicrano (PCdoB)" → name "Cicrano", party "PCdoB" (B157 syntax).
const createOption = deputiesPopover.getByRole('option', {
name: new RegExp(`^Criar dobradinha.*${deputyName}`),
})
const chip = deputiesPopover.getByRole('button', {
name: `Remover ${deputyName} (${party})`,
})
// Same retry contract as B154: a click before hydration is a silent no-op,
// and once the create succeeded the option is gone (count() guard).
await expect(async () => {
await search.fill(`${deputyName} (${party})`)
if (await createOption.count()) await createOption.click({ timeout: 1_000 })
await expect(chip).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 25_000 })
// Same popover, no reload: typing the party now must NOT offer to create a
// duplicate — the search only lists ADDABLE items, so the assigned chip's
// own name never appears as an option; the "Criar …" suppression is what
// proves the RSC refresh brought the new deputy into the catalog.
await search.fill(party)
await expect(deputiesPopover.getByRole('option', { name: /Criar dobradinha/ })).toHaveCount(0)
await expect(chip).toBeVisible()
// The popover stays open after the write (auto-save, not submit+close).
await expect(deputiesPopover).toBeVisible()
await page.keyboard.press('Escape')
// Persistence: reload and reopen — the server holds both the deputy and
// the assignment, not just the local optimistic state.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: `Editar dobradinhas em ${municipality.name}` }).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${deputyName} (${party})`,
}),
).toBeVisible()
})
test('coordinator sets trend status and justification with auto-save (B24)', async ({
campaign,
page,

View file

@ -0,0 +1,175 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { createMunicipalityStateDeputyRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
import config from '@/payload.config'
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { stateDeputies: true },
overrideAccess: true,
})
return relationIds(municipality.stateDeputies)
}
const stateDeputyByName = async (name: string) => {
const result = await payload.find({
collection: 'stateDeputy',
where: { name: { equals: name } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
return result.docs[0]
}
describe('createMunicipalityStateDeputyRecord (B157)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s create a dobradinha and link it to the município in one write',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Cicrano')
const { stateDeputy, municipalitySlug } = await createMunicipalityStateDeputyRecord(
payload,
actor,
{ municipalityId: municipality.id, rawName: name },
)
fixtures.touchMunicipality(municipality.id)
expect(municipalitySlug).toBe(municipality.slug)
expect(stateDeputy.name).toBe(name)
expect(stateDeputy.party).toBeNull()
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
},
)
it('parses a trailing (PARTIDO) group and persists the party', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Cicrano')
const party = fixtures
.value('PCdoB')
.replace(/-\w+-\d+$/, '')
.slice(0, 32)
const { stateDeputy } = await createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: `${name} (${party})`,
})
fixtures.touchMunicipality(municipality.id)
expect(stateDeputy.name).toBe(name)
expect(stateDeputy.party).toBe(party)
expect((await stateDeputyByName(name))?.party).toBe(party)
})
it('refuses a name that only parses to a party group', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await expect(
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: '(PT)',
}),
).rejects.toThrow(/entre 2 e 160/)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
})
it('maps a duplicate name to the safe conflict message', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const existing = await fixtures.createStateDeputy()
await expect(
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: existing.name,
}),
).rejects.toThrow(STATE_DEPUTY_CONFLICT_MESSAGE)
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
})
it('rolls the create back when the assign is out of the actor scope', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const outside = await fixtures.getMunicipality()
const name = fixtures.value('Fora do escopo')
await expect(
createMunicipalityStateDeputyRecord(payload, advisor, {
municipalityId: outside.id,
rawName: name,
}),
).rejects.toThrow()
// The create must roll back with the failed assign — no orphan deputy.
expect(await stateDeputyByName(name)).toBeUndefined()
expect(await stateDeputyIdsOf(outside.id)).toEqual([])
})
it('denies a leader actor', async () => {
const fixtures = campaignFixtures()
const leaderActor = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
const name = fixtures.value('Só staff')
await expect(
createMunicipalityStateDeputyRecord(payload, leaderActor, {
municipalityId: municipality.id,
rawName: name,
}),
).rejects.toThrow(/coordenação e a assessoria/i)
expect(await stateDeputyByName(name)).toBeUndefined()
})
it('serializes concurrent creates on the same município through the advisory lock', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const [first, second] = await Promise.all([
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: fixtures.value('Primeiro'),
}),
createMunicipalityStateDeputyRecord(payload, coordinator, {
municipalityId: municipality.id,
rawName: fixtures.value('Segundo'),
}),
])
fixtures.touchMunicipality(municipality.id)
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
[first.stateDeputy.id, second.stateDeputy.id].sort((a, b) => a - b),
)
})
})

View file

@ -20,6 +20,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import { createEmptyGoalCoverageByScenario } from '@/utilities/municipality/goalCoverage'
import { municipalityPriorityIndicatorLabel } from '@/utilities/municipality/municipalityLabels'
import type {
@ -44,6 +45,7 @@ const noopListFormAction = async (
const municipalityListDefaultProps = {
isCoordinator: false,
isCampaignUnrestricted: false,
columnVisibility: {
listId: 'municipios',
hiddenColumnIds: [],
@ -52,6 +54,9 @@ const municipalityListDefaultProps = {
advisorOptions: [],
leadershipNamesById: new Map(),
leadershipOptions: [],
stateDeputyOptions: [],
stateDeputyCommitAction: noopListFormAction,
stateDeputyCreateAction: async () => ({}),
columnFilterOptions: { name: [], region: [], advisor: [] },
signalFormAction: noopListFormAction,
state: { page: 1 },
@ -180,6 +185,7 @@ describe('campaign visual foundation', () => {
zoneNumber: null,
advisorIDs: [advisor.id],
leadershipIDs: [11],
stateDeputyIDs: [],
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
@ -224,6 +230,7 @@ describe('campaign visual foundation', () => {
zoneNumber: 3,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -285,6 +292,122 @@ describe('campaign visual foundation', () => {
expect(tbodyHtml).toContain('Seabra')
})
it('renders the Dobradinhas column for unrestricted staff with the advisors-style display', () => {
const stateDeputyOptions: StateDeputyRelationOption[] = [
{ id: 11, name: 'Fulano (PT)', plainName: 'Fulano', party: 'PT', slug: 'fulano' },
{ id: 12, name: 'Beltrana (PSB)', plainName: 'Beltrana', party: 'PSB', slug: 'beltrana' },
]
const baseRow = stub<MunicipalityListViewModel>({
id: 1,
name: 'Seabra',
slug: 'seabra',
kind: 'municipio',
city: 'Seabra',
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
priority: 'normal',
advisorIDs: [],
leadershipIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
territorialClass: 'sem_base',
territorialClassFactors: [],
})
const municipalities: MunicipalityListViewModel[] = [
{ ...baseRow, id: 1, stateDeputyIDs: [11, 12] },
{ ...baseRow, id: 2, name: 'Uauá', slug: 'uaua', stateDeputyIDs: [] },
]
const html = renderWithAppRouter(
createElement(MunicipalityList, {
municipalities,
advisorNamesById: new Map(),
...municipalityListDefaultProps,
isStaffView: true,
isCampaignUnrestricted: true,
stateDeputyOptions,
}),
)
// B157: the new column's header explanation makes it 12 staff tooltips
// (11 with B155's Lideranças column + this one).
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(12)
expect(theadHtml).toContain('Dobradinhas')
// Avatar-stack display: initials circles + the names in the trigger's
// accessible label (the closed cell reads WHO is assigned without opening).
expect(html).toContain('>F<') // Fulano
expect(html).toContain('>B<') // Beltrana
expect(html).toContain(
'aria-label="Editar dobradinhas em Seabra — Fulano (PT), Beltrana (PSB)"',
)
// Empty município reads "—".
expect(html).toContain('>—<')
})
it('keeps the Dobradinhas column out of the advisor and leader views', () => {
const municipalities: MunicipalityListViewModel[] = [
stub<MunicipalityListViewModel>({
id: 1,
name: 'Seabra',
slug: 'seabra',
kind: 'municipio',
city: 'Seabra',
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
priority: 'normal',
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
engagementLevel: null,
levelNote: null,
levelChangedAt: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
territorialClass: 'sem_base',
territorialClassFactors: [],
}),
]
const advisorHtml = renderWithAppRouter(
createElement(MunicipalityList, {
municipalities,
advisorNamesById: new Map(),
...municipalityListDefaultProps,
isStaffView: true,
// Advisor: staff view, but NOT unrestricted — the column must not render.
isCampaignUnrestricted: false,
}),
)
expect(advisorHtml).not.toContain('Dobradinhas')
const leaderHtml = renderWithAppRouter(
createElement(MunicipalityList, {
municipalities,
advisorNamesById: new Map(),
...municipalityListDefaultProps,
isStaffView: false,
isCampaignUnrestricted: false,
}),
)
expect(leaderHtml).not.toContain('Dobradinhas')
})
/**
* E10: the class is a suggestion, and a suggestion without its reason reads
* as a verdict (research §6.4). The reason moved into the column's
@ -306,6 +429,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
@ -326,6 +450,7 @@ describe('campaign visual foundation', () => {
region: 'Chapada Diamantina',
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
expectedVotes: toVoteEstimateScenarioViewModel(null),
pledges: createEmptyMunicipalityPledgeAggregate(),
@ -371,6 +496,7 @@ describe('campaign visual foundation', () => {
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'alta',
lastUpdateAt: staleSignal,
lastSignalAt: staleSignal,
@ -416,6 +542,7 @@ describe('campaign visual foundation', () => {
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -458,6 +585,7 @@ describe('campaign visual foundation', () => {
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
@ -506,6 +634,7 @@ describe('campaign visual foundation', () => {
zoneNumber: null,
advisorIDs: [],
leadershipIDs: [],
stateDeputyIDs: [],
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
@ -599,7 +728,7 @@ describe('campaign visual foundation', () => {
const withAdvisors = renderWithTooltip(
createElement(MunicipalityList, {
municipalities: [{ ...baseMunicipality, advisorIDs: [7] }],
municipalities: [{ ...baseMunicipality, advisorIDs: [7], stateDeputyIDs: [8] }],
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
isStaffView: true,
...municipalityListDefaultProps,
@ -611,7 +740,7 @@ describe('campaign visual foundation', () => {
const withoutAdvisors = renderWithTooltip(
createElement(MunicipalityList, {
municipalities: [{ ...baseMunicipality, advisorIDs: [] }],
municipalities: [{ ...baseMunicipality, advisorIDs: [], stateDeputyIDs: [] }],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
isStaffView: true,
...municipalityListDefaultProps,

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { parseStateDeputyNameParty } from '@/lib/stateDeputyNameParty'
describe('parseStateDeputyNameParty (B157)', () => {
it('returns the trimmed name with no party when there are no parens', () => {
expect(parseStateDeputyNameParty('Cicrano')).toEqual({ name: 'Cicrano', party: null })
expect(parseStateDeputyNameParty(' Cicrano de Tal ')).toEqual({
name: 'Cicrano de Tal',
party: null,
})
})
it('extracts a trailing parenthesized group as the party', () => {
expect(parseStateDeputyNameParty('Cicrano (PCdoB)')).toEqual({
name: 'Cicrano',
party: 'PCdoB',
})
expect(parseStateDeputyNameParty('Fulano (PT) ')).toEqual({ name: 'Fulano', party: 'PT' })
expect(parseStateDeputyNameParty('Fulana da Silva (PSB)')).toEqual({
name: 'Fulana da Silva',
party: 'PSB',
})
})
it('keeps parens that are not a trailing group as part of the name', () => {
// The doc's rule is ONE group at the very end; anything else stays in the name.
expect(parseStateDeputyNameParty('Fulano (PT) da Silva')).toEqual({
name: 'Fulano (PT) da Silva',
party: null,
})
})
it('takes the LAST group when the text ends in several', () => {
expect(parseStateDeputyNameParty('Fulano (PT) (PSB)')).toEqual({
name: 'Fulano (PT)',
party: 'PSB',
})
})
it('refuses a whole-string group with an empty name', () => {
// "(PT)" must not mint a deputy named "(PT)": the schema rejects the empty name.
expect(parseStateDeputyNameParty('(PT)')).toEqual({ name: '', party: 'PT' })
})
it('trims the party', () => {
expect(parseStateDeputyNameParty('Cicrano ( PCdoB )')).toEqual({
name: 'Cicrano',
party: 'PCdoB',
})
})
})