Merge branch 'cursor/a907248c'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 10:39:36 -03:00
commit eab4dae430
31 changed files with 1983 additions and 1201 deletions

View file

@ -233,4 +233,5 @@ alwaysApply: false
- **B7 entregue (2026-07-21):** mapa em `/campanha/municipios` usa `buildMunicipalityListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
- **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador).
- **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) segue com Popover próprio e é ele — não o B37 — o gatilho de extração de um editor de chips agnóstico. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) seguiu com Popover próprio até o **B37 ✓** generalizar os dois num motor comum. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). **O `/simplify` do fechamento achou um bug latente, e ele é a razão de uma convenção daqui ser carga estrutural: `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por string exata**, então uma mensagem de teto escrita como literal cru no `throw` **e** em dois allowlists independentes são três chances de reescrever uma e colapsar em silêncio uma recusa real em "tente novamente". O que as expôs: o helper de delta deste item era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, diferindo só no cap que lê — virou `nextIdsAfterMembership` (cap como dado, três wrappers de uma linha) e os três specs clonados viraram um `describe.each` **com mais cobertura que antes**; as mensagens viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`, `STATE_DEPUTY_STAFF_MESSAGE`). A forma "conjunto com piso e teto" ficou fora de propósito: o B34+ já havia invertido essa recomendação numa segunda leitura, e o código colapsado confirmou (genérico das duas formas pede quatro callbacks para poupar ~20 linhas; o helper novo tem 12 sem parâmetro opcional). Duas lacunas de memoização fechadas, ambas restaurando invariante que a base já tinha: `scopedPortfolioIndex` (o `.filter()` do escopo do assessor devolvia array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas — fecha o F9 do B34+) e a busca da célula de dobradinhas, que renormalizava o catálogo a cada tecla. `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto; um "30 generoso" recusaria dobradinha estadual legítima). Dois débitos **absorvidos no B34+ como F10**, sem abrir ID: o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — gatilho: "selecionar tudo"/multi-TI ou um terceiro lote desse lado. Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md).

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,44 @@
# Chips editáveis de municípios na lista de dobradinhas (paridade com Assessores)
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue (2026-07-28)
Atualizado em: 2026-07-28
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B37**)
Impeccable: B — encaixe na coluna "Municípios" de `/campanha/dobradinhas`; sem rota nova
Appetite: ~0,75–1 dia eng — 1 componente de célula novo (Popover + Command), 2 server actions + schema + helper puro, ajuste no loader da lista; sem migration. (Menor que o B34 análogo: sem piso mínimo, sem `assertMunicipalitiesWithinScope`, lock por município em vez de por documento.)
Responsável: —
## Entregue (2026-07-28) — as-built
**A célula é a mesma peça do B34, sem cópia.** Este item não construiu `StateDeputyMunicipalitiesCell` nem um provider novo — plugou `shared/MunicipalityPortfolioCell` (já promovida pelo B34 ✓) direto na coluna "Municípios" de `/campanha/dobradinhas`, com as costuras que o B34 já desenhou para isso: sem `minItems` (uma dobradinha pode ficar sem município — o piso deste plano nunca existiu no schema, e o comentário permanece correto), sem `maxItems` do lado da dobradinha (o teto que existe é por município — `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`, inalterado), `addableIds` só quando o ator é `advisor`. O container herda **integralmente** a decisão do B34: sempre editável em `pointer-fine` (chips + "×" no hover/foco + input de busca na própria linha), Drawer em `pointer-coarse` — nenhuma decisão de container nova foi tomada aqui, e o "conflito com o pedido literal" registrado abaixo já estava fechado antes deste item começar (ver B34 ✓ e a nota B34/B36/B37 em `codebase-map.mdc`).
**Generalização feita nesta entrega, não adiada para um "3º call site" futuro.** Este plano registrava a extração de um `RelationshipChipEditor` agnóstico em "Adiado com gatilho", esperando B31+B34+este landarem primeiro. Na crítica pós-implementação (`/simplify` + `/impeccable`) ficou visível que **este item já era o 3º call site**: `MunicipalityPortfolioCell` (município) e `LeadershipStateDeputyRelationCell` (dobradinha↔liderança, B31 ✓/B36 ✓) tinham interfaces incompatíveis para o mesmo padrão (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) — P1 do critique. A resposta foi generalizar imediatamente em vez de registrar um quarto "adiado com gatilho": `src/components/campaign/shared/RelationChipCell.tsx` (813 linhas) nasceu como a máquina de interação única, e `MunicipalityPortfolioCell` (816 → ~180 linhas) e `LeadershipStateDeputyRelationCell` (450 → ~185 linhas) foram reduzidos a wrappers finos — chips/busca/`FormData`/copy por domínio, nada de interação duplicada. `LeadershipStateDeputyRelationCell` ganhou `ownerName` (para o Drawer e os `aria-label`) e perdeu sua própria implementação de Popover+Command, sem mudar contrato para os dois call sites existentes (`liderancas/page.tsx`, `dobradinhas/page.tsx`) além de passar o novo prop.
**Segundo P1 do critique, também resolvido nesta entrega:** um assessor via — e tentava remover — chips de municípios fora do seu escopo, e a remoção falhava em silêncio no nível do documento (`canUpdateMunicipality` recusa, mas a célula não sabia explicar por quê). `MunicipalityPortfolioCell` agora filtra `municipalityIds` por `addableIds` quando presente (`visibleIds`), então um assessor só vê — e só pode tentar remover — municípios que administra; staff sem `addableIds` continua vendo e removendo todos os vínculos. Trade-off aceito: em uma janela rara de reatribuição de carteira, um piso client-side poderia recusar uma remoção que o servidor aceitaria — aceitável porque o servidor segue sendo a fonte de verdade e o pedido explícito era esconder, não apenas explicar.
**Escrita:** `setStateDeputyMunicipalitiesBatch` — uma única action de lote (não a dupla delta+lote do plano), transacional, com lock por município (`municipality-state-deputies:{id}`, um por município tocado, ordenado) e leitura+escrita de `municipality.stateDeputies` com `overrideAccess: false` (o acesso real é `canUpdateMunicipality`; sem `assertMunicipalitiesWithinScope`, como planejado). **Falha em lote é tudo-ou-nada**: se qualquer município do lote está fora do escopo do assessor ou não existe, a transação inteira é revertida com uma mensagem única — decisão explícita desta sessão (rejeita a alternativa "aplica o que pode, ignora o resto", que deixaria o assessor sem saber quais municípios silenciosamente falharam). `nextStateDeputyMunicipalityIdsAfterMembership` (em `src/lib/municipalityStateDeputyMembership.ts`, puro) espelha `nextAdvisorIdsAfterMembership`.
**Cap de 20 → 435 em `stateDeputiesArraySchema`** (`src/lib/schemas/municipality.ts`): o teto original de 20 dobradinhas por município era um vestígio de outra era do dado; com a coluna agora permitindo lote de território/ZE (até 435 municípios de uma vez do lado do dobradinha), o teto do lado do município precisava cobrir o universo inteiro em vez de truncar silenciosamente um lote grande. `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` foi ajustado e reexportado como mensagem segura da nova action.
**Loader:** `StateDeputyRowViewModel.municipalityCount` (número) → `municipalityIDs: number[]` — não o `{ id, name, slug }[]` do plano. Mesma razão do B34: a célula já recebe o catálogo completo (435 municípios) via `municipalityIndex`, então nome/slug/território resolvem no cliente e o loader não repete strings por linha.
**Sem provider de contexto** (mesma decisão do B34): `municipalityIndex` e `addableIds` viajam como props da factory de colunas que o RSC monta uma vez por tabela.
**Testes:** unit da lib pura (`municipalityStateDeputyMembership.unit.spec.ts`), int da action de lote (`campaignStateDeputyMunicipalityMembership.int.spec.ts` — escopo do assessor, teto, lock, tudo-ou-nada, no-op idempotente), e uma nova suite unit dedicada a `LeadershipStateDeputyRelationCell` (`leadershipStateDeputyRelationCell.unit.spec.ts`) que não existia antes desta entrega — o componente tinha zero cobertura própria.
**Gate:** `tsc`/`lint`/`format:check`/`check:cycles` limpos; unit 651/651, int 452/452 (suítes completas, não só os arquivos tocados); Aikido 0 findings nos arquivos de primeira parte editados; `knip` bloqueado pela limitação pré-existente e já ledgerada de não conseguir carregar `payload.config.ts` (P3 em `TECH-DEBT.md`) — não uma regressão desta entrega.
### `/simplify` do fechamento (2026-07-28) — o que mudou depois do as-built acima
**O achado que vale guardar é um bug latente, não uma dedução:** `nextStateDeputyIdsAfterMunicipalityMembership` nasceu aqui como a **terceira** cópia byte-a-byte do mesmo delta de um id (as três diferiam só no teto que leem), então o algoritmo passou a viver uma vez em `src/lib/relationMembershipDelta.ts` e os três nomes de domínio são wrappers de uma linha. O efeito colateral é que duas das mensagens de teto eram **literais crus repetidos em três lugares de `src` cada** — a lançada mais dois allowlists independentes — e `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**: reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps, seguindo o `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que esta entrega já havia ajustado nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares (e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo), então é `STATE_DEPUTY_STAFF_MESSAGE` — renomeada, porque guarda create/update também, não só o lote. Os três specs clonados viraram um `describe.each`, e a cobertura subiu: cada relação recebeu os casos "pode ir a zero" e "não muta a entrada" que só o clone deste item tinha. Unit 651 → **715**.
Na action de lote: o slug da dobradinha deixou de ser resolvido antes do laço (ele existe só para mirar o `revalidate`, que o chamador já pula quando nada mudou — o guard que o gêmeo `setLeadershipMunicipalitiesMembershipRecord` já tinha), o segundo dedup redundante saiu (o `.transform` do schema já deduplica; o `sort` fica, e o comentário dele foi corrigido: quem garante ordem de lock é `acquireTextAdvisoryLocks`, que ordena as próprias chaves), e o `435` cru que este item passou a dividir com o lote de assessores é `MAX_MUNICIPALITIES_PER_BATCH`.
Duas lacunas de memoização fechadas, ambas restaurando invariantes que o código já tinha em outro lugar: o `.filter()` do escopo do assessor devolvia um array novo **por linha**, justamente derrotando o WeakMap que existe para compartilhar derivações entre linhas (`scopedPortfolioIndex` memoiza por `(index, addableIds)`, fechando o **F9** do B34+), e a busca do `LeadershipStateDeputyRelationCell` chamava `matchesAtWordStart` por candidato, pagando um passe NFD e três regexes Unicode dos dois lados a cada tecla sobre o catálogo de lideranças — o custo que o próprio docstring de `wordStartFilter` avisa; agora normaliza a query uma vez contra rótulos cacheados, como `municipalityPortfolio` já fazia.
**Rejeitado com motivo:** `maxItems` na célula de dobradinhas, sugerido "por paridade" com o B34. Uma dobradinha não tem teto de produto de municípios (é por isso que o bound é o catálogo), então um "30 generoso" inventaria uma regra que recusa uma dobradinha estadual legítima, e o 435 honesto é inalcançável quando os ids são únicos.
**Adiado com gatilho, absorvido no B34+ como fase F10** (não abriu ID novo): o laço município-a-município das duas actions de lote do lado do município (`findByID` + `update` por município — ~870 round trips no teto de 435, mas ~52 no maior TI real, e nenhum teste acima de lote 2) **e** a gemelaridade das duas actions (2 call sites, abaixo do limiar de 3). Gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote do lado do município.
## ⚠️ Conflito com o pedido literal — leia antes de implementar
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_

View file

@ -54,11 +54,21 @@ Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é u
**Correção ao F1:** a opção (b) "payload mínimo" encolhe o payload RSC de **35.510 B / 7.286 B gzip** para **13.610 B / 3.534 B gzip**, mas **não poupa tempo de servidor nenhum** (10,7 ms de qualquer jeito). A decisão registrada é payload-contra-bundle, não um ganho de servidor.
## F9 — O ramo do assessor entrega uma chave nova ao WeakMap por linha
## F9 ✓ — O ramo do assessor entrega uma chave nova ao WeakMap por linha
`searchIndex` filtra `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha** — e é esse array que vira a chave do WeakMap de derivações. O assessor fica com 25 conjuntos de derivação (4 Maps cada) em vez de um, e paga o build O(435) de novo na primeira busca de cada linha: **1,89 ms** para as 25 contra 0,076 ms para uma. É exatamente o modo de falha que a memoização foi escrita para evitar, embora o número absoluto seja pequeno.
**Fechado no `/simplify` do B37 ✓ (2026-07-28), pela segunda saída — e ela não ficou dentro da célula.** `searchIndex` filtrava `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha**, e era esse array que virava a chave do WeakMap de derivações: 25 conjuntos (4 Maps cada) em vez de um, mais o build O(435) repetido na primeira busca de cada linha (**1,89 ms** para as 25 contra 0,076 ms para uma).
Duas saídas, nenhuma feita agora: passar `addableIds` como opção de `searchMunicipalityPortfolio` (preserva a contagem escopada de hits de TI/ZE e elimina as 25 alocações, mas mexe na assinatura de uma função compartilhada), ou memoizar o filtro num WeakMap aninhado dentro da célula (contido, mas é uma segunda camada de cache).
O filtro passou a ser `scopedPortfolioIndex(index, addableIds)` em `src/lib/municipalityPortfolio.ts`, memoizado num WeakMap aninhado por `(index, addableIds)` — as duas chaves são estáveis por request (o índice é montado uma vez pelo RSC; o `Set` de escopo também), então todas as linhas voltam a receber **o mesmo array** e a memoização de derivações volta a funcionar como escrita. Mora na lib, não na célula, porque a alternativa "WeakMap dentro do componente" seria uma segunda camada de cache ao lado da que já existe no mesmo módulo — e porque a primeira saída (`addableIds` como opção de `searchMunicipalityPortfolio`) muda a assinatura de uma função compartilhada para resolver um problema de identidade de array, que é o que um WeakMap já resolve.
## F10 — O lote do lado do município escreve município por município (absorvido do B37 ✓)
Entrou por triage do `/simplify` do **B37 ✓** (2026-07-28), como fase desta família e não como item novo, porque a função que a fase discute — `setAdvisorMunicipalitiesBatchRecord` — já é nomeada no F3 acima.
`setStateDeputyMunicipalitiesBatchRecord` e `setAdvisorMunicipalitiesBatchRecord` percorrem a relação **invertida** (o array mora em `municipality.advisors`/`municipality.stateDeputies`), e cada município do lote custa **um `findByID` + um `update`** com `overrideAccess: false` — a leitura existe porque o acesso por linha e por campo do Payload é o que escopa o assessor, e o update precisa do array inteiro. Os locks **não** são o problema (o C8 ✓ já os bateu em 1 round trip via `unnest`); o laço é. No teto do schema (435, elevado pelo próprio B37 para caber um TI inteiro) são ~870 round trips numa transação, cada um pagando ~1–3 ms de `gru1`↔`sa-east-1` — e o int spec mais gordo do caminho usa lote de 2, então o comportamento acima de um punhado de municípios é **inferido, não medido**.
Não foi corrigido no fechamento porque o teto é inalcançável pelo produto de hoje: o maior lote que a UI oferece é um chip de território, e o maior TI da Bahia tem **26** municípios (Litoral Sul — medido em `bahiaTerritories.ts`, ~52 round trips). **O gatilho é uma afordância de "selecionar tudo" / "aplicar a vários TIs"** — quem construir isso mede antes: ou reescreve o laço em SQL bulk sobre a transação do Payload (precedente `supporterImportBulk.ts`, que já faz `payload.db.drizzle` em chunks dentro da transação aberta), **ou** mantém o laço e trava o lote num limite honesto abaixo do universo. Reescrever em bulk custa a checagem de escopo que o `overrideAccess: false` dá de graça hoje — ela teria de ser reimplementada explicitamente contra `getAdvisorMunicipalityIds`, que é exatamente o tipo de reimplementação de access que o repo evita, então a medição vem antes da escolha.
**Segunda metade, com o mesmo gatilho: as duas actions são gêmeas (2 call sites).** Fora o nome do campo, do lock e da mensagem, são a mesma sequência: parse, dedup+sort, transação, `reloadStaffActor`, locks em bloco, laço de leitura escopada + delta puro + update, coleta de slugs mudados, revalidate por delta. Está **abaixo** do limiar de 3 do `AGENTS.md`, e — diferente do F3, onde o algoritmo era idêntico — aqui o que se repete é uma sequência de orquestração cuja extração pediria parametrizar "de que lado da aresta eu escrevo", a fontanaria genérica que o F3 já rejeitou uma vez. Registrado para que um **terceiro** lote do lado do município não nasça como terceira cópia: quem o escrever extrai `withMunicipalitySideRelationBatch({ relation, lockPrefix, computeNext })` primeiro, e fecha esta metade junto com a de cima.
## F1 — Regressão de First Load JS nas duas listas (prioritário)
@ -84,9 +94,13 @@ O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago
Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas.
## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
## F2 ✓ — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
**Fechado no B37 ✓ (2026-07-28), e a previsão desta fase estava errada — vale registrar por quê.** Estava escrito aqui que "**o B37 não retira este débito**: B37 pluga uma terceira relação na célula compartilhada sem máquina nova; quem fecha o F2 é quem deletar `LeadershipStateDeputyRelationCell`". O critique pós-implementação do B37 leu a mesma evidência ao contrário: se as duas células rodam na mesma tabela com a mesma máquina escrita duas vezes e contratos incompatíveis, então **o B37 já era o 3º call site** — e a resposta foi generalizar na hora em vez de registrar um quarto adiamento. `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (`MunicipalityPortfolioCell` 816 → ~180 linhas; `LeadershipStateDeputyRelationCell` 450 → ~185), então as ~165 linhas que esta fase mediu **não foram deduplicadas num hook, foram deletadas** — não há mais duas cópias de medição, adoção por `sameIdSet`, `expandToggle` nem redutor otimista para extrair. O módulo do qual a cópia sobreviveu continua existindo, mas sem máquina dentro: o que resta nele é domínio (chips com sufixo de partido, `FormData`, copy).
Não reabrir como "extrair `useChipRowOverflow`": a medição vive uma vez, dentro do motor, e um hook separado só faria sentido para um terceiro consumidor que **não** seja uma célula de chips.
**Descrição original (histórico):** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregavam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro.
@ -94,9 +108,17 @@ A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e
**O `/simplify` do B32+ ✓ mexeu nesta célula e mudou duas coisas para esta fase (2026-07-28).** A região `aria-live` dela estava **dentro** do `PopoverContent` — o bug exato que o B32+ F4 corrigiu nos outros cinco controles, e que aqui deixava a falha e o "Salvando…" mudos justamente depois de fechar, que é quando a gravação acontece — e foi içada para fora do `<Popover>`, com `role="status"`, já nesta forma: **a migração não pode reintroduzi-la dentro do overlay.** E o argumento de custo virou: `CampaignCellEditOverlay` já é usado por seis chamadores, então a parte "trigger + Popover" desta célula deixou de ser trabalho de extração e passou a ser adoção de casca pronta (inclusive o `initialFocus` e o `aria-busy` que ela não tem) — o que sobra de verdade nesta fase é a máquina de chips e o redutor otimista. Um detalhe da adoção, medido no B34: a folha do container é um `Drawer` sempre montado e reporta `onOpenChange(false)` quando **qualquer** dismiss varre a pilha de camadas, não só quando ela fecha.
## F3 — Um só delta de membership
## F3 — Um só delta de membership (metade fechada)
`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
**A metade dos três deltas de um id foi fechada no `/simplify` do B37 ✓ (2026-07-28), e a recomendação invertida da segunda leitura se confirmou.** `nextStateDeputyIdsAfterMunicipalityMembership` (nascido no B37) era a **terceira** cópia byte-a-byte do mesmo algoritmo de um id — os três diferiam só no teto que leem —, então ele vive uma vez em `src/lib/relationMembershipDelta.ts` (`nextIdsAfterMembership(current, id, assigned, { max, message })`) e os três nomes de domínio são wrappers de uma linha ligando o seu próprio cap. Os três specs clonados viraram um `describe.each`, e a cobertura **subiu**: cada relação agora recebe os casos "pode ir a zero" e "não muta a entrada" que só o clone do B37 tinha, mais a asserção explícita de que cada wrapper recusa no **seu** teto com a **sua** mensagem.
**`nextMunicipalityIdsAfterLeadershipMembership` ficou de fora de propósito** — é a forma "um conjunto com piso e teto", e a segunda leitura registrada acima (um genérico cobrindo as duas formas precisaria de quatro callbacks para poupar ~20 linhas) continua valendo depois de ver o código colapsado: o helper novo tem 12 linhas e nenhum parâmetro opcional.
**Efeito colateral que era um bug latente, e a razão de o pareamento importar:** duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada (lançada + dois allowlists independentes), e `mapCampaignFormActionError` casa mensagem lançada contra `safeMessages` por **string exata** — reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`), seguindo o precedente `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que já existia nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares — e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo — então é `STATE_DEPUTY_STAFF_MESSAGE` (renomeada: guarda create/update também, não só o lote), e a de conflito que ela divide com a rota de criação também tem nome.
**Continua aberto nesta fase:** o `withLeadershipRelationDelta` abaixo (política de escrita por delta sob lock consultivo numa linha de liderança).
**Descrição original (histórico):** `nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra.
@ -106,14 +128,15 @@ No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadership
`leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo.
## F5 — Combobox à mão onde o kit já embrulha um
## F5 — Combobox à mão onde o kit já embrulha um (escopo reduzido pelo B37 ✓)
São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
**A metade "duplicada" morreu com o `RelationChipCell` (B37 ✓, 2026-07-28):** os ~20 atributos repetidos entre as duas superfícies e as ~16 linhas de `searchInputProps` do F6 não existem mais — o input de busca e o listbox são escritos **uma vez**, no motor. O que resta são as ~90 linhas de teclado/ARIA num único lugar, o que **melhora** a economia da troca em vez de piorá-la: adotar `ui/combobox.tsx` passou a ser uma edição, não duas mantidas em sincronia. O argumento abaixo (fit parcial, `anchor` já previsto, cmdk corretamente fora) continua íntegro.
**Descrição original (histórico):** eram ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
## F6 — Baixos
- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? <Badge asChild><Link/></Badge> : <Badge/>` está escrito duas vezes (~14 linhas) nas duas superfícies. O resto da divergência entre elas é a tese da entrega (16 px revelado no hover contra 44 px sempre visível) e deve continuar divergindo.
- **`searchInputProps(surface)`**: o input de busca está escrito nas duas superfícies com **oito** props idênticas (`role`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, `onKeyDown`, `aria-label` e o corpo `setQuery` + `setFeedback(null)`), divergindo só no `setOpen(true)`/`onFocus`/`onBlur` do inline. A divergência é **correta**, e é justamente por isso que deveria ser explícita: uma factory espalhada nos dois, com os overrides do inline escritos depois do spread, poupa ~16 linhas e troca uma assimetria inferida por uma declarada.
- ~~**`PortfolioChipLabel`**~~ ✓ e ~~**`searchInputProps(surface)`**~~ ✓ — os dois eram "escrito duas vezes nas duas superfícies", e as duas superfícies passaram a ser **uma** com o `RelationChipCell` (B37 ✓): o rótulo do chip é um `renderChipLabel` do motor e o input de busca é montado uma vez. Nada a extrair.
- **e2e**: `createStaffLeadership({ role, municipalities })` e `expectPostResponse(page, fragment)` em `campaignE2EFixtures.ts` — 3 call sites hoje.
- **`relationIds()`** exportado de `tests/helpers/campaignFixtures.ts`: `typeof entry === 'number' ? entry : entry.id` está reescrito em 9 lugares nos int specs, com `src/utilities/relationship.ts` já exportando o equivalente de produção. Débito anterior; o spec novo é a 9ª instância.
- **`loadMunicipalityPortfolioIndex` fora da escada de cache**: leitura do catálogo inteiro, de campos só-geografia, idêntica para todo ator e toda request, sem `cache()` nem `unstable_cache`. Uma chamada por request hoje, então o degrau 1 não compra nada — mas o degrau 2 é a resposta da escada. Decidir, não deixar por omissão.

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import { updateStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { nullableFormText, requiredRelationshipFormValue } from '@/lib/formData'
import { STATE_DEPUTY_STAFF_MESSAGE } from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -23,6 +24,6 @@ export const updateStateDeputyFormAction = async (
revalidatePath('/campanha/dobradinhas/[slug]', 'page')
return { message: 'Dobradinha atualizada.' }
},
safeMessages: ['Somente a coordenação e a assessoria gerenciam dobradinhas.'],
safeMessages: [STATE_DEPUTY_STAFF_MESSAGE],
genericMessage: 'Não foi possível salvar a dobradinha. Verifique seu acesso e tente novamente.',
})

View file

@ -1,8 +1,14 @@
'use server'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/leadership'
import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -10,7 +16,7 @@ import {
const safeMessages = [
'Somente a coordenação e a assessoria podem gerenciar lideranças.',
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
] as const
/** One chip toggle in the "Lideranças" column of `/campanha/dobradinhas` (B36). */
@ -30,3 +36,26 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
safeMessages,
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
})
/**
* Add or remove municipalities in the "Municípios" column of
* `/campanha/dobradinhas` (B37) — one chip or a whole território/ZE. `ownerId`
* is the field name `MunicipalityPortfolioCell` sends, so the same cell
* serves lideranças, assessores and dobradinhas.
*/
export const setStateDeputyMunicipalitiesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await setStateDeputyMunicipalitiesBatch({
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Municípios atualizados.' }
},
safeMessages: STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
})

View file

@ -2,6 +2,10 @@
import { createStateDeputy } from '@/app/(campaign)/campanha/actions/stateDeputy'
import { optionalFormText, requiredFormText } from '@/lib/formData'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
} from '@/lib/schemas/stateDeputy'
import {
runCampaignRedirectFormAction,
type CampaignFormActionState,
@ -19,10 +23,7 @@ export const createStateDeputyFormAction = async (
notes: optionalFormText(formData, 'notes'),
}),
redirectTo: (stateDeputy) => `/campanha/dobradinhas/${stateDeputy.slug}`,
safeMessages: [
'Já existe uma dobradinha com este nome.',
'Somente a coordenação e a assessoria gerenciam dobradinhas.',
],
safeMessages: [STATE_DEPUTY_CONFLICT_MESSAGE, STATE_DEPUTY_STAFF_MESSAGE],
genericMessage:
'Não foi possível cadastrar a dobradinha. Verifique os dados e tente novamente.',
})

View file

@ -10,11 +10,16 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import {
LeadershipStateDeputyRelationCell,
type RelationCellOption,
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
@ -27,11 +32,13 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex'
import {
loadStateDeputyListPageData,
type StateDeputyRowViewModel,
@ -50,7 +57,10 @@ import {
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
import {
setLeadershipStateDeputyMembershipFormAction,
setStateDeputyMunicipalitiesFormAction,
} from './formActions'
type StateDeputiesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -86,6 +96,8 @@ const stateDeputyColumns = (
partyFilterOptions: StateDeputyFilterOption[],
hasNoPartyOption: boolean,
leadershipOptions: RelationCellOption[],
municipalityIndex: MunicipalityPortfolioIndexEntry[],
addableMunicipalityIds: ReadonlySet<number> | undefined,
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
{
id: 'name',
@ -118,8 +130,24 @@ const stateDeputyColumns = (
{
id: 'municipalities',
label: 'Municípios',
cellClassName: 'tabular-nums',
cell: (row) => row.municipalityCount,
head: (
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Um território ou ZE entra e sai como um bloco.">
Municípios
</CampaignTableHead>
),
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={row.municipalityIDs}
municipalityIndex={municipalityIndex}
{...(addableMunicipalityIds ? { addableIds: addableMunicipalityIds } : {})}
commitAction={setStateDeputyMunicipalitiesFormAction}
drawerTitle="Municípios da dobradinha"
updateErrorMessage="Não foi possível atualizar os municípios."
/>
),
},
{
id: 'leaderships',
@ -129,6 +157,7 @@ const stateDeputyColumns = (
<LeadershipStateDeputyRelationCell
direction="fromStateDeputy"
fixedId={row.id}
ownerName={row.name}
items={row.leaderships.map((leadership) => ({
id: leadership.id,
label: leadership.name,
@ -150,9 +179,16 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
const [
{ rows, totalDocs, totalPages, filterFacets },
leadershipOptions,
municipalityIndex,
administeredIds,
] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
loadLeadershipOptions(payload, user),
loadMunicipalityPortfolioIndex(payload),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@ -175,6 +211,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
href: `/campanha/liderancas/${option.id}`,
},
})),
municipalityIndex,
administeredIds ? new Set(administeredIds) : undefined,
)
return (
@ -183,8 +221,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
<p className="text-muted-foreground">
Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
municípios nas fichas correspondentes.
Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto
na lista.
</p>
</div>
<Button asChild className="min-h-11">

View file

@ -14,7 +14,7 @@ import {
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
MAX_LEADERSHIP_STATE_DEPUTIES,
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
} from '@/lib/schemas/leadership'
import {
runCampaignFormAction,
@ -23,7 +23,7 @@ import {
const safeMessages = [
...leadershipStaffEditSafeMessages,
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,

View file

@ -219,6 +219,7 @@ const leadershipColumns = ({
<LeadershipStateDeputyRelationCell
direction="fromLeadership"
fixedId={row.id}
ownerName={row.name}
items={row.stateDeputies.map((deputy) => ({
id: deputy.id,
label: deputy.name,

View file

@ -1,22 +1,32 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import {
STATE_DEPUTY_CONFLICT_MESSAGE,
STATE_DEPUTY_STAFF_MESSAGE,
stateDeputyCreateSchema,
stateDeputyMunicipalitiesBatchSchema,
stateDeputyUpdateSchema,
type StateDeputyCreateInput,
type StateDeputyMunicipalitiesBatchInput,
type StateDeputyUpdateInput,
} from '@/lib/schemas/stateDeputy'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { uniqueRelationshipIds } from '@/utilities/relationship'
const stateDeputyPolicy: StaffEntityPolicy = {
staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.',
staffMessage: STATE_DEPUTY_STAFF_MESSAGE,
conflictPattern: /state_deputy_(name|slug)|duplicate key/i,
conflictMessage: 'Já existe uma dobradinha com este nome.',
conflictMessage: STATE_DEPUTY_CONFLICT_MESSAGE,
}
const createStateDeputyRecord = async (
@ -63,3 +73,129 @@ export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
return updateStateDeputyRecord(payload, actor, input)
}
const revalidateStateDeputyMunicipalityPaths = (
stateDeputySlug: string | undefined,
municipalitySlugs: readonly string[],
) => {
revalidatePath('/campanha/dobradinhas', 'page')
if (stateDeputySlug) {
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
}
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of municipalitySlugs) {
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
}
}
/**
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
* adds or removes this `stateDeputy` on `municipality.stateDeputies` for a
* batch of municipalities (one chip, or a whole território/ZE). Mirrors
* `setAdvisorMunicipalitiesBatchRecord`: many municípios get touched, not one
* owning document. Unlike that twin, this write does NOT bypass access —
* `canUpdateMunicipality` already scopes an advisor to administered
* municípios at the document level, and `stateDeputies`' field access
* (`canManageCampaignStaffField`) is staff-wide, so `overrideAccess: false`
* on both the read and the write is what refuses an out-of-scope município.
*/
export const setStateDeputyMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,
input: StateDeputyMunicipalitiesBatchInput,
) => {
const { stateDeputyId, municipalityIds, assigned } =
stateDeputyMunicipalitiesBatchSchema.parse(input)
// Already deduped by the schema's `.transform`; sorted only so a batch applies
// and reports in a stable order — `acquireTextAdvisoryLocks` sorts its own keys,
// so the deadlock-avoidance ordering does not depend on this line.
const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, STATE_DEPUTY_STAFF_MESSAGE, req)
await acquireTextAdvisoryLocks(
payload,
req,
uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`),
)
// Every município actually changed, not just the last one — same bug
// class the B34 twin fixed: a território chip can touch up to 435
// municípios in one call, and revalidating only the last would leave
// the rest of the detail pages stale.
const changedSlugs: string[] = []
for (const municipalityId of uniqueMunicipalityIds) {
// Row + field access verify the município is in the actor's scope
// (`canUpdateMunicipality` for advisors, `canManageCampaignStaffField`
// for the field) — an out-of-scope município throws here.
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { stateDeputies: true, slug: true },
user: currentActor,
overrideAccess: false,
req,
})
const currentStateDeputyIDs = uniqueRelationshipIds(municipality.stateDeputies)
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
currentStateDeputyIDs,
stateDeputyId,
assigned,
)
if (nextStateDeputyIDs === null) continue
await payload.update({
collection: 'municipality',
id: municipalityId,
data: { stateDeputies: nextStateDeputyIDs },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
// Resolved AFTER the loop, and only when something changed: the slug
// exists solely to target the revalidate, which the caller skips on a
// no-op batch — so on a no-op this read was pure waste (the guard the
// `setLeadershipMunicipalitiesMembershipRecord` twin already had).
// Intentional admin bypass: existence is otherwise enforced by Payload's
// relationship validation on `update` (precedent:
// `setLeadershipStateDeputyMembershipRecord`, B31).
const stateDeputySlug =
changedSlugs.length > 0
? (
await payload.findByID({
collection: 'stateDeputy',
id: stateDeputyId,
depth: 0,
select: { slug: true },
overrideAccess: true,
req,
})
).slug
: undefined
return { stateDeputySlug, slugs: changedSlugs }
},
{ beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' },
)
}
export const setStateDeputyMunicipalitiesBatch = async (
input: StateDeputyMunicipalitiesBatchInput,
) => {
const { payload, actor } = await getCampaignActionContext()
const result = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, input)
if (result.slugs.length > 0) {
revalidateStateDeputyMunicipalityPaths(result.stateDeputySlug, result.slugs)
}
return result
}

View file

@ -1,17 +1,14 @@
'use client'
import { PencilIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { useCallback, useMemo } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import { matchesAtWordStart } from '@/lib/wordStartFilter'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
export type RelationCellItem = {
@ -32,6 +29,8 @@ type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeput
type LeadershipStateDeputyRelationCellProps = {
direction: LeadershipStateDeputyRelationDirection
fixedId: number
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
ownerName: string
items: RelationCellItem[]
options: RelationCellOption[]
membershipAction: (
@ -42,351 +41,176 @@ type LeadershipStateDeputyRelationCellProps = {
measureOverflow?: boolean
}
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1` on the rest chip row. */
const CHIP_GAP_PX = 4
const COPY = {
fromLeadership: {
editLabel: 'Editar dobradinhas',
title: 'Atribuir dobradinhas',
description: 'Deputados estaduais em dobradinha com esta liderança.',
searchPlaceholder: 'Buscar deputado estadual…',
searchAriaLabel: 'Buscar deputado estadual',
saving: 'Salvando dobradinhas.',
savingAria: 'Salvando dobradinhas',
saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
},
fromStateDeputy: {
editLabel: 'Editar lideranças',
title: 'Atribuir lideranças',
description: 'Lideranças em dobradinha com este deputado estadual.',
searchPlaceholder: 'Buscar liderança…',
searchAriaLabel: 'Buscar liderança',
saving: 'Salvando lideranças.',
savingAria: 'Salvando lideranças',
saveError: 'Não foi possível atualizar as lideranças. Tente novamente.',
},
} as const
const itemLabel = (item: RelationCellItem): string =>
item.party ? `${item.label} (${item.party})` : item.label
/**
* Bidirectional cell for the `leadership.stateDeputies` edge.
* Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the
* same membership action.
* 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 `municipalityPortfolio`'s derivation cache).
*/
const normalizedLabelsByOptions = new WeakMap<readonly RelationCellOption[], Map<number, string>>()
const normalizedOptionLabels = (options: readonly RelationCellOption[]): Map<number, string> => {
const cached = normalizedLabelsByOptions.get(options)
if (cached) return cached
const normalized = new Map(
options.map((option) => [option.id, normalizeSearchPhrase(option.searchLabel)] as const),
)
normalizedLabelsByOptions.set(options, normalized)
return normalized
}
const DIRECTION_COPY: Record<
LeadershipStateDeputyRelationDirection,
{
drawerTitle: string
triggerVerb: string
emptyDrawerMessage: string
searchPlaceholder: string
searchLabel: string
suggestionsLabel: string
savingMessage: string
savedMessage: string
removedMessage: (count: number) => string
updateErrorMessage: string
}
> = {
fromLeadership: {
drawerTitle: 'Dobradinhas da liderança',
triggerVerb: 'dobradinhas',
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
searchPlaceholder: 'Buscar deputado estadual…',
searchLabel: 'Buscar deputado estadual',
suggestionsLabel: 'Sugestões de deputados estaduais',
savingMessage: 'Salvando dobradinhas.',
savedMessage: 'Dobradinhas salvas.',
removedMessage: (count) =>
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
updateErrorMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
},
fromStateDeputy: {
drawerTitle: 'Lideranças da dobradinha',
triggerVerb: 'lideranças',
emptyDrawerMessage: 'Nenhuma liderança vinculada.',
searchPlaceholder: 'Buscar liderança…',
searchLabel: 'Buscar liderança',
suggestionsLabel: 'Sugestões de lideranças',
savingMessage: 'Salvando lideranças.',
savedMessage: 'Lideranças salvas.',
removedMessage: (count) =>
count === 1 ? 'Liderança removida.' : `${count} lideranças removidas.`,
updateErrorMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
},
}
/**
* Bidirectional `leadership.stateDeputies` edge, on the same `RelationChipCell`
* that serves município portfolios (B37). Both directions write
* `leadershipId`/`stateDeputyId`/`assigned` through the same membership action;
* every chip here is a single item — no batch, no floor/ceiling.
*/
export const LeadershipStateDeputyRelationCell = ({
direction,
fixedId,
ownerName,
items,
options,
membershipAction,
measureOverflow = true,
}: LeadershipStateDeputyRelationCellProps) => {
const copy = COPY[direction]
const [open, setOpen] = useState(false)
const [current, setCurrent] = useState<RelationCellItem[]>(items)
const [query, setQuery] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const lastPropsRef = useRef(items)
const copy = DIRECTION_COPY[direction]
const [expanded, setExpanded] = useState(false)
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const chipRowRef = useRef<HTMLDivElement>(null)
const editRef = useRef<HTMLButtonElement>(null)
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
// Additions resolve through `options` (the addable catalog), same asymmetry
// as the município cell's `optionById`: only what can still be added needs to
// be looked up there, and a removal always has its item in `items` already.
const optionItemById = useMemo(
() => new Map(options.map((option) => [option.id, option.item])),
[options],
)
// Adopt server props only when they change from outside (navigation / RSC
// refresh) — an in-flight delta's optimistic state must not be clobbered by
// a prop identical in content to what we already had.
useEffect(() => {
const propIds = items.map((item) => item.id)
if (
sameIdSet(
propIds,
lastPropsRef.current.map((item) => item.id),
)
)
return
lastPropsRef.current = items
setCurrent(items)
}, [items])
const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current])
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
const filteredOptions = useMemo(() => {
if (!open) return []
return options.filter(
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
)
}, [open, options, currentIds, query])
const invalidateMeasurement = useCallback(() => {
setVisibleChipCount(null)
setMeasureToken((token) => token + 1)
}, [])
const chipsKey = current.map((item) => item.id).join('|')
useEffect(() => {
if (!measureOverflow) return
invalidateMeasurement()
}, [chipsKey, measureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow) return
const row = chipRowRef.current
if (!row) return
lastRowWidth.current = row.clientWidth
const observer = new ResizeObserver(() => {
if (row.clientWidth === lastRowWidth.current) return
lastRowWidth.current = row.clientWidth
invalidateMeasurement()
})
observer.observe(row)
return () => observer.disconnect()
}, [measureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow || visibleChipCount !== null) return
const row = chipRowRef.current
if (!row) return
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
if (chipElements.length === 0) {
setVisibleChipCount(0)
return
}
const rowRect = row.getBoundingClientRect()
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
const firstChip = chipRects[0]
if (!firstChip) {
setVisibleChipCount(0)
return
}
const chipHeight = firstChip.height
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
// Reserve room on the last visible line for "Ver mais…" (and the sibling pencil).
const toggleWidth =
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0
const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth
while (fitting > 0 && fitting < chipElements.length) {
const trailing = chipRects[fitting - 1]
if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
fitting -= 1
}
setVisibleChipCount(fitting)
}, [measureOverflow, measureToken, visibleChipCount])
const toggle = (itemId: number, assigned: boolean) => {
// The removed summary is captured up front (not re-derived from the
// catalog) so a failed removal can be undone even for an item that has
// since dropped out of `options` — same asymmetry as the `optionById`
// lookup below, which only needs to resolve *additions*.
const removedItem = assigned ? undefined : current.find((item) => item.id === itemId)
if (assigned && !optionById.has(itemId)) return
setErrorMessage(null)
setCurrent((previous) => {
if (assigned) {
if (previous.some((item) => item.id === itemId)) return previous
const option = optionById.get(itemId)
if (!option) return previous
return [...previous, option.item]
const buildChips = useCallback(
(ids: number[]): RelationChip[] => {
const chips: RelationChip[] = []
for (const id of ids) {
const item = itemById.get(id) ?? optionItemById.get(id)
if (!item) continue
chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] })
}
return previous.filter((item) => item.id !== itemId)
})
setQuery('')
return chips
},
[itemById, optionItemById],
)
const formData = new FormData()
if (direction === 'fromLeadership') {
formData.set('leadershipId', String(fixedId))
formData.set('stateDeputyId', String(itemId))
} else {
formData.set('leadershipId', String(itemId))
formData.set('stateDeputyId', String(fixedId))
}
formData.set('assigned', assigned ? 'true' : 'false')
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] => {
// Normalize the query ONCE and read pre-normalized labels, instead of an
// NFD pass plus three Unicode-property regexes per candidate per
// keystroke — the cost `wordStartFilter`'s own doc comment warns about,
// and which matters here because `options` in the `fromStateDeputy`
// direction is the whole (unpaginated) leadership catalog.
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.searchLabel, ids: [option.id] })
}
return hits
},
[options],
)
startTransition(async () => {
const result = await membershipAction({}, formData)
if (result.status === 'success') return
// Undo only this delta — not the whole cell back to `lastPropsRef` —
// so a failed toggle can't wipe an earlier toggle from the same batch
// that already saved successfully.
setCurrent((previous) =>
assigned
? previous.filter((item) => item.id !== itemId)
: removedItem
? [...previous, removedItem]
: previous,
)
const message = result.message ?? copy.saveError
setErrorMessage(message)
toast.error(message)
})
}
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const itemId = changedIds[0]
const formData = new FormData()
if (direction === 'fromLeadership') {
formData.set('leadershipId', String(fixedId))
formData.set('stateDeputyId', String(itemId))
} else {
formData.set('leadershipId', String(itemId))
formData.set('stateDeputyId', String(fixedId))
}
formData.set('assigned', assigned ? 'true' : 'false')
return formData
},
[direction, fixedId],
)
const statusMessage = errorMessage ?? (isPending ? copy.saving : '')
const showAllChips = !measureOverflow || expanded
const measuring = measureOverflow && visibleChipCount === null
const hasHiddenChips =
measureOverflow && visibleChipCount !== null && visibleChipCount < current.length
const visibleItems = (() => {
if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current
return current.slice(0, visibleChipCount)
})()
/**
* Only the pre-measurement render needs a height cap; once the chip list is
* trimmed the row is three lines tall on its own.
*/
const clamping = measuring && !showAllChips
const chipRowStyle =
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
// Mounted while measuring so width is reserved; hidden until overflow is known.
const expandToggle =
measureOverflow && (measuring || hasHiddenChips) ? (
<button
type="button"
data-relation-toggle
tabIndex={measuring ? -1 : undefined}
aria-hidden={measuring || undefined}
className={cn(
'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
event.stopPropagation()
setExpanded((previous) => !previous)
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
const chipCellCopy = useMemo<RelationChipCellCopy>(
() => ({
searchPlaceholder: copy.searchPlaceholder,
searchLabel: copy.searchLabel,
suggestionsLabel: copy.suggestionsLabel,
emptyDrawerMessage: copy.emptyDrawerMessage,
savingMessage: copy.savingMessage,
savedMessage: copy.savedMessage,
removedMessage: copy.removedMessage,
}),
[copy],
)
return (
<div className="flex flex-wrap items-center gap-1.5">
<div
ref={chipRowRef}
className={cn(
'flex flex-wrap items-center gap-1',
measureOverflow && current.length > 0 ? 'min-w-0 flex-1' : undefined,
clamping && 'overflow-hidden',
)}
style={chipRowStyle}
>
{current.length === 0 ? (
<span className="text-sm text-muted-foreground">—</span>
) : (
visibleItems.map((item) => (
<Badge key={item.id} variant="outline" asChild>
<Link href={item.href} data-relation-chip className="max-w-full truncate">
{itemLabel(item)}
</Link>
</Badge>
))
)}
{expandToggle}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={editRef}
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-label={copy.editLabel}
className={cn(
'inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60 text-foreground' : undefined,
)}
>
<PencilIcon className="size-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<div className="relative flex flex-col gap-2 p-3 pb-0">
<p className="text-sm font-medium">{copy.title}</p>
<p className="text-xs text-muted-foreground">{copy.description}</p>
{isPending ? (
<Spinner
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
aria-label={copy.savingAria}
/>
) : null}
</div>
{current.length > 0 ? (
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
{current.map((item) => (
<button
key={item.id}
type="button"
aria-label={`Remover ${item.label}`}
onClick={() => toggle(item.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{itemLabel(item)}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
</div>
) : null}
<Command shouldFilter={false} className="mt-1">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={copy.searchPlaceholder}
aria-label={copy.searchAriaLabel}
/>
<CommandList>
{filteredOptions.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
) : (
filteredOptions.map((option) => (
<CommandItem
key={option.id}
value={`${direction}-${option.id}`}
onSelect={() => toggle(option.id, true)}
>
<span className="truncate">{option.searchLabel}</span>
</CommandItem>
))
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/*
* Outside the Popover on purpose (B32+ F4): a region that unmounts with
* the overlay announces nothing once the overlay closes, and closing is
* what commits here. This cell still hand-rolls its trigger and popover —
* migrating it onto `CampaignCellEditOverlay` is B31's extraction trigger.
*/}
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
</div>
<RelationChipCell
ownerId={fixedId}
ownerName={ownerName}
ids={items.map((item) => item.id)}
buildChips={buildChips}
searchHits={searchHits}
buildFormData={buildFormData}
commitAction={membershipAction}
drawerTitle={copy.drawerTitle}
triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`}
updateErrorMessage={copy.updateErrorMessage}
copy={chipCellCopy}
measureOverflow={measureOverflow}
/>
)
}

View file

@ -1,69 +1,53 @@
'use client'
import { MapPinIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
useTransition,
type KeyboardEvent,
} from 'react'
import { toast } from 'sonner'
import { MapPinIcon } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { CampaignCellEditOverlay } 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 {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import {
buildMunicipalityPortfolioChips,
scopedPortfolioIndex,
searchMunicipalityPortfolio,
type MunicipalityPortfolioChip,
type MunicipalityPortfolioIndexEntry,
type MunicipalityPortfolioSearchHit,
} from '@/lib/municipalityPortfolio'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
const CHIP_GAP_PX = 6
const SEARCH_PLACEHOLDER = 'Buscar município, território ou ZE…'
const SEARCH_LABEL = 'Buscar município, território de identidade ou zona eleitoral'
/** Distinct from `SEARCH_LABEL`: two controls must not share one accessible name. */
const SUGGESTIONS_LABEL = 'Sugestões de municípios'
const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
if (hit.kind === 'municipality') return 'Município'
if (hit.kind === 'territory') return `Território · ${hit.count} municípios`
return `Zona eleitoral · ${hit.count} municípios`
}
const resultsMessage = (count: number): string => {
if (count === 0) return 'Nenhum resultado.'
return count === 1 ? '1 resultado.' : `${count} resultados.`
}
const toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip =>
chip.kind === 'territory'
? {
key: chip.key,
label: chip.label,
icon: <MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />,
trailingLabel: String(chip.municipalityIds.length),
ids: chip.municipalityIds,
removalSuffix: ` — ${chip.municipalityIds.length} municípios`,
}
: {
key: chip.key,
label: chip.label,
href: `/campanha/municipios/${chip.slug}`,
ids: [chip.municipalityId],
}
const withDelta = (
base: readonly number[],
changedIds: readonly number[],
assigned: boolean,
): number[] => {
const next = new Set(base)
for (const id of changedIds) {
if (assigned) next.add(id)
else next.delete(id)
}
return [...next]
}
type SuggestionSurface = 'inline' | 'drawer'
const toRelationHit = (hit: MunicipalityPortfolioSearchHit): RelationSearchHit => ({
key: hit.key,
label: hit.label,
description: hitDescription(hit),
ids: hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds,
})
type MunicipalityPortfolioCellProps = {
/** `null` in draft mode (a row that does not exist yet). */
@ -80,12 +64,7 @@ type MunicipalityPortfolioCellProps = {
addableIds?: ReadonlySet<number>
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
minItems?: number
/**
* Ceiling the relation enforces server-side. Checked here so a território that
* would blow the cap is refused with the reason BEFORE the optimistic apply —
* the server rejection would otherwise revert a whole batch with a message
* that never says which limit was hit.
*/
/** Ceiling the relation enforces server-side. */
maxItems?: number
/** Draft mode (new row): keep changes local and report them upward. */
draft?: boolean
@ -99,6 +78,12 @@ type MunicipalityPortfolioCellProps = {
updateErrorMessage: string
}
/**
* Município specialization of `RelationChipCell`: territory/ZE chips collapse a
* complete membership into one batch chip, and every município chip links to
* its detail page. All of the interaction machine (optimistic delta, undo,
* combobox, floor/ceiling, pointer-fine/coarse split) lives in the shared cell.
*/
export const MunicipalityPortfolioCell = ({
ownerId,
ownerName,
@ -113,701 +98,86 @@ export const MunicipalityPortfolioCell = ({
drawerTitle,
updateErrorMessage,
}: MunicipalityPortfolioCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [optimistic, setOptimistic] = useState<number[] | null>(null)
const [expanded, setExpanded] = useState(false)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const [isPending, startTransition] = useTransition()
const [feedback, setFeedback] = useState<
{ kind: 'saved' } | { kind: 'error'; message: string } | null
>(null)
/** 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. */
const activeFromKeyboard = useRef(false)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const chipRowRef = useRef<HTMLDivElement>(null)
const baseId = useId()
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
const optionId = useCallback(
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
[baseId],
)
useEffect(() => {
setOptimistic((current) => {
if (!current) return null
return sameIdSet(current, municipalityIds) ? null : current
})
}, [municipalityIds])
const effectiveIds = optimistic ?? municipalityIds
/**
* The baseline a deferred delta falls back to, which cannot be the render's
* `municipalityIds`: the toast's "Desfazer" outlives the render that created
* it, and by the time it fires the optimistic value has usually reconciled to
* null — so the functional updater takes the fallback branch and would rebuild
* the row from a set that predates every edit made since. That divergence is
* permanent, because the reconcile effect above only clears on an exact match.
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
* `municipality.advisors`, so a município this relation holds outside that
* scope cannot be removed either — the write would silently no-op at the
* document level with no way for the cell to explain why. Rather than let
* that failure surface, the chip for it never renders for the advisor at
* all; staff (`addableIds` undefined) still see and can remove every link.
*/
const latestIds = useRef(municipalityIds)
useEffect(() => {
latestIds.current = effectiveIds
}, [effectiveIds])
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
const chips = useMemo(
() => buildMunicipalityPortfolioChips(effectiveIds, municipalityIndex),
[effectiveIds, municipalityIndex],
const visibleIds = useMemo(
() => (addableIds ? municipalityIds.filter((id) => addableIds.has(id)) : municipalityIds),
[municipalityIds, addableIds],
)
/**
* Suggestions are scoped, chips are not: filtering the index the search reads
* also shrinks the território / ZE hits to what the actor may actually add,
* instead of offering a batch the server would reject halfway.
* instead of offering a batch the server would reject halfway. Memoized by
* `(index, addableIds)` rather than per row so all rows share one array — see
* `scopedPortfolioIndex`.
*/
const searchIndex = useMemo(
() =>
addableIds
? municipalityIndex.filter((entry) => addableIds.has(entry.id))
: municipalityIndex,
() => scopedPortfolioIndex(municipalityIndex, addableIds),
[addableIds, municipalityIndex],
)
const searching = query.trim().length > 0
/**
* 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 drawerSuggesting = searching && drawerOpen
const suggesting = inlineSuggesting || drawerSuggesting
const hits = useMemo(
() => (suggesting ? searchMunicipalityPortfolio(query, searchIndex, assignedIds) : []),
[suggesting, query, searchIndex, assignedIds],
const buildChips = useCallback(
(ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip),
[municipalityIndex],
)
const hitsKey = hits.map((hit) => hit.key).join('|')
useEffect(() => {
setActiveIndex(0)
}, [hitsKey])
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
useEffect(() => {
if (!activeFromKeyboard.current) return
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
block: 'nearest',
})
}, [activeIndex, activeSurface, optionId])
/**
* The token is what makes a stale measurement recoverable: an invalidation
* batched with a measurement would otherwise settle back on `null` without
* changing any dependency, leaving the cell unmeasured forever.
*/
const invalidateMeasurement = useCallback(() => {
setVisibleChipCount(null)
setMeasureToken((token) => token + 1)
}, [])
// Keyed by content, not array identity: a re-render that rebuilds the same
// chips must not throw away a measurement that is still valid. The remove
// button is absolutely positioned, so revealing it never changes a chip's box.
const chipsKey = chips.map((chip) => chip.key).join('|')
useEffect(() => {
invalidateMeasurement()
}, [chipsKey, invalidateMeasurement])
useEffect(() => {
const row = chipRowRef.current
if (!row) return
// Seeded here so the observer's initial callback isn't read as a resize.
lastRowWidth.current = row.clientWidth
const observer = new ResizeObserver(() => {
if (row.clientWidth === lastRowWidth.current) return
lastRowWidth.current = row.clientWidth
invalidateMeasurement()
})
observer.observe(row)
return () => observer.disconnect()
}, [invalidateMeasurement])
/**
* Runs on the frames where every chip is in the DOM, so the packing of the
* three visible rows — and the room the toggle needs on the third one — is
* read from the real layout instead of estimated from label lengths.
*/
useEffect(() => {
if (visibleChipCount !== null) return
const row = chipRowRef.current
if (!row) return
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-portfolio-chip]')]
if (chipElements.length === 0) {
setVisibleChipCount(0)
return
}
const rowRect = row.getBoundingClientRect()
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
const lastVisibleTop =
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
// The toggle — and the search input — must share the last visible line,
// otherwise they wrap into a line nobody asked for. The input's box is read
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
// leftover of the row, which would reserve the whole line. A zero-width
// rect means the input is `display: none` (coarse pointer), where the row
// has no trailing control to make room for at all.
const toggleWidth =
row.querySelector<HTMLElement>('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0
const inputElement = inputRef.current
const inputWidth =
inputElement && inputElement.getBoundingClientRect().width > 0
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
: 0
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
while (fitting > 0 && fitting < chipElements.length) {
const trailing = chipRects[fitting - 1]
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
fitting -= 1
}
setVisibleChipCount(fitting)
}, [measureToken, visibleChipCount])
/**
* Every delta is applied functionally, forward and back: two chips toggled in
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
* each compute from the set their own render captured and drop the other's work.
*/
const commit = (changedIds: number[], assigned: boolean) => {
if (changedIds.length === 0) return
if (draft) {
// Advanced synchronously as well: the parent holds the draft in state, so
// two toggles batched into one tick would otherwise both read the prop of
// the render they were queued from and the second would drop the first.
const next = withDelta(latestIds.current, changedIds, assigned)
latestIds.current = next
onDraftChange?.(next)
} else {
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
}
setQuery('')
setOpen(false)
setFeedback(null)
if (draft) return
if (ownerId === null) return
const formData = new FormData()
formData.set('ownerId', String(ownerId))
formData.set('assigned', assigned ? 'true' : 'false')
for (const id of changedIds) formData.append('municipalityIds', String(id))
startTransition(async () => {
const result = await commitAction({}, formData)
if (result.status === 'success') {
setFeedback({ kind: 'saved' })
// A território/ZE removal drops up to 30 links in one tap, and the row
// it left behind carries no trace of what was there. Single removals
// are one search away from being restored and get no toast.
if (!assigned && changedIds.length > 1) {
toast.success(`${changedIds.length} municípios removidos.`, {
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
})
}
return
}
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
})
}
const chipMunicipalityIds = (chip: MunicipalityPortfolioChip): number[] =>
chip.kind === 'territory' ? chip.municipalityIds : [chip.municipalityId]
/** The relation's floor, refused here so the server never has to say no. */
const removalFloorReason = (chip: MunicipalityPortfolioChip): string | null =>
assignedIds.size - chipMunicipalityIds(chip).length < minItems
? `${ownerName} precisa de pelo menos ${minItems === 1 ? 'um município' : `${minItems} municípios`}`
: null
const additionCapReason = (additions: number[]): string | null =>
maxItems !== undefined && assignedIds.size + additions.length > maxItems
? `${ownerName} aceita no máximo ${maxItems} municípios.`
: null
/** A batch chip says how much it removes, in the label a screen reader reads. */
const chipScopeLabel = (chip: MunicipalityPortfolioChip): string =>
chip.kind === 'territory'
? `${chip.label} — ${chip.municipalityIds.length} municípios`
: chip.label
const chipRemoveLabel = (chip: MunicipalityPortfolioChip, floorReason: string | null): string =>
floorReason
? `${floorReason} — não é possível remover ${chip.label}`
: `Remover ${chipScopeLabel(chip)}`
/**
* Refusals explain themselves on attempt instead of going silent: the control
* keeps `aria-disabled` (so it stays in the tab order and can still be
* activated) rather than `disabled`, which would hide the only copy of the
* reason from every keyboard user.
*/
const attemptRemove = (chip: MunicipalityPortfolioChip) => {
const floorReason = removalFloorReason(chip)
if (floorReason) {
toast.error(`${floorReason}.`)
return
}
commit(chipMunicipalityIds(chip), false)
}
const hitMunicipalityIds = (hit: MunicipalityPortfolioSearchHit): number[] =>
hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds
const pickHit = (hit: MunicipalityPortfolioSearchHit) => {
const additions = hitMunicipalityIds(hit)
const capReason = additionCapReason(additions)
if (capReason) {
toast.error(capReason)
return
}
commit(additions, true)
}
const closeSuggestions = () => {
setOpen(false)
setQuery('')
}
/**
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
* abandons the search. Without it the suggestions are mouse-only.
*/
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (hits.length === 0) return
event.preventDefault()
const delta = event.key === 'ArrowDown' ? 1 : -1
activeFromKeyboard.current = true
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
return
}
if (event.key === 'Home' || event.key === 'End') {
if (hits.length === 0) return
event.preventDefault()
activeFromKeyboard.current = true
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
return
}
if (event.key === 'Enter') {
const hit = hits[activeIndex]
if (!hit) return
event.preventDefault()
pickHit(hit)
return
}
if (event.key === 'Escape') {
if (drawerOpen) {
setQuery('')
return
}
closeSuggestions()
inputRef.current?.blur()
}
}
// Editing needs every chip reachable, so focusing the cell always expands it.
const showAllChips = expanded || open
const measuring = visibleChipCount === null
const hasHiddenChips = visibleChipCount !== null && visibleChipCount < chips.length
const visibleChips = showAllChips || measuring ? chips : chips.slice(0, visibleChipCount)
/**
* Only the pre-measurement render needs a height cap; once the chip list is
* trimmed the row is three lines tall on its own, and capping it again would
* clip whatever shares the last line (the search input is taller than a chip).
*/
const clamping = measuring && !showAllChips
/**
* One region, four things to say, in this order: a save in flight outranks
* everything; a failure outranks the search; and the search outranks a past
* success, otherwise "Municípios salvos." would shadow every later result
* count for the rest of the session.
*/
const statusMessage = isPending
? 'Salvando municípios…'
: feedback?.kind === 'error'
? feedback.message
: suggesting
? resultsMessage(hits.length)
: feedback?.kind === 'saved'
? 'Municípios salvos.'
: ''
/**
* Mounted while measuring so its width is reserved on the last line, but kept
* out of the tab order and the accessibility tree until the overflow it
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
* above it, so a tap opens the full list instead of expanding in place.
*/
const expandToggle =
measuring || hasHiddenChips ? (
<button
type="button"
data-portfolio-toggle
tabIndex={measuring ? -1 : undefined}
aria-hidden={measuring || undefined}
className={cn(
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
event.stopPropagation()
const next = !showAllChips
setExpanded(next)
// Focus is what keeps the cell open — collapsing must release it.
if (!next) {
setOpen(false)
inputRef.current?.blur()
}
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
const chipBody = (chip: MunicipalityPortfolioChip) => (
<>
{chip.kind === 'territory' ? (
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
) : null}
<span className="truncate">{chip.label}</span>
{chip.kind === 'territory' ? (
<span className="shrink-0 text-xs tabular-nums opacity-70">
{chip.municipalityIds.length}
</span>
) : null}
</>
const searchHits = useCallback(
(query: string, assignedIds: ReadonlySet<number>) =>
searchMunicipalityPortfolio(query, searchIndex, assignedIds).map(toRelationHit),
[searchIndex],
)
/**
* The remove button is a sibling of the chip link (never nested in it) and sits
* absolutely on its corner, so revealing it on hover does not resize the chip
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
* is permanently visible at a real tap size.
*/
const inlineChip = (chip: MunicipalityPortfolioChip) => {
const floorReason = removalFloorReason(chip)
return (
<span
key={chip.key}
data-portfolio-chip
className="group/chip relative inline-flex max-w-full"
>
{chip.kind === 'municipality' ? (
<Badge variant="secondary" className="max-w-full font-normal" asChild>
<Link
href={`/campanha/municipios/${chip.slug}`}
className="truncate underline-offset-4 hover:underline"
>
{chip.label}
</Link>
</Badge>
) : (
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<button
type="button"
data-chip-remove
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
className={cn(
// `border-muted-foreground/70`: the default 12%-alpha `border` token
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
"before:absolute before:-inset-1 before:content-['']",
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
floorReason
? 'cursor-not-allowed'
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
)}
onClick={(event) => {
event.stopPropagation()
attemptRemove(chip)
}}
>
<XIcon className="size-2.5" aria-hidden="true" />
</button>
</span>
)
}
const suggestionOption = (
hit: MunicipalityPortfolioSearchHit,
index: number,
surface: SuggestionSurface,
) => {
const capped = additionCapReason(hitMunicipalityIds(hit)) !== null
const active = index === activeIndex
return (
<button
key={hit.key}
id={optionId(surface, index)}
type="button"
role="option"
aria-selected={active}
aria-disabled={capped || undefined}
tabIndex={-1}
className={cn(
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
active && 'bg-muted',
capped ? 'opacity-60' : 'hover:bg-muted',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => {
activeFromKeyboard.current = false
setActiveIndex(index)
}}
onClick={() => pickHit(hit)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{hit.label}</span>
<span className="block text-xs text-muted-foreground">
{hitDescription(hit)}
{capped ? ' · excede o limite' : ''}
</span>
</span>
</button>
)
}
/**
* The listbox stays mounted while empty (the input's `aria-controls` must
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
* reader would count it as a selectable option.
*/
const suggestionList = (surface: SuggestionSurface) => (
<>
<div role="listbox" id={listboxId(surface)} aria-label={SUGGESTIONS_LABEL}>
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
</div>
{hits.length === 0 ? (
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
) : null}
</>
const buildFormData = useCallback(
(changedIds: number[], assigned: boolean) => {
const formData = new FormData()
formData.set('ownerId', String(ownerId))
formData.set('assigned', assigned ? 'true' : 'false')
for (const id of changedIds) formData.append('municipalityIds', String(id))
return formData
},
[ownerId],
)
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
const copy = useMemo<RelationChipCellCopy>(
() => ({
searchPlaceholder: 'Buscar município, território ou ZE…',
searchLabel: 'Buscar município, território de identidade ou zona eleitoral',
suggestionsLabel: 'Sugestões de municípios',
emptyDrawerMessage: 'Nenhum município vinculado.',
savingMessage: 'Salvando municípios…',
savedMessage: 'Municípios salvos.',
removedMessage: (count) => `${count} municípios removidos.`,
floorMessage: (min) =>
`${ownerName} precisa de pelo menos ${min === 1 ? 'um município' : `${min} municípios`}`,
capMessage: (max) => `${ownerName} aceita no máximo ${max} municípios.`,
}),
[ownerName],
)
return (
<div
ref={rootRef}
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
// the write is fire-and-forget, and a cell that never reacts reads as
// "nothing happened" until the next navigation proves otherwise.
aria-busy={isPending || undefined}
// Same box in both pointer modes so nothing reflows when the row is touched.
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
// Reached only on a fine pointer: the coarse overlay below covers the cell
// and stops the click, so the pointer policy stays in the media queries.
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('[data-chip-remove]') || target.closest('a')) return
inputRef.current?.focus()
setOpen(true)
}}
>
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
ref={chipRowRef}
className={cn(
'flex min-h-8 flex-wrap items-center gap-1.5',
clamping && 'max-h-18 overflow-hidden',
isPending && 'opacity-60 transition-opacity',
)}
>
{chips.length === 0 && !searching ? (
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
) : null}
{visibleChips.map(inlineChip)}
{expandToggle}
<input
ref={inputRef}
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
aria-controls={listboxId('inline')}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => {
if (!rootRef.current?.contains(document.activeElement)) {
closeSuggestions()
}
}, 120)
}}
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
/>
</div>
</PopoverAnchor>
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
<PopoverContent
align="start"
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
// The input owns focus the whole time — the list is a suggestion
// surface, not a dialog, and must never steal or restore focus.
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{suggestionList('inline')}
</PopoverContent>
</Popover>
{/*
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
* table cannot be asked to tell "open the município" from "delete it". The
* links it covers come back inside the Drawer.
*
* `variant` is hardcoded because the container takes its policy from the
* call site (B42) — and here the policy is the `pointer:` media query on
* the trigger, not the viewport, so a touch laptop gets this sheet and a
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
* undoes the shared class's hover: this trigger is an invisible pane over
* the whole cell, and tinting it would tint the chips it covers.
*/}
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={`Editar municípios de ${ownerName}`}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other two in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.kind === 'municipality' ? (
<Link
href={`/campanha/municipios/${chip.slug}`}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
</>
</CampaignCellEditOverlay>
{/*
* This cell keeps its own region rather than the shell's `statusMessage`:
* the shell mounts that one from the first open of the overlay, and on a
* fine pointer this cell's editor IS the cell — the Drawer never opens,
* so nothing would ever be announced.
*/}
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
</div>
<RelationChipCell
ownerId={ownerId}
ownerName={ownerName}
ids={visibleIds}
buildChips={buildChips}
searchHits={searchHits}
minItems={minItems}
maxItems={maxItems}
draft={draft}
onDraftChange={onDraftChange}
buildFormData={buildFormData}
commitAction={commitAction}
drawerTitle={drawerTitle}
triggerLabel={`Editar municípios de ${ownerName}`}
updateErrorMessage={updateErrorMessage}
copy={copy}
/>
)
}

View file

@ -0,0 +1,809 @@
'use client'
import { XIcon } from 'lucide-react'
import Link from 'next/link'
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
useTransition,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { toast } from 'sonner'
import { CampaignCellEditOverlay } 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 type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
const CHIP_GAP_PX = 6
const resultsMessage = (count: number): string => {
if (count === 0) return 'Nenhum resultado.'
return count === 1 ? '1 resultado.' : `${count} resultados.`
}
const withDelta = (
base: readonly number[],
changedIds: readonly number[],
assigned: boolean,
): number[] => {
const next = new Set(base)
for (const id of changedIds) {
if (assigned) next.add(id)
else next.delete(id)
}
return [...next]
}
type SuggestionSurface = 'inline' | 'drawer'
export type RelationChip = {
key: string
label: string
/** Omit for a chip that is not a link (e.g. a batch/territory chip). */
href?: string
/** Rendered before the label, e.g. a territory pin. */
icon?: ReactNode
/** Rendered after the label as a small muted count. */
trailingLabel?: string
/** Ids removing (or re-adding, on undo) this chip touches — usually one. */
ids: number[]
/** Appended to the label in the remove control's aria-label, e.g. ` — 2 municípios`. */
removalSuffix?: string
}
export type RelationSearchHit = {
key: string
label: string
/** Second line under the suggestion. Omit for a flat, single-line list. */
description?: string
ids: number[]
}
export type RelationChipCellCopy = {
searchPlaceholder: string
searchLabel: string
suggestionsLabel: string
emptyDrawerMessage: string
savingMessage: string
savedMessage: string
removedMessage: (count: number) => string
/** Only invoked when `minItems` is actually configured. */
floorMessage?: (min: number) => string
/** Only invoked when `maxItems` is actually configured. */
capMessage?: (max: number) => string
}
type RelationChipCellProps = {
/** `null` in draft mode (a row that does not exist yet). */
ownerId: number | null
/** Whose relation this is — spoken in the aria-labels and the Drawer description. */
ownerName: string
ids: number[]
buildChips: (ids: number[]) => RelationChip[]
searchHits: (query: string, assignedIds: ReadonlySet<number>) => RelationSearchHit[]
/** Floor the relation enforces server-side. */
minItems?: number
/**
* Ceiling the relation enforces server-side. Checked here so a batch that would
* blow the cap is refused with the reason BEFORE the optimistic apply — the
* server rejection would otherwise revert a whole batch with a message that
* never says which limit was hit.
*/
maxItems?: number
/** Draft mode (new row): keep changes local and report them upward. */
draft?: boolean
onDraftChange?: (ids: number[]) => void
buildFormData: (changedIds: number[], assigned: boolean) => FormData
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
drawerTitle: string
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
triggerLabel: string
updateErrorMessage: string
copy: RelationChipCellCopy
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
measureOverflow?: boolean
}
/**
* Shared interaction machine behind every bounded-set relation edited by chips
* (município portfolios, leadership↔dobradinha membership): optimistic delta
* commits that survive concurrent toggles, an undo toast scoped to exactly what
* changed, an ARIA 1.2 combobox for search/suggestions, a floor/ceiling refused
* with a spoken reason before the optimistic apply, and a pointer-fine inline
* editor / pointer-coarse Drawer split. What differs between relations — chip
* content, search, the write's field names, copy — is entirely prop-driven;
* nothing domain-specific lives here.
*/
export const RelationChipCell = ({
ownerId,
ownerName,
ids,
buildChips,
searchHits,
minItems = 0,
maxItems,
draft = false,
onDraftChange,
buildFormData,
commitAction,
drawerTitle,
triggerLabel,
updateErrorMessage,
copy,
measureOverflow = true,
}: RelationChipCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [optimistic, setOptimistic] = useState<number[] | null>(null)
const [expanded, setExpanded] = useState(false)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const [isPending, startTransition] = useTransition()
const [feedback, setFeedback] = useState<
{ kind: 'saved' } | { kind: 'error'; message: string } | null
>(null)
/** 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. */
const activeFromKeyboard = useRef(false)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const chipRowRef = useRef<HTMLDivElement>(null)
const baseId = useId()
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
const optionId = useCallback(
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
[baseId],
)
useEffect(() => {
setOptimistic((current) => {
if (!current) return null
return sameIdSet(current, ids) ? null : current
})
}, [ids])
const effectiveIds = optimistic ?? ids
/**
* The baseline a deferred delta falls back to, which cannot be the render's
* `ids`: the toast's "Desfazer" outlives the render that created it, and by
* the time it fires the optimistic value has usually reconciled to null — so
* the functional updater takes the fallback branch and would rebuild the row
* from a set that predates every edit made since. That divergence is
* permanent, because the reconcile effect above only clears on an exact match.
*/
const latestIds = useRef(ids)
useEffect(() => {
latestIds.current = effectiveIds
}, [effectiveIds])
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
const searching = query.trim().length > 0
/**
* 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 drawerSuggesting = searching && drawerOpen
const suggesting = inlineSuggesting || drawerSuggesting
const hits = useMemo(
() => (suggesting ? searchHits(query, assignedIds) : []),
[suggesting, query, searchHits, assignedIds],
)
const hitsKey = hits.map((hit) => hit.key).join('|')
useEffect(() => {
setActiveIndex(0)
}, [hitsKey])
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
useEffect(() => {
if (!activeFromKeyboard.current) return
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
block: 'nearest',
})
}, [activeIndex, activeSurface, optionId])
/**
* The token is what makes a stale measurement recoverable: an invalidation
* batched with a measurement would otherwise settle back on `null` without
* changing any dependency, leaving the cell unmeasured forever.
*/
const invalidateMeasurement = useCallback(() => {
setVisibleChipCount(null)
setMeasureToken((token) => token + 1)
}, [])
// Keyed by content, not array identity: a re-render that rebuilds the same
// chips must not throw away a measurement that is still valid. The remove
// 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
invalidateMeasurement()
}, [chipsKey, measureOverflow, invalidateMeasurement])
useEffect(() => {
if (!measureOverflow) return
const row = chipRowRef.current
if (!row) return
// Seeded here so the observer's initial callback isn't read as a resize.
lastRowWidth.current = row.clientWidth
const observer = new ResizeObserver(() => {
if (row.clientWidth === lastRowWidth.current) return
lastRowWidth.current = row.clientWidth
invalidateMeasurement()
})
observer.observe(row)
return () => observer.disconnect()
}, [measureOverflow, invalidateMeasurement])
/**
* Runs on the frames where every chip is in the DOM, so the packing of the
* three visible rows — and the room the toggle needs on the third one — is
* read from the real layout instead of estimated from label lengths.
*/
useEffect(() => {
if (!measureOverflow) return
if (visibleChipCount !== null) return
const row = chipRowRef.current
if (!row) return
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
if (chipElements.length === 0) {
setVisibleChipCount(0)
return
}
const rowRect = row.getBoundingClientRect()
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
const lastVisibleTop =
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
// The toggle — and the search input — must share the last visible line,
// otherwise they wrap into a line nobody asked for. The input's box is read
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
// leftover of the row, which would reserve the whole line. A zero-width
// rect means the input is `display: none` (coarse pointer), where the row
// has no trailing control to make room for at all.
const toggleWidth =
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
const inputElement = inputRef.current
const inputWidth =
inputElement && inputElement.getBoundingClientRect().width > 0
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
: 0
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
while (fitting > 0 && fitting < chipElements.length) {
const trailing = chipRects[fitting - 1]
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
fitting -= 1
}
setVisibleChipCount(fitting)
}, [measureOverflow, measureToken, visibleChipCount])
/**
* Every delta is applied functionally, forward and back: two chips toggled in
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
* each compute from the set their own render captured and drop the other's work.
*/
const commit = (changedIds: number[], assigned: boolean) => {
if (changedIds.length === 0) return
if (draft) {
// Advanced synchronously as well: the parent holds the draft in state, so
// two toggles batched into one tick would otherwise both read the prop of
// the render they were queued from and the second would drop the first.
const next = withDelta(latestIds.current, changedIds, assigned)
latestIds.current = next
onDraftChange?.(next)
} else {
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
}
setQuery('')
setOpen(false)
setFeedback(null)
if (draft) return
if (ownerId === null) return
const formData = buildFormData(changedIds, assigned)
startTransition(async () => {
const result = await commitAction({}, formData)
if (result.status === 'success') {
setFeedback({ kind: 'saved' })
// A batch removal drops several links in one tap, and the row it left
// behind carries no trace of what was there. Single removals are one
// search away from being restored and get no toast.
if (!assigned && changedIds.length > 1) {
toast.success(copy.removedMessage(changedIds.length), {
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
})
}
return
}
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
})
}
/** The relation's floor, refused here so the server never has to say no. */
const removalFloorReason = (chip: RelationChip): string | null =>
assignedIds.size - chip.ids.length < minItems
? (copy.floorMessage ?? ((min) => `${ownerName} precisa de pelo menos ${min}.`))(minItems)
: null
const additionCapReason = (additions: number[]): string | null =>
maxItems !== undefined && assignedIds.size + additions.length > maxItems
? (copy.capMessage ?? ((max) => `${ownerName} aceita no máximo ${max}.`))(maxItems)
: null
const chipRemoveLabel = (chip: RelationChip, floorReason: string | null): string =>
floorReason
? `${floorReason} — não é possível remover ${chip.label}`
: `Remover ${chip.label}${chip.removalSuffix ?? ''}`
/**
* Refusals explain themselves on attempt instead of going silent: the control
* keeps `aria-disabled` (so it stays in the tab order and can still be
* activated) rather than `disabled`, which would hide the only copy of the
* reason from every keyboard user.
*/
const attemptRemove = (chip: RelationChip) => {
const floorReason = removalFloorReason(chip)
if (floorReason) {
toast.error(`${floorReason}.`)
return
}
commit(chip.ids, false)
}
const pickHit = (hit: RelationSearchHit) => {
const capReason = additionCapReason(hit.ids)
if (capReason) {
toast.error(capReason)
return
}
commit(hit.ids, true)
}
const closeSuggestions = () => {
setOpen(false)
setQuery('')
}
/**
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
* abandons the search. Without it the suggestions are mouse-only.
*/
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (hits.length === 0) return
event.preventDefault()
const delta = event.key === 'ArrowDown' ? 1 : -1
activeFromKeyboard.current = true
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
return
}
if (event.key === 'Home' || event.key === 'End') {
if (hits.length === 0) return
event.preventDefault()
activeFromKeyboard.current = true
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
return
}
if (event.key === 'Enter') {
const hit = hits[activeIndex]
if (!hit) return
event.preventDefault()
pickHit(hit)
return
}
if (event.key === 'Escape') {
if (drawerOpen) {
setQuery('')
return
}
closeSuggestions()
inputRef.current?.blur()
}
}
// Editing needs every chip reachable, so focusing the cell always expands it.
const showAllChips = !measureOverflow || expanded || open
const measuring = measureOverflow && visibleChipCount === null
const hasHiddenChips =
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
const visibleChips =
!measureOverflow || showAllChips || measuring || visibleChipCount === null
? chips
: chips.slice(0, visibleChipCount)
/**
* Only the pre-measurement render needs a height cap; once the chip list is
* trimmed the row is three lines tall on its own, and capping it again would
* clip whatever shares the last line (the search input is taller than a chip).
*/
const clamping = measuring && !showAllChips
/**
* One region, four things to say, in this order: a save in flight outranks
* everything; a failure outranks the search; and the search outranks a past
* success, otherwise the saved message would shadow every later result count
* for the rest of the session.
*/
const statusMessage = isPending
? copy.savingMessage
: feedback?.kind === 'error'
? feedback.message
: suggesting
? resultsMessage(hits.length)
: feedback?.kind === 'saved'
? copy.savedMessage
: ''
/**
* Mounted while measuring so its width is reserved on the last line, but kept
* out of the tab order and the accessibility tree until the overflow it
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
* above it, so a tap opens the full list instead of expanding in place.
*/
const expandToggle =
measureOverflow && (measuring || hasHiddenChips) ? (
<button
type="button"
data-relation-toggle
tabIndex={measuring ? -1 : undefined}
aria-hidden={measuring || undefined}
className={cn(
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
event.stopPropagation()
const next = !showAllChips
setExpanded(next)
// Focus is what keeps the cell open — collapsing must release it.
if (!next) {
setOpen(false)
inputRef.current?.blur()
}
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
const chipBody = (chip: RelationChip) => (
<>
{chip.icon}
<span className="truncate">{chip.label}</span>
{chip.trailingLabel !== undefined ? (
<span className="shrink-0 text-xs tabular-nums opacity-70">{chip.trailingLabel}</span>
) : null}
</>
)
/**
* The remove button is a sibling of the chip link (never nested in it) and sits
* absolutely on its corner, so revealing it on hover does not resize the chip
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
* is permanently visible at a real tap size.
*/
const inlineChip = (chip: RelationChip) => {
const floorReason = removalFloorReason(chip)
return (
<span
key={chip.key}
data-relation-chip
className="group/chip relative inline-flex max-w-full"
>
{chip.href ? (
<Badge variant="secondary" className="max-w-full font-normal" asChild>
<Link href={chip.href} className="truncate underline-offset-4 hover:underline">
{chipBody(chip)}
</Link>
</Badge>
) : (
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<button
type="button"
data-chip-remove
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
className={cn(
// `border-muted-foreground/70`: the default 12%-alpha `border` token
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
"before:absolute before:-inset-1 before:content-['']",
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
floorReason
? 'cursor-not-allowed'
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
)}
onClick={(event) => {
event.stopPropagation()
attemptRemove(chip)
}}
>
<XIcon className="size-2.5" aria-hidden="true" />
</button>
</span>
)
}
const suggestionOption = (hit: RelationSearchHit, index: number, surface: SuggestionSurface) => {
const capped = additionCapReason(hit.ids) !== null
const active = index === activeIndex
return (
<button
key={hit.key}
id={optionId(surface, index)}
type="button"
role="option"
aria-selected={active}
aria-disabled={capped || undefined}
tabIndex={-1}
className={cn(
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
active && 'bg-muted',
capped ? 'opacity-60' : 'hover:bg-muted',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => {
activeFromKeyboard.current = false
setActiveIndex(index)
}}
onClick={() => pickHit(hit)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{hit.label}</span>
{hit.description ? (
<span className="block text-xs text-muted-foreground">
{hit.description}
{capped ? ' · excede o limite' : ''}
</span>
) : null}
</span>
</button>
)
}
/**
* The listbox stays mounted while empty (the input's `aria-controls` must
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
* reader would count it as a selectable option.
*/
const suggestionList = (surface: SuggestionSurface) => (
<>
<div role="listbox" id={listboxId(surface)} aria-label={copy.suggestionsLabel}>
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
</div>
{hits.length === 0 ? (
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
) : null}
</>
)
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
return (
<div
ref={rootRef}
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
// the write is fire-and-forget, and a cell that never reacts reads as
// "nothing happened" until the next navigation proves otherwise.
aria-busy={isPending || undefined}
// Same box in both pointer modes so nothing reflows when the row is touched.
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
// Reached only on a fine pointer: the coarse overlay below covers the cell
// and stops the click, so the pointer policy stays in the media queries.
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('[data-chip-remove]') || target.closest('a')) return
inputRef.current?.focus()
setOpen(true)
}}
>
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
ref={chipRowRef}
className={cn(
'flex min-h-8 flex-wrap items-center gap-1.5',
clamping && 'max-h-18 overflow-hidden',
isPending && 'opacity-60 transition-opacity',
)}
>
{chips.length === 0 && !searching ? (
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
) : null}
{visibleChips.map(inlineChip)}
{expandToggle}
<input
ref={inputRef}
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
aria-controls={listboxId('inline')}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => {
if (!rootRef.current?.contains(document.activeElement)) {
closeSuggestions()
}
}, 120)
}}
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
/>
</div>
</PopoverAnchor>
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
<PopoverContent
align="start"
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
// The input owns focus the whole time — the list is a suggestion
// surface, not a dialog, and must never steal or restore focus.
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{suggestionList('inline')}
</PopoverContent>
</Popover>
{/*
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
* table cannot be asked to tell "open the entity" from "delete it". The
* links it covers come back inside the Drawer.
*
* `variant` is hardcoded because the container takes its policy from the
* call site (B42) — and here the policy is the `pointer:` media query on
* the trigger, not the viewport, so a touch laptop gets this sheet and a
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
* undoes the shared class's hover: this trigger is an invisible pane over
* the whole cell, and tinting it would tint the chips it covers.
*/}
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={triggerLabel}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.href ? (
<Link
href={chip.href}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={copy.searchPlaceholder}
aria-label={copy.searchLabel}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
</>
</CampaignCellEditOverlay>
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
</div>
)
}

View file

@ -1,28 +1,22 @@
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
import {
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
MAX_LEADERSHIP_STATE_DEPUTIES,
} from '@/lib/schemas/leadership'
/**
* Returns the next state-deputy-id list after applying one membership
* change, or `null` when the leadership is already in the desired state (no
* write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure delta
* calculator for the other side of a relation edited by chip+combobox
* (B31's `leadership.stateDeputies`, edited from the "Dobradinhas" column of
* `/campanha/liderancas`).
* write needed) — the `leadership.stateDeputies` side of the relation, edited
* from the "Dobradinhas" column of `/campanha/liderancas` (B31) and from the
* "Lideranças" column of `/campanha/dobradinhas` (B36).
*/
export const nextStateDeputyIdsAfterMembership = (
currentStateDeputyIDs: readonly number[],
stateDeputyId: number,
assigned: boolean,
): number[] | null => {
const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId)
if (assigned === alreadyAssigned) return null
if (assigned) {
if (currentStateDeputyIDs.length >= MAX_LEADERSHIP_STATE_DEPUTIES) {
throw new Error(
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
)
}
return [...currentStateDeputyIDs, stateDeputyId]
}
return currentStateDeputyIDs.filter((id) => id !== stateDeputyId)
}
): number[] | null =>
nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, {
max: MAX_LEADERSHIP_STATE_DEPUTIES,
message: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
})

View file

@ -1,4 +1,8 @@
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
import {
MAX_ADVISORS_PER_MUNICIPALITY,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
} from '@/lib/schemas/municipality'
/**
* Returns the next advisor-id list after applying one membership change, or
@ -11,17 +15,8 @@ export const nextAdvisorIdsAfterMembership = (
currentAdvisorIDs: readonly number[],
advisorId: number,
assigned: boolean,
): number[] | null => {
const alreadyAssigned = currentAdvisorIDs.includes(advisorId)
if (assigned === alreadyAssigned) return null
if (assigned) {
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
throw new Error(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
}
return [...currentAdvisorIDs, advisorId]
}
return currentAdvisorIDs.filter((id) => id !== advisorId)
}
): number[] | null =>
nextIdsAfterMembership(currentAdvisorIDs, advisorId, assigned, {
max: MAX_ADVISORS_PER_MUNICIPALITY,
message: MUNICIPALITY_ADVISORS_CAP_MESSAGE,
})

View file

@ -125,6 +125,38 @@ const portfolioIndexDerivations = (
return derived
}
/**
* The index an advisor's suggestions may draw from, as ONE array shared by every
* row — a per-row `.filter()` returns a distinct array identity even when the
* content is identical, which silently defeats the `WeakMap` above and makes each
* searching row rebuild the same O(435) derivation. Keyed on both inputs because
* the scope belongs to the actor, not to the index.
*/
const scopedIndexByAddableIds = new WeakMap<
ReadonlySet<number>,
WeakMap<readonly MunicipalityPortfolioIndexEntry[], readonly MunicipalityPortfolioIndexEntry[]>
>()
export const scopedPortfolioIndex = (
index: readonly MunicipalityPortfolioIndexEntry[],
addableIds: ReadonlySet<number> | undefined,
): readonly MunicipalityPortfolioIndexEntry[] => {
if (!addableIds) return index
let byIndex = scopedIndexByAddableIds.get(addableIds)
if (!byIndex) {
byIndex = new WeakMap()
scopedIndexByAddableIds.set(addableIds, byIndex)
}
const cached = byIndex.get(index)
if (cached) return cached
const scoped = index.filter((entry) => addableIds.has(entry.id))
byIndex.set(index, scoped)
return scoped
}
const municipalityIdsForTseZone = (
zoneNumber: number,
bySlug: Map<string, MunicipalityPortfolioIndexEntry>,

View file

@ -0,0 +1,22 @@
import { nextIdsAfterMembership } from '@/lib/relationMembershipDelta'
import {
MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
} from '@/lib/schemas/municipality'
/**
* Returns the next state-deputy-id list for a município after applying one
* membership change, or `null` when the município is already in the desired
* state (no write needed) — the município side of the relation that a
* `stateDeputy` chip batch touches (B37, from the "Municípios" column of
* `/campanha/dobradinhas`).
*/
export const nextStateDeputyIdsAfterMunicipalityMembership = (
currentStateDeputyIDs: readonly number[],
stateDeputyId: number,
assigned: boolean,
): number[] | null =>
nextIdsAfterMembership(currentStateDeputyIDs, stateDeputyId, assigned, {
max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
message: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
})

View file

@ -0,0 +1,32 @@
/**
* Returns the next id list after applying ONE membership change to a bounded
* relation, or `null` when the owner is already in the desired state (no write
* needed) — the shape every chip-toggled relation in `/campanha` needs before
* it writes: `municipality.advisors` (B19/B27), `leadership.stateDeputies`
* (B31/B36) and `municipality.stateDeputies` (B37).
*
* The cap travels as data because that is the only thing that differs between
* them, and its `message` is a constant owned by the relation's schema rather
* than a literal: `mapCampaignFormActionError` matches thrown messages against
* `safeMessages` by exact string, so a reworded throw would silently collapse
* a real refusal into the generic "tente novamente".
*
* Not the batch sibling: `nextMunicipalityIdsAfterLeadershipMembership` moves a
* whole território at once and enforces a floor, so it reports `added`/`changed`
* instead of just the next list.
*/
export const nextIdsAfterMembership = (
currentIDs: readonly number[],
id: number,
assigned: boolean,
cap: { max: number; message: string },
): number[] | null => {
const alreadyAssigned = currentIDs.includes(id)
if (assigned === alreadyAssigned) return null
if (assigned) {
if (currentIDs.length >= cap.max) throw new Error(cap.message)
return [...currentIDs, id]
}
return currentIDs.filter((currentId) => currentId !== id)
}

View file

@ -1,6 +1,9 @@
import { z } from 'zod'
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
import {
MAX_MUNICIPALITIES_PER_BATCH,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
} from '@/lib/schemas/municipality'
import {
brazilianMobile,
optionalPersistedEmail,
@ -41,7 +44,7 @@ export const ADVISOR_ACTION_SAFE_MESSAGES = [
PLACEHOLDER_RESET_MESSAGE,
'Só é possível gerenciar contas com papel de Assessor nesta tela.',
'Use Meu perfil para alterar a própria conta.',
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
] as const
export const advisorCreateSchema = z.object({
@ -64,7 +67,7 @@ export const advisorProfileUpdateSchema = z
export const advisorMunicipalitiesBatchSchema = z.object({
advisorId: positiveRelationshipId,
municipalityIds: z.array(positiveRelationshipId).min(1).max(435),
municipalityIds: z.array(positiveRelationshipId).min(1).max(MAX_MUNICIPALITIES_PER_BATCH),
assigned: z.boolean(),
})

View file

@ -48,6 +48,9 @@ export const LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE =
export const LEADERSHIP_MUNICIPALITY_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_MUNICIPALITIES} municípios.`
/** Thrown by the chip toggle on both sides of the relation (B31/B36), so both routes allowlist it. */
export const LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`
/** Thrown by the actions and matched verbatim by every route's `safeMessages`. */
export const LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE =
'Você só pode vincular lideranças aos municípios que assessora.'

View file

@ -7,9 +7,29 @@ import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge'
export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const
export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number]
/**
* Anti-payload bound, not a domain rule: `municipality.stateDeputies` has no
* product-level ceiling (a município can be dobrado with any number of
* deputados), and the catalog itself caps out at 435. Named so the chip-batch
* write (B37) and this whole-array strategy update stay on the same number —
* they parse through the same schema, and a batch write that pushed past a
* lower bound would leave the strategy ficha unsavable on its next submit.
*/
export const MAX_STATE_DEPUTIES_PER_MUNICIPALITY = 435
/**
* Anti-payload bound on a batch that names many municípios at once (a whole
* território/ZE): the catalog is 435 rows and ids are unique, so this is the
* whole universe and not a product rule. Named because both município-side
* batch schemas (`advisor`, `stateDeputy`) were carrying a bare `435`.
*/
export const MAX_MUNICIPALITIES_PER_BATCH = 435
export const MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.`
const stateDeputiesArraySchema = z
.array(positiveRelationshipId)
.max(20)
.max(MAX_STATE_DEPUTIES_PER_MUNICIPALITY)
.transform((ids) => [...new Set(ids)])
export const municipalityStrategyUpdateSchema = z.object({
@ -40,6 +60,8 @@ export const parsePoliticalTrendStatusFormValue = (
export const MAX_ADVISORS_PER_MUNICIPALITY = 10
export const MUNICIPALITY_ADVISORS_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`
export const municipalityAdvisorsAssignmentSchema = z.object({
municipality: positiveRelationshipId,
advisors: z
@ -60,7 +82,7 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE =
export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
] as const
export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =

View file

@ -1,5 +1,9 @@
import { z } from 'zod'
import {
MAX_MUNICIPALITIES_PER_BATCH,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
} from '@/lib/schemas/municipality'
import {
positiveRelationshipId,
trimmedNullableText,
@ -18,5 +22,43 @@ export const stateDeputyUpdateSchema = z.object({
notes: trimmedNullableText(4000),
})
/**
* Refused staff-only write on any dobradinha — thrown by the create/update
* policy AND by the município batch. Named once because every route's
* `safeMessages` matches it by exact string: a reworded literal in one of the
* four places it used to live would have silently become the generic error.
*/
export const STATE_DEPUTY_STAFF_MESSAGE =
'Somente a coordenação e a assessoria gerenciam dobradinhas.'
/** 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.'
/**
* 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)
* on `municipality.stateDeputies` — the inverted side of the relation this
* `stateDeputy` sits on. The bound is the universe of municípios in one batch
* call (a whole território), not the per-município cap on dobradinhas, which
* lives in `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`.
*/
export const stateDeputyMunicipalitiesBatchSchema = z.object({
stateDeputyId: positiveRelationshipId,
municipalityIds: z
.array(positiveRelationshipId)
.min(1)
.max(MAX_MUNICIPALITIES_PER_BATCH)
.transform((ids) => [...new Set(ids)]),
assigned: z.boolean(),
})
export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [
STATE_DEPUTY_STAFF_MESSAGE,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
] as const
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
export type StateDeputyMunicipalitiesBatchInput = z.input<
typeof stateDeputyMunicipalitiesBatchSchema
>

View file

@ -23,7 +23,7 @@ export type StateDeputyRowViewModel = {
name: string
slug: string
party: string | null
municipalityCount: number
municipalityIDs: number[]
leaderships: LeadershipRelationSummary[]
}
@ -98,11 +98,14 @@ export const loadStateDeputyListPageData = async (
])
const stateDeputyIDs = result.docs.map((doc) => doc.id)
const municipalityCounts = new Map<number, number>()
const municipalityIDsByDeputy = new Map<number, number[]>()
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
if (stateDeputyIDs.length) {
const [municipalities, leaderships] = await Promise.all([
// Intentional admin bypass: the chips render outside an advisor's scope
// on purpose (B34 precedent) — an advisor must be able to see and undo
// a município link even outside their own portfolio.
payload.find({
collection: 'municipality',
where: { stateDeputies: { in: stateDeputyIDs } },
@ -130,7 +133,9 @@ export const loadStateDeputyListPageData = async (
for (const deputy of municipality.stateDeputies ?? []) {
const id = relationshipId(deputy)
if (id !== null && stateDeputyIDs.includes(id)) {
municipalityCounts.set(id, (municipalityCounts.get(id) ?? 0) + 1)
const list = municipalityIDsByDeputy.get(id) ?? []
list.push(municipality.id)
municipalityIDsByDeputy.set(id, list)
}
}
}
@ -162,7 +167,7 @@ export const loadStateDeputyListPageData = async (
name: doc.name,
slug: doc.slug,
party: doc.party ?? null,
municipalityCount: municipalityCounts.get(doc.id) ?? 0,
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
})),
totalDocs: result.totalDocs,

View file

@ -303,7 +303,7 @@ describe('loadStateDeputyListPageData', () => {
expect(result.rows[0]).toMatchObject({
slug: stateDeputy.slug,
party: 'PT',
municipalityCount: 0,
municipalityIDs: [],
leaderships: [],
})
})

View file

@ -0,0 +1,185 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setStateDeputyMunicipalitiesBatchRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { stateDeputies: true },
overrideAccess: true,
})
return (municipality.stateDeputies ?? []).map((entry) =>
typeof entry === 'number' ? entry : entry.id,
)
}
describe('setStateDeputyMunicipalitiesBatchRecord (B37)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s add and remove a municipality by delta',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const municipality = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy()
const { slugs: addedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, {
stateDeputyId: stateDeputy.id,
municipalityIds: [municipality.id],
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(addedSlugs).toEqual([municipality.slug])
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
const { slugs: removedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(
payload,
actor,
{
stateDeputyId: stateDeputy.id,
municipalityIds: [municipality.id],
assigned: false,
},
)
expect(removedSlugs).toEqual([municipality.slug])
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
},
)
it('lets an advisor add a state deputy to a municipality they administer', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
const stateDeputy = await fixtures.createStateDeputy()
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
stateDeputyId: stateDeputy.id,
municipalityIds: [administered.id],
assigned: true,
})
expect(slugs).toEqual([administered.slug])
expect(await stateDeputyIdsOf(administered.id)).toEqual([stateDeputy.id])
})
it('denies an advisor writing to a municipality outside their portfolio', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const outside = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy()
await expect(
setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
stateDeputyId: stateDeputy.id,
municipalityIds: [outside.id],
assigned: true,
}),
).rejects.toThrow()
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 stateDeputy = await fixtures.createStateDeputy()
await expect(
setStateDeputyMunicipalitiesBatchRecord(payload, leaderActor, {
stateDeputyId: stateDeputy.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(/coordenação e a assessoria/i)
})
it('adds a whole batch (território / zona) in one write, touching every município', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy()
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
municipalityIds: [first.id, second.id],
assigned: true,
})
fixtures.touchMunicipality(first.id)
fixtures.touchMunicipality(second.id)
expect(slugs.sort()).toEqual([first.slug, second.slug].sort())
expect(await stateDeputyIdsOf(first.id)).toEqual([stateDeputy.id])
expect(await stateDeputyIdsOf(second.id)).toEqual([stateDeputy.id])
})
it('is idempotent — a no-op write touches nothing and returns no slugs to revalidate', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const stateDeputy = await fixtures.createStateDeputy()
await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
municipalityIds: [municipality.id],
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: stateDeputy.id,
municipalityIds: [municipality.id],
assigned: true,
})
expect(slugs).toEqual([])
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
})
it('serializes a concurrent delta on the same municipality through the advisory lock', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const first = await fixtures.createStateDeputy()
const second = await fixtures.createStateDeputy()
await Promise.all([
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: first.id,
municipalityIds: [municipality.id],
assigned: true,
}),
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
stateDeputyId: second.id,
municipalityIds: [municipality.id],
assigned: true,
}),
])
fixtures.touchMunicipality(municipality.id)
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
[first.id, second.id].sort((a, b) => a - b),
)
})
})

View file

@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
describe('nextStateDeputyIdsAfterMembership', () => {
it('adds a state deputy not yet present', () => {
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
})
it('removes a state deputy present in the list', () => {
expect(nextStateDeputyIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
})
it('is a no-op (returns null) when already in the desired state', () => {
expect(nextStateDeputyIdsAfterMembership([1, 2], 2, true)).toBeNull()
expect(nextStateDeputyIdsAfterMembership([1, 2], 3, false)).toBeNull()
})
it('throws once the leadership is at the state-deputy cap', () => {
const atCap = Array.from({ length: MAX_LEADERSHIP_STATE_DEPUTIES }, (_, index) => index + 1)
expect(() => nextStateDeputyIdsAfterMembership(atCap, 999, true)).toThrow(
`Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`,
)
})
})

View file

@ -0,0 +1,157 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
LeadershipStateDeputyRelationCell,
type RelationCellItem,
type RelationCellOption,
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/**
* B37: this cell used to have its own Popover+Command implementation and zero
* coverage. It is now a thin wrapper over `RelationChipCell` — the same engine
* `MunicipalityPortfolioCell` uses — so these pins cover what changed for it:
* the write's field names per `direction`, the party suffix, and the success
* feedback the old Popover never gave (no toast, but the live region says so).
*/
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const OPTIONS: RelationCellOption[] = [
{
id: 10,
searchLabel: 'Ana Bastos',
item: { id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
},
{
id: 11,
searchLabel: 'Beto Lima',
item: { id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
},
]
const items: RelationCellItem[] = [
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
]
const cell = (
overrides: Partial<{
direction: 'fromLeadership' | 'fromStateDeputy'
items: RelationCellItem[]
membershipAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
}> = {},
) =>
createElement(LeadershipStateDeputyRelationCell, {
direction: overrides.direction ?? 'fromLeadership',
fixedId: 7,
ownerName: 'Maria Souza',
items: overrides.items ?? items,
options: OPTIONS,
membershipAction: overrides.membershipAction ?? (async () => ({ status: 'success' as const })),
measureOverflow: false,
})
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(cleanup)
afterAll(() => vi.unstubAllGlobals())
describe('leadership ↔ state deputy relation cell', () => {
it('renders each item as a linked chip, with the party suffix when present', () => {
render(
cell({
items: [
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
{ id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
],
}),
)
expect(screen.getByText('Ana Bastos')).toBeTruthy()
expect(screen.getByText('Beto Lima (PT)')).toBeTruthy()
})
it('sends leadershipId/stateDeputyId/assigned=false, matching `fromLeadership`', async () => {
const membershipAction = vi.fn(
async (_state: CampaignFormActionState, _formData: FormData) =>
({ status: 'success' as const }) satisfies CampaignFormActionState,
)
render(cell({ direction: 'fromLeadership', membershipAction }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
const formData = membershipAction.mock.calls[0]?.[1] as FormData
expect(formData.get('leadershipId')).toBe('7')
expect(formData.get('stateDeputyId')).toBe('10')
expect(formData.get('assigned')).toBe('false')
})
it('swaps the field the fixed id fills for `fromStateDeputy`', async () => {
const membershipAction = vi.fn(
async (_state: CampaignFormActionState, _formData: FormData) =>
({ status: 'success' as const }) satisfies CampaignFormActionState,
)
render(cell({ direction: 'fromStateDeputy', membershipAction }))
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
const formData = membershipAction.mock.calls[0]?.[1] as FormData
expect(formData.get('stateDeputyId')).toBe('7')
expect(formData.get('leadershipId')).toBe('10')
})
it('adds a searched option and reports the live region as saved', async () => {
render(cell())
fireEvent.click(screen.getByRole('button', { name: 'Editar dobradinhas de Maria Souza' }))
const dialog = await screen.findByRole('dialog')
const searchInput = within(dialog).getByRole('combobox', { name: 'Buscar deputado estadual' })
fireEvent.change(searchInput, { target: { value: 'Beto' } })
fireEvent.click(await within(dialog).findByRole('option', { name: /Beto Lima/ }))
await waitFor(() => expect(within(dialog).getByText('Beto Lima (PT)')).toBeTruthy())
await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Dobradinhas salvas.'))
})
it('shows the empty dash when there are no items yet', () => {
render(cell({ items: [] }))
expect(screen.getByText('—')).toBeTruthy()
})
it('undoes a failed removal without reverting a sibling change from the same render', async () => {
const membershipAction = vi.fn(
async (
_state: CampaignFormActionState,
_formData: FormData,
): Promise<CampaignFormActionState> => ({
message: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
}),
)
render(cell({ membershipAction }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
})
await waitFor(() => expect(screen.getByText('Ana Bastos')).toBeTruthy())
})
})

View file

@ -1,26 +0,0 @@
import { describe, expect, it } from 'vitest'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import { MAX_ADVISORS_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
describe('nextAdvisorIdsAfterMembership', () => {
it('adds an advisor not yet present', () => {
expect(nextAdvisorIdsAfterMembership([1, 2], 3, true)).toEqual([1, 2, 3])
})
it('removes an advisor present in the list', () => {
expect(nextAdvisorIdsAfterMembership([1, 2, 3], 2, false)).toEqual([1, 3])
})
it('is a no-op (returns null) when already in the desired state', () => {
expect(nextAdvisorIdsAfterMembership([1, 2], 2, true)).toBeNull()
expect(nextAdvisorIdsAfterMembership([1, 2], 3, false)).toBeNull()
})
it('throws once the municipality is at the advisor cap', () => {
const atCap = Array.from({ length: MAX_ADVISORS_PER_MUNICIPALITY }, (_, index) => index + 1)
expect(() => nextAdvisorIdsAfterMembership(atCap, 999, true)).toThrow(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
})
})

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import {
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
MAX_LEADERSHIP_STATE_DEPUTIES,
} from '@/lib/schemas/leadership'
import {
MAX_ADVISORS_PER_MUNICIPALITY,
MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
MUNICIPALITY_ADVISORS_CAP_MESSAGE,
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
} from '@/lib/schemas/municipality'
/**
* One contract, three relations. Each wrapper only binds its own cap to
* `nextIdsAfterMembership`, so the delta behaviour is pinned once and each
* wrapper is additionally pinned to refuse at ITS cap with ITS message — the
* pair `safeMessages` matches by exact string.
*/
const relations = [
{
name: 'nextAdvisorIdsAfterMembership (municipality.advisors)',
next: nextAdvisorIdsAfterMembership,
max: MAX_ADVISORS_PER_MUNICIPALITY,
capMessage: MUNICIPALITY_ADVISORS_CAP_MESSAGE,
},
{
name: 'nextStateDeputyIdsAfterMembership (leadership.stateDeputies)',
next: nextStateDeputyIdsAfterMembership,
max: MAX_LEADERSHIP_STATE_DEPUTIES,
capMessage: LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
},
{
name: 'nextStateDeputyIdsAfterMunicipalityMembership (municipality.stateDeputies)',
next: nextStateDeputyIdsAfterMunicipalityMembership,
max: MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
capMessage: MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
},
] as const
describe.each(relations)('$name', ({ next, max, capMessage }) => {
it('adds an id not yet present', () => {
expect(next([1, 2], 3, true)).toEqual([1, 2, 3])
})
it('removes an id present in the list', () => {
expect(next([1, 2, 3], 2, false)).toEqual([1, 3])
})
it('is a no-op (returns null) when already in the desired state', () => {
expect(next([1, 2], 2, true)).toBeNull()
expect(next([1, 2], 3, false)).toBeNull()
})
it('can go to zero', () => {
expect(next([1], 1, false)).toEqual([])
})
it('does not mutate the list it was given', () => {
const current = [1, 2]
next(current, 3, true)
next(current, 2, false)
expect(current).toEqual([1, 2])
})
it('throws its own cap message once at its own cap', () => {
const atCap = Array.from({ length: max }, (_, index) => index + 1)
expect(() => next(atCap, max + 1, true)).toThrow(capMessage)
// At the cap, a removal is still allowed — the ceiling is not a freeze.
expect(next(atCap, 1, false)).toHaveLength(max - 1)
})
})