diff --git a/docs/plans/dobradinhas-coluna-municipios-impl.md b/docs/plans/dobradinhas-coluna-municipios-impl.md new file mode 100644 index 00000000..4fb762cc --- /dev/null +++ b/docs/plans/dobradinhas-coluna-municipios-impl.md @@ -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/`; 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
+ loadStateDeputyOptions (1×, staff)"] --> cols["municipalityListColumns(props)
coluna stateDeputies · gate isCampaignUnrestricted"] + cols --> cell["MunicipalityStateDeputyRelationCell
(wrapper · trigger avatar-stack + tooltip)"] + cell -->|"RelationChipCell modo trigger
(popover desktop / sheet mobile)"| fa["setMunicipalityStateDeputiesFormAction
(reusa setStateDeputyMunicipalitiesBatch invertido)"] + cell -->|criar inline| ca["createMunicipalityStateDeputyFormAction
parse Nome (PARTIDO) · create+assign em 1 tx"] + fa --> reval["revalidateStateDeputyMunicipalityPaths
(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/`: 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) diff --git a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts index e5a90b91..856c0303 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts @@ -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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index 06ddb335..f4491a74 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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={ } /> @@ -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} diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index e459e36a..653a764f 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -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 +> diff --git a/src/collections/StateDeputy.ts b/src/collections/StateDeputy.ts index 97adc10f..95bfd005 100644 --- a/src/collections/StateDeputy.ts +++ b/src/collections/StateDeputy.ts @@ -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) diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 6677f319..023ebabb 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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 => [ { @@ -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: ( + + {municipalityColumnLabels.stateDeputies} + + ), + cellClassName: 'max-w-56 whitespace-normal', + cell: (municipality) => ( + + ), + }, + ] satisfies Array) + : []), { // 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): 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={} /> diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index bb0aa3e6..af18665e 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 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 = ({ /> + {isCampaignUnrestricted ? ( +
+
Dobradinhas
+
+ +
+
+ ) : null} ) : null} diff --git a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx new file mode 100644 index 00000000..dff5cd88 --- /dev/null +++ b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx @@ -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 — + + return ( +
+
+ {chips.slice(0, MAX_VISIBLE_AVATARS).map((chip) => ( + + {deputyInitials(chip.label)} + + ))} +
+
+ ) +} + +/** One "Nome (Partido)" per line — the hover read of the closed cell. */ +const formatStateDeputyNamesTooltip = (chips: RelationChip[]): ReactElement | null => + chips.length === 0 ? null : ( +
+ {chips.map((chip) => ( + {chip.label} + ))} +
+ ) + +/** + * 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 +>() + +const normalizedOptionLabels = ( + options: readonly StateDeputyRelationOption[], +): Map => { + 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 +>() + +const optionByIdFor = ( + options: readonly StateDeputyRelationOption[], +): Map => { + 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 + 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): 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 => { + 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( + () => ({ + 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 ( + + `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) => } + triggerTooltip={formatStateDeputyNamesTooltip} + editorVariant={editorVariant} + createAction={createActionWithChip} + buildCreateFormData={buildCreateFormData} + createLabel={CREATE_LABEL} + createErrorMessage="Não foi possível criar a dobradinha. Tente novamente." + /> + ) +} diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index 59b426ea..44747d20 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -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 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 + 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(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(null) + /** B157 — chips the create action already returned, until `ids` catches up. */ + const [createdChips, setCreatedChips] = useState([]) /** 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) => { 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 ( )} - + {isPendingCreate ? null : ( + + )} ) } 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 ( + )} + + ) + } + + /** + * 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 ? ( +

{copy.emptyDrawerMessage}

+ ) : ( +
    {chips.map(chipListItem)}
+ )} + { + setQuery(event.currentTarget.value) + setFeedback(null) + }} + onKeyDown={onSearchKeyDown} + placeholder={copy.searchPlaceholder} + aria-label={copy.searchLabel} + className="min-h-11" + /> + {searching ?
{suggestionList(surface)}
: 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 ( +
+ { + 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" + > +
+ {editorBody('popover', popoverSuggesting)} +
+
+
+ ) + } return (
- {chips.length === 0 ? ( -

{copy.emptyDrawerMessage}

- ) : ( -
    - {chips.map((chip) => { - const floorReason = removalFloorReason(chip) - return ( -
  • - {chip.href ? ( - - - {chipBody(chip)} - - - ) : ( - - {chipBody(chip)} - - )} - -
  • - ) - })} -
- )} - { - setQuery(event.currentTarget.value) - setFeedback(null) - }} - onKeyDown={onSearchKeyDown} - placeholder={copy.searchPlaceholder} - aria-label={copy.searchLabel} - className="min-h-11" - /> - {searching ?
{suggestionList('drawer')}
: null} - + <>{editorBody('drawer', drawerSuggesting)}

diff --git a/src/lib/schemas/stateDeputy.ts b/src/lib/schemas/stateDeputy.ts index 88abb796..36f6f4a5 100644 --- a/src/lib/schemas/stateDeputy.ts +++ b/src/lib/schemas/stateDeputy.ts @@ -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 export type StateDeputyUpdateInput = z.input export type StateDeputyMunicipalitiesBatchInput = z.input< typeof stateDeputyMunicipalitiesBatchSchema > +export type MunicipalityStateDeputyCreateInput = z.input diff --git a/src/lib/stateDeputyNameParty.ts b/src/lib/stateDeputyNameParty.ts new file mode 100644 index 00000000..80dd26a9 --- /dev/null +++ b/src/lib/stateDeputyNameParty.ts @@ -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 diff --git a/src/payload-types.ts b/src/payload-types.ts index 32018dbe..8acb102f 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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. diff --git a/src/utilities/campaignEntityActions.ts b/src/utilities/campaignEntityActions.ts index 57b92b8d..845718fb 100644 --- a/src/utilities/campaignEntityActions.ts +++ b/src/utilities/campaignEntityActions.ts @@ -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: \" + * 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 ( payload: Payload, actor: CampaignUser, @@ -30,10 +48,8 @@ export const runStaffEntityMutation = async ( 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 } } diff --git a/src/utilities/municipality/municipalityLabels.ts b/src/utilities/municipality/municipalityLabels.ts index 4b21e57d..2e0ab38e 100644 --- a/src/utilities/municipality/municipalityLabels.ts +++ b/src/utilities/municipality/municipalityLabels.ts @@ -190,6 +190,7 @@ export type MunicipalityListColumnId = | 'level' | 'advisors' | 'leaderships' + | 'stateDeputies' | 'trend' | 'expectedVotes' | 'lastSignal' @@ -220,6 +221,8 @@ export const municipalityColumnDescriptions: Record 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. */ diff --git a/src/utilities/municipality/municipalityViewModels.ts b/src/utilities/municipality/municipalityViewModels.ts index 0aaed438..e058e496 100644 --- a/src/utilities/municipality/municipalityViewModels.ts +++ b/src/utilities/municipality/municipalityViewModels.ts @@ -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( diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index aa133525..037a49ca 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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, diff --git a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts new file mode 100644 index 00000000..9e4fe090 --- /dev/null +++ b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts @@ -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 => { + 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), + ) + }) +}) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 402b8615..363cea1a 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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({ + 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('')) + 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({ + 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(), isStaffView: true, ...municipalityListDefaultProps, diff --git a/tests/unit/stateDeputyNameParty.unit.spec.ts b/tests/unit/stateDeputyNameParty.unit.spec.ts new file mode 100644 index 00000000..1de99874 --- /dev/null +++ b/tests/unit/stateDeputyNameParty.unit.spec.ts @@ -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', + }) + }) +})