From ef3d5b6c225a6d9b02859074ad57ce65bb9b2be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 19:30:26 -0300 Subject: [PATCH] =?UTF-8?q?B31:=20coluna=20de=20dobradinhas=20na=20lista?= =?UTF-8?q?=20de=20lideran=C3=A7as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coluna "Dobradinhas" em /campanha/liderancas com chips clicáveis para /campanha/dobradinhas/ e edição via Popover+Command (padrão B27, escrita via server action delta + lock, como B19). - LeadershipRowViewModel.stateDeputies (StateDeputySummary[]) via loadStateDeputySummaries, substituindo stateDeputyNames. - setLeadershipStateDeputyMembership(Record): delta com withPayloadTransaction + acquireTextAdvisoryLocks + revalidatePath. - nextStateDeputyIdsAfterMembership (lib/leadershipStateDeputyMembership.ts), espelha nextAdvisorIdsAfterMembership. - loadStateDeputyOptions estendido com plainName/party/slug. - Catálogo entregue uma vez por tabela via leadershipColumns(options) (columns factory, mesmo precedente de municipalityListColumns). /simplify (1ª rodada): corrige revert otimista por delta (não pelo snapshot inteiro), remove fallback silencioso na adição, extrai sameIdSet compartilhado com MunicipalityListAdvisorsControl (B27), e evita leitura+revalidação supérfluas no caminho no-op da action. /simplify (2ª rodada, pós-rebase): troca o StateDeputyOptionsProvider (Context) inicial pela columns factory acima — a 1ª rodada tinha validado o Context por falta da evidência de que MunicipalityList.tsx já resolve o mesmo problema sem Context; corrige revalidatePath para o id concreto em vez do template dinâmico '/campanha/liderancas/[id]' (que invalidava toda ficha a cada toggle); remove non-null assertion movendo a guarda para dentro do setCurrent updater. Gate verde: tsc, lint --max-warnings=0, format:check, unit 530/530, int 406/406 (incl. os 6 de campaignLeadershipStateDeputyMembership), build. Aikido: 0 achados nos arquivos novos/editados. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/dobradinhas-lista-liderancas.md | 49 +++- docs/roadmap.md | 14 +- .../campanha/(app)/liderancas/formActions.ts | 33 +++ .../campanha/(app)/liderancas/page.tsx | 32 ++- .../(campaign)/campanha/actions/leadership.ts | 106 +++++++++ .../LeadershipStateDeputiesCell.tsx | 222 ++++++++++++++++++ .../MunicipalityListAdvisorsControl.tsx | 7 +- src/lib/leadershipStateDeputyMembership.ts | 28 +++ src/lib/sameIdSet.ts | 11 + src/lib/schemas/leadership.ts | 13 +- src/utilities/campaignRelationOptions.ts | 20 +- src/utilities/leadershipData.ts | 23 +- ...eadershipStateDeputyMembership.int.spec.ts | 177 ++++++++++++++ ...adershipStateDeputyMembership.unit.spec.ts | 26 ++ 15 files changed, 727 insertions(+), 35 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/liderancas/formActions.ts create mode 100644 src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx create mode 100644 src/lib/leadershipStateDeputyMembership.ts create mode 100644 src/lib/sameIdSet.ts create mode 100644 tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts create mode 100644 tests/unit/leadershipStateDeputyMembership.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 876120b8..5e84ace2 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md). - **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md). - **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md). - **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível. diff --git a/docs/plans/dobradinhas-lista-liderancas.md b/docs/plans/dobradinhas-lista-liderancas.md index 55249a36..7563eda9 100644 --- a/docs/plans/dobradinhas-lista-liderancas.md +++ b/docs/plans/dobradinhas-lista-liderancas.md @@ -1,7 +1,7 @@ # Coluna de dobradinhas na lista de lideranças -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue +Atualizado em: 2026-07-26 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B31**) Impeccable: B — encaixe em `/campanha/liderancas` (coluna + célula editável no `CampaignTable` existente); sem rota nova Appetite: ~1 dia eng (0,75–1,25); coluna + célula de chips com busca e gravação por delta + action nova + provider de opções; sem migration @@ -136,3 +136,48 @@ Depth check: reusa `CampaignTable`, `Popover`/`Command`/`Badge` do kit, `wordSta - [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (B27) · [`email-celular-lista-liderancas.md`](email-celular-lista-liderancas.md) (B28) · [`ordenacao-filtros-lista-liderancas.md`](ordenacao-filtros-lista-liderancas.md) (B29) · [`convite-whatsapp-lista-liderancas.md`](convite-whatsapp-lista-liderancas.md) (B30) · [`gerenciar-assessores.md`](gerenciar-assessores.md) (B19 ✓) - AGENTS.md — Campaign auth, naming, `overrideAccess: false`, escrita transacional, `Contact` join - `PRODUCT.md` / `DESIGN.md` — princípios 3, 4 e 8; Field Desk · `.cursor/rules/campanha-edit-where-you-see.mdc` + +## Revisão na entrega (2026-07-26) + +O plano acima executou como escrito, com as 5 defasagens já sinalizadas na auditoria pré-implementação corrigidas no código, não no texto deste plano (preservado como histórico): + +- **Ordem de colunas.** B28 ✓ já tinha inserido E-mail/Celular e a coluna Ações antes deste item chegar; "Dobradinhas" entrou entre Organizações e Acesso ao app como previsto, só a vizinhança mudou (Nome · E-mail · Celular · Status · Municípios · Organizações · **Dobradinhas** · Acesso ao app · Ações). +- **B27 já entregue.** A questão em aberto sobre "Popover ou inline" ficou resolvida por precedente direto, não por suposição: `LeadershipStateDeputiesCell` usa a mesma mecânica Popover+`Command`+chips de `MunicipalityListAdvisorsControl` (B27), mas grava por **server action chamada direto do cliente via `useTransition`** (padrão de `AdvisorMunicipalityCell`/B19), não pelo endpoint JSON que o B27 usou — a diferença existe porque esta célula tem derivados a revalidar (`leadershipCount`, a ficha do deputado) e o B27 não tinha. +- **`MAX_LEADERSHIP_STATE_DEPUTIES` exportado**, `loadStateDeputyOptions` estendido com `plainName`/`party`/`slug` (não só `slug`) para a célula montar o chip otimista sem 2ª busca, e a chamada duplicada de `loadStateDeputySummaries` em `loadLeadershipDetail` removida — `LeadershipDetailViewModel` deriva `stateDeputyIDs` de `row.stateDeputies`. +- **Extração de célula genérica continua adiada.** Este é o **2º call site** do padrão Popover+Command+chips (depois de B27); a extração para `shared/` segue esperando o 3º (candidatos: B34/B36/B37). +- Questões em aberto resolvidas: Popover (não inline) confirmado pelo precedente B27; chip só com nome na célula, partido no resultado da busca (A); sem filtro por dobradinha no header (gatilho no B29, ainda não entregue); sem "Ver mais…" (A). + +Gate verde (tsc, lint, format, knip — P3 pré-existente, check:cycles, unit+int, e2e smoke, build); Aikido 0 achados nos 9 arquivos novos/editados. + +### `/simplify` (2026-07-26) + +Três revisores paralelos (qualidade, performance, reuso) sobre o diff completo. Corrigidos: + +- **Bug de revert otimista.** `LeadershipStateDeputiesCell` desfazia a falha de um toggle voltando para `lastPropsRef.current` (o snapshot pré-edição inteiro), o que apagava um toggle anterior já salvo com sucesso na mesma sessão de popover. Agora desfaz só o delta que falhou (mesma garantia que `MunicipalityListAdvisorsControl`/B27 tem via `revertDelta`, sem herdar sua complexidade de `requestSeq`/`latestConfirmed` — a action daqui não devolve a lista "fonte da verdade" do servidor, então não há o que reconciliar). +- **Fallback silencioso na adição.** `toggle` construía o chip otimista com `option?.plainName ?? 'Dobradinha'` / `slug: option?.slug ?? ''` — nunca deveria disparar (a opção sempre vem de `filteredOptions`), mas escondia um bug e podia gerar um link `/campanha/dobradinhas/` quebrado. Agora exige a opção e sai sem efeito se ela não existir. +- **`sameIdSet` duplicado byte a byte** entre este arquivo e `MunicipalityListAdvisorsControl.tsx` (B27) — extraído para [`src/lib/sameIdSet.ts`](../../src/lib/sameIdSet.ts), importado pelos dois. +- **No-op ainda pagava uma leitura + revalidação.** `setLeadershipStateDeputyMembershipRecord` buscava o `slug` do deputado _antes_ de checar `nextStateDeputyIDs === null`, e o wrapper sempre chamava `revalidateLeadershipStateDeputyPaths` mesmo sem escrita. Agora o no-op retorna antes da busca do slug, e o wrapper só revalida quando algo foi de fato escrito. + +Adiados (achados reais, mas fora do apetite deste item — ver também o "2º call site" acima): + +- Unificar `nextStateDeputyIdsAfterMembership`/`nextAdvisorIdsAfterMembership` num helper genérico parametrizado — casa com a extração adiada para o 3º call site (B34/B36/B37), não antes. +- `StateDeputyOptionsProvider` como Context vs. props diretas: um revisor sugeriu trocar por uma columns factory; outro validou o Context como decisão correta (só um consumidor hoje, mesmo padrão de `MunicipalityEstimateScenarioProvider`). Mantido como está — não há um 2º catálogo consumindo o Context ainda para justificar a escolha de forma ou outra. +- Query duplicada de `stateDeputy` na página da lista (catálogo completo para o Popover + subconjunto por IDs em `toLeadershipRows`) — `toLeadershipRows` é compartilhado com rotas que não carregam o catálogo, então não pode simplesmente consumi-lo; ficaria como uma otimização condicional só para esta rota. + +Gate verde novamente após os ajustes (tsc, lint, format:check, unit 526/526, int 406/406 incl. os 6 do B31, build); Aikido 0 achados nos 4 arquivos tocados pelo `/simplify`. + +### `/simplify` — segunda rodada, pós-rebase (2026-07-26) + +Mesmos três revisores paralelos sobre o diff (já rebaseado em `origin/main`). Corrigidos: + +- **`StateDeputyOptionsProvider` revertido para prop direta.** A primeira rodada tinha decidido manter o Context; esta rodada trouxe a evidência concreta que faltava — `MunicipalityList.tsx` já resolve exatamente o mesmo problema ("colunas estáticas + uma coluna que precisa de opções por request") com uma **columns factory** (`municipalityListColumns(props)`, chamada dentro do componente, fechando sobre `advisorOptions`), sem Context. Adotar esse precedente em vez de divergir dele: `leadershipColumns` virou `leadershipColumns(stateDeputyOptions)`, `LeadershipStateDeputiesCell` recebe `options` como prop comum, e `StateDeputyOptionsProvider.tsx` foi deletado. Zero comportamento mudado, um arquivo e uma camada de Context a menos. +- **`revalidatePath` mais largo que o necessário.** `revalidateLeadershipStateDeputyPaths` chamava `revalidatePath('/campanha/liderancas/[id]', 'page')` — a forma de padrão dinâmico, que invalida a entrada de cache de **toda** ficha de liderança a cada toggle, não só a tocada — mesmo com o `leadershipId` já em escopo. Contrasta com o precedente já estabelecido (`revalidateAdvisorPaths`/`revalidateMunicipalityListPaths`), que sempre usa o id/slug concreto quando ele é conhecido. Corrigido para `revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')`. +- **Non-null assertion evitável.** `optionById.get(stateDeputyId)!` dentro do `setCurrent` updater dependia de uma guarda feita *fora* do updater (`if (assigned && !optionById.has(...)) return`) — correto hoje, mas as duas partes podiam se separar numa edição futura sem o TypeScript avisar. Movida a checagem para dentro do próprio updater (`if (!option) return previous`), sem asserção. + +Adiados (achados reais desta rodada, registrados para quem tocar este código de novo): + +- **Round-trip de DB só para resolver o `slug` do deputado a revalidar.** `setLeadershipStateDeputyMembershipRecord` faz um `findByID('stateDeputy', ..., select: { slug })` extra dentro da transação, e o cliente já tem esse slug (vem em `StateDeputyRelationOption`/`StateDeputySummary`). Passar o slug pelo `FormData`/schema evitaria a leitura, mas move a fonte de verdade do slug para o cliente só para fins de cache — a escrita em si continua validada pelo Payload (`overrideAccess: false` + FK); pior caso de um slug incorreto é invalidar a página errada, não uma falha de segurança. Ainda assim é uma mudança de superfície de confiança maior que o achado justifica isolado — fica para quando o 3º call site (B34/B36/B37) tocar esta mesma action. +- Query duplicada de `stateDeputy` (catálogo completo + subconjunto por IDs em `toLeadershipRows`) — mesmo adiado da primeira rodada, ainda válido. +- `optionById` recomputado uma vez por linha (célula) em vez de uma vez por tabela — `useMemo` já evita recomputar em cada render, só recomputa por linha no mount; moveria para o provider/hook compartilhado se algum dia importar na prática (catálogo de dobradinhas é pequeno). + +Gate verde novamente (tsc, lint --max-warnings=0, format:check, unit 530/530, int 406/406 incl. os 6 do B31, knip com o P3 pré-existente do `payload.config.ts`); Aikido 0 achados nos 3 arquivos editados. diff --git a/docs/roadmap.md b/docs/roadmap.md index c0ce0d74..cb02547f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -108,12 +108,12 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B28** e-mail + celular na lista de lideranças~~ — **entregue 2026-07-26** (em `/campanha/liderancas`: colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores — **B19 ✓** — clique copia (toast), "—" se vazio, celular com máscara BR, mais uma coluna de **Ações** com ícone WhatsApp (`wa.me`) por linha quando o número normaliza; `LeadershipRowViewModel.email` populado sem query nova e `loadLeadershipDetail` deduplicado; primitivo de copy extraído para `shared/CampaignCopyableCell.tsx` **já no 2º call site** — `AdvisorsTable` migrado no mesmo PR — e `whatsAppHrefForPhone` promovido a `lib/phone.ts`; `CampaignTable` permanece (sem clonar `AdvisorsTable`). **Fora:** toggle "Editar" / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita) · [plano](plans/email-celular-lista-liderancas.md) - **B30** convidar para completar cadastro por WhatsApp na lista de lideranças — pedido de produto (2026-07-25): ação ao fim de cada linha de `/campanha/liderancas` disparando o **mesmo** fluxo que já existe no detalhe (`/campanha/liderancas/[id]` → `LeadershipInviteButtons`, `kind: 'autopreenchimento'`): gera `campaignInvite` (token único, 7 dias) via `createCampaignInvite` e mostra a âncora `wa.me` com a mensagem pronta, num `Popover` compacto (two-step reveal — sem `window.open` automático, que quebra com popup blocker após o `await`). Botão **disabled** quando `contact.phone` não passa em `normalizeBrazilianPhone` (mesmo critério de `AdvisorsTable`/B19 ✓), com `aria-label` explicando o motivo. Distinto do WhatsApp de contato do **B28** (que é atalho de chat sem token) — ícone diferente para não ambiguar as duas ações na mesma linha; nenhum dos dois bloqueia o outro. Reusa 100% da action/collection/access existentes; sem migration, sem endpoint, sem mudança de access · ~0,25–0,5d · Janela 1 · Impeccable B · soft: B28 (coordenar ícone na mesma linha) · cortável · [plano](plans/convite-whatsapp-lista-liderancas.md) - **B29** ordenação e filtro no header da lista de lideranças — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) -- **B31** coluna de dobradinhas na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar a coluna **Dobradinhas** com as mesmas funcionalidades da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips clicáveis (→ `/campanha/dobradinhas/`), remover chip, buscar e adicionar por typeahead, gravação automática otimista. O dado já chega e é descartado (`LeadershipRowViewModel.stateDeputyNames`, como `sector`/`phone` antes de B28/B29), e o **E4R ✓** semeou dobradinha só em `municipality.stateDeputies` — as fichas nasceram vazias e hoje só se preenchem abrindo uma a uma o `RelationMultiSelect` do detalhe e clicando "Salvar", exatamente o trabalho de mesa do onboarding. **Paridade é da coluna, não do modo:** o toggle "Editar" de tabela inteira dos assessores existe lá porque todas as colunas daquela tela são editáveis; trazê-lo para cá é o anti-goal literal do princípio 3 do `PRODUCT.md` (spreadsheet / inputs montados em toda linha) já travado no **B28** — a edição fica por célula (Popover, contrato de B9 ✓/B24/B26/B27; typeahead inline é troca de container no craft, não de contrato). Escrita **por delta** (`leadershipId`+`stateDeputyId`+`assigned`) em transação com lock `leadership-state-deputies:{id}` e teto de 20 — **não** o replace de array do `updateLeadershipInternal` (sob auto-save, dois atores na mesma ficha se sobrescrevem); ao contrário do B24/B27, aqui vale `revalidatePath` (25 linhas, e `/campanha/dobradinhas` tem `leadershipCount` derivado + a ficha do deputado lista lideranças). Catálogo entregue **uma vez por tabela** via provider cliente (props por célula multiplicariam o payload por linha). Access inalterado (`canManageLeadership` escreve, `canReadStateDeputy` lê); sem migration/collection/Consent/mudança de URL · ~1d · Janela 1–2 · Impeccable B · soft: B27 (mesma peça — chips + combobox + delta; extração para `shared/` só no 3º call site), B19 ✓ (padrão e espelho do cálculo), B28/B29/B30 (mesma tabela), B17 (a tabela chega a 8–10 colunas), E4R ✓ · cortável · [plano](plans/dobradinhas-lista-liderancas.md) -- **B32** editar Status de apoio na célula da lista de lideranças — pedido de produto (2026-07-25): clicar na célula "Status" de `/campanha/liderancas` abre um Popover com `NativeSelect` de `supportStatus` (as 4 opções, mesmo Badge como trigger), gravando sozinho — **sem** botão "Salvar" e **sem** depender do toggle "Editar" de tabela inteira (`AdvisorsTable`/**B19 ✓**), anti-goal nomeado explicitamente no pedido e já travado no **B28**/**B31** para esta mesma tabela. O campo já é editável na ficha (`LeadershipInternalForm`) e o schema já trata `supportStatus` como opcional isolado (`leadershipInternalUpdateSchema`) — falta só o atalho pontual. Gravação por **endpoint JSON dedicado** (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), reusando `updateLeadershipInternal` (`{ id, supportStatus }`, `overrideAccess: false`), **sem `revalidatePath`** (25 linhas, e um futuro sort/filtro por Status do B29 só reconcilia na próxima navegação, mesma escolha do B24). Fecha o "Adiado com gatilho" que o próprio **B29** já havia registrado para exatamente este caso. É o **2º endpoint JSON** da campanha — extrai `isSameOriginRequest` para `src/utilities/` (compartilhado com o futuro `expected-votes`/B24/B27) e as mensagens seguras do form action de `/liderancas/[id]` para um módulo dedicado. Sem migration/collection/Consent · ~0,4–0,5d · Janela 1 · Impeccable B · soft: B24 (mesma família de decisão — guard CSRF), B29 (gatilho fechado), B31 (mesma tabela, célula vizinha) · cortável · [plano](plans/autosave-status-lista-liderancas.md) +- ~~**B31** coluna de dobradinhas na lista de lideranças~~ — **entregue 2026-07-26** (coluna **Dobradinhas** entre Organizações e Acesso ao app: chips clicáveis → `/campanha/dobradinhas/`, edição por Popover+`Command` — mesma mecânica do **B27 ✓**, com escrita por **server action chamada direto do cliente via `useTransition`**, padrão de `AdvisorMunicipalityCell`/**B19 ✓**, não o endpoint JSON do B27, porque aqui há derivados a revalidar; `setLeadershipStateDeputyMembership(Record)` em transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/` (id concreto — `/simplify` rodada 2 trocou o template dinâmico), `/campanha/dobradinhas` e a ficha do deputado tocado; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo (`loadStateDeputyOptions`, agora com `plainName`/`party`/`slug`) entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns` — `/simplify` rodada 2 substituiu o Context inicial); `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; `loadLeadershipDetail` deduplicado. É o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º) · [plano](plans/dobradinhas-lista-liderancas.md) +- **B32** editar Status de apoio na célula da lista de lideranças — pedido de produto (2026-07-25): clicar na célula "Status" de `/campanha/liderancas` abre um Popover com `NativeSelect` de `supportStatus` (as 4 opções, mesmo Badge como trigger), gravando sozinho — **sem** botão "Salvar" e **sem** depender do toggle "Editar" de tabela inteira (`AdvisorsTable`/**B19 ✓**), anti-goal nomeado explicitamente no pedido e já travado no **B28**/**B31 ✓** para esta mesma tabela. O campo já é editável na ficha (`LeadershipInternalForm`) e o schema já trata `supportStatus` como opcional isolado (`leadershipInternalUpdateSchema`) — falta só o atalho pontual. Gravação por **endpoint JSON dedicado** (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), reusando `updateLeadershipInternal` (`{ id, supportStatus }`, `overrideAccess: false`), **sem `revalidatePath`** (25 linhas, e um futuro sort/filtro por Status do B29 só reconcilia na próxima navegação, mesma escolha do B24). Fecha o "Adiado com gatilho" que o próprio **B29** já havia registrado para exatamente este caso. É o **2º endpoint JSON** da campanha — extrai `isSameOriginRequest` para `src/utilities/` (compartilhado com o futuro `expected-votes`/B24/B27) e as mensagens seguras do form action de `/liderancas/[id]` para um módulo dedicado. Sem migration/collection/Consent · ~0,4–0,5d · Janela 1 · Impeccable B · soft: B24 (mesma família de decisão — guard CSRF), B29 (gatilho fechado), B31 ✓ (mesma tabela, célula vizinha) · cortável · [plano](plans/autosave-status-lista-liderancas.md) - ~~**B33** ordenação e filtro no header da lista de dobradinhas~~ — **entregue 2026-07-26** (`?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** sem extrair de novo; `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` espelham `municipalityListUrl.ts`/`territoryListUrl.ts`; paridade mobile — `StateDeputyFilters` — entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test, não assumida; `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado) · [plano](plans/ordenacao-filtros-lista-dobradinhas.md) -- **B34** chips editáveis de municípios na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas`, a coluna "Municípios" (hoje `row.municipalityNames.join(', ')`, texto morto sem link) ganha **chips**: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon`) aparece quando a liderança cobre o TI inteiro, e um ícone de edição ao fim da célula abre um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` com busca acento-insensível por município/território/ZE — adicionar/remover grava **na hora**, sem "Salvar", incluindo lote de território/ZE inteiro (paridade explicitamente pedida com `/campanha/assessores`, **B19 ✓**). **⚠️ Conflito registrado com o pedido literal:** o pedido pede um **botão de "modo de edição"** de tabela inteira, espelhando o toggle de `AdvisorsTable` — mas essa **mesma tabela** já travou a decisão oposta três vezes no mesmo dia (**B28**, **B31**, **B32**: "paridade é da coluna, não do modo... toggle Editar de tabela inteira é o anti-goal literal do princípio 3"). Este plano segue o precedente (Popover por célula, não toggle) e marca a decisão como _proposta — validar com produto_ antes de implementar. Diferença estrutural do B19: o vínculo mora no próprio `leadership.municipalities` (`hasMany`, piso de 1 via hook `requireAtLeastOneMunicipality`, teto de 30), não numa reverse relationship que pode ir a zero — mutação por **delta** direto no documento da liderança (não replace via `updateLeadershipInternalRecord`, que validaria o array inteiro contra o escopo do assessor e quebraria lideranças cross-boundary), lock único `leadership-municipalities:{id}` (ao contrário do B19, que trava por município), piso de 1 desabilitado no cliente + recusado no servidor como defesa em profundidade, sugestões de busca restritas ao escopo do assessor enquanto os chips já atribuídos usam o catálogo completo. Promove `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros (`municipalityPortfolio.ts`/`loadMunicipalityPortfolioIndex`) **sem** portar a UI de `AdvisorMunicipalityCell` (o `ResizeObserver` resolve um problema — empacotar chips numa célula sempre-visível — que não existe dentro de um Popover); catálogo entregue uma vez por tabela via provider cliente. `revalidatePath` (não endpoint JSON como o B27): a lista tem 25 linhas, não 435, e há derivados a jusante (`/campanha/liderancas/[id]`, e `/campanha/municipios/[slug]` via `MunicipalityLeadershipsPanel`/dossiê **E16 ✓**). Sem migration/collection/Consent/mudança de URL · ~1–1,25d · Janela 1–2 · Impeccable B · soft: B19 ✓ (lib de origem), B27 (mesma peça de célula), B31 (mesma tabela e mesma decisão de container — extração de editor de chips genérico só no 3º call site), B28/B29/B30/B32 (mesma tabela), B17 (tabela cresce), E16 ✓ (painel do município a revalidar) · cortável · [plano](plans/chips-municipios-lista-liderancas.md) -- **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e um ícone de edição na célula abrindo um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` (busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". **⚠️ Mesmo conflito com o pedido literal já registrado quatro vezes no mesmo dia** (B28/B31/B32/B34): o pedido pede o toggle "Editar" de tabela inteira de `AdvisorsTable`; este plano segue o precedente e fica com o Popover por célula, marcado como _proposta — validar com produto_. Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. Promove a mesma lib pura do B19/B34 (`advisorMunicipalityPortfolio.ts` → `municipalityPortfolio.ts`) — quem entre B34 e este chegar primeiro faz o `git mv`, o outro só importa; os três (B31+B34+este) juntos são o gatilho já registrado para extrair um `RelationshipChipEditor` agnóstico. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) -- **B36** coluna de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores) — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Lideranças" (hoje `row.leadershipCount`, uma contagem sem nomes) ganha as **mesmas funcionalidades e UI** da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips nomeados linkando para `/campanha/liderancas/[id]`, adicionar/remover na própria linha via Popover + busca (`Command`) sem botão "Salvar", gravação **por delta** com estado otimista, e clamp de 3 linhas + "Ver mais…" (`ResizeObserver`, portado do `AdvisorMunicipalityCell`) — necessário aqui porque, ao contrário do **B31** (a mesma relação vista do lado da liderança, onde poucos chips bastam), um deputado popular pode acumular dezenas de lideranças engajadas. É **a mesma aresta `leadership.stateDeputies`** que o B31 edita, só do lado oposto: o pedido explícito de **não duplicar lógica** é resolvido reusando **sem alteração** a mutação/schema/lock que o B31 desenha (`setLeadershipStateDeputyMembership`, teto `MAX_LEADERSHIP_STATE_DEPUTIES`=20, lock `leadership-state-deputies:{id}`) e promovendo a célula de chips do B31 para um único componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`, `direction: 'fromStateDeputy' | 'fromLeadership'`) em vez de um segundo componente-irmão fazendo a mesma coisa. Catálogo de busca novo (`loadLeadershipOptions`, espelha `loadStateDeputyOptions`) via `overrideAccess: false` (escopo do assessor já resolvido pelo access de `leadership`); `stateDeputyData.ts` troca `leadershipCount` por `leaderships: {id,name}[]` na mesma query batch já existente (mais `select`, não uma segunda consulta). Sem migration/collection/Consent/mudança de URL · ~0,5–0,75d se o B31 já tiver aterrissado (só a direção nova + o loader), ~1–1,5d se **B36** entrar primeiro (paga a extração da célula/action compartilhadas) · Janela 1–2 · Impeccable B · soft (corrida de extração): **B31** — quem entrar primeiro paga a peça compartilhada, o outro consome; B19 ✓ (padrão de origem), E4R ✓ (fichas vazias dos dois lados), B33 (mesma tabela) · cortável · [plano](plans/chips-liderancas-lista-dobradinhas.md) +- **B34** chips editáveis de municípios na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas`, a coluna "Municípios" (hoje `row.municipalityNames.join(', ')`, texto morto sem link) ganha **chips**: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon`) aparece quando a liderança cobre o TI inteiro, e um ícone de edição ao fim da célula abre um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` com busca acento-insensível por município/território/ZE — adicionar/remover grava **na hora**, sem "Salvar", incluindo lote de território/ZE inteiro (paridade explicitamente pedida com `/campanha/assessores`, **B19 ✓**). **⚠️ Conflito registrado com o pedido literal:** o pedido pede um **botão de "modo de edição"** de tabela inteira, espelhando o toggle de `AdvisorsTable` — mas essa **mesma tabela** já travou a decisão oposta três vezes no mesmo dia (**B28**, **B31 ✓**, **B32**: "paridade é da coluna, não do modo... toggle Editar de tabela inteira é o anti-goal literal do princípio 3"). Este plano segue o precedente (Popover por célula, não toggle) e marca a decisão como _proposta — validar com produto_ antes de implementar. Diferença estrutural do B19: o vínculo mora no próprio `leadership.municipalities` (`hasMany`, piso de 1 via hook `requireAtLeastOneMunicipality`, teto de 30), não numa reverse relationship que pode ir a zero — mutação por **delta** direto no documento da liderança (não replace via `updateLeadershipInternalRecord`, que validaria o array inteiro contra o escopo do assessor e quebraria lideranças cross-boundary), lock único `leadership-municipalities:{id}` (ao contrário do B19, que trava por município), piso de 1 desabilitado no cliente + recusado no servidor como defesa em profundidade, sugestões de busca restritas ao escopo do assessor enquanto os chips já atribuídos usam o catálogo completo. Promove `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros (`municipalityPortfolio.ts`/`loadMunicipalityPortfolioIndex`) **sem** portar a UI de `AdvisorMunicipalityCell` (o `ResizeObserver` resolve um problema — empacotar chips numa célula sempre-visível — que não existe dentro de um Popover); catálogo entregue uma vez por tabela via provider cliente. `revalidatePath` (não endpoint JSON como o B27): a lista tem 25 linhas, não 435, e há derivados a jusante (`/campanha/liderancas/[id]`, e `/campanha/municipios/[slug]` via `MunicipalityLeadershipsPanel`/dossiê **E16 ✓**). Sem migration/collection/Consent/mudança de URL · ~1–1,25d · Janela 1–2 · Impeccable B · soft: B19 ✓ (lib de origem), B27 ✓ (mesma peça de célula), B31 ✓ (mesma tabela e mesma decisão de container — extração de editor de chips genérico só no 3º call site), B28/B29/B30/B32 (mesma tabela), B17 (tabela cresce), E16 ✓ (painel do município a revalidar) · cortável · [plano](plans/chips-municipios-lista-liderancas.md) +- **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e um ícone de edição na célula abrindo um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` (busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". **⚠️ Mesmo conflito com o pedido literal já registrado quatro vezes no mesmo dia** (B28/B31 ✓/B32/B34): o pedido pede o toggle "Editar" de tabela inteira de `AdvisorsTable`; este plano segue o precedente e fica com o Popover por célula, marcado como _proposta — validar com produto_. Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. Promove a mesma lib pura do B19/B34 (`advisorMunicipalityPortfolio.ts` → `municipalityPortfolio.ts`) — quem entre B34 e este chegar primeiro faz o `git mv`, o outro só importa; os três (B31 ✓+B34+este) juntos são o gatilho já registrado para extrair um `RelationshipChipEditor` agnóstico — B31 ✓ entregou o 2º call site do padrão sem extrair, então este seria o 3º. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 ✓ (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 ✓ (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) +- **B36** coluna de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores) — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Lideranças" (hoje `row.leadershipCount`, uma contagem sem nomes) ganha as **mesmas funcionalidades e UI** da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips nomeados linkando para `/campanha/liderancas/[id]`, adicionar/remover na própria linha via Popover + busca (`Command`) sem botão "Salvar", gravação **por delta** com estado otimista, e clamp de 3 linhas + "Ver mais…" (`ResizeObserver`, portado do `AdvisorMunicipalityCell`) — necessário aqui porque, ao contrário do **B31 ✓** (a mesma relação vista do lado da liderança, onde poucos chips bastam), um deputado popular pode acumular dezenas de lideranças engajadas. É **a mesma aresta `leadership.stateDeputies`** que o B31 ✓ já edita, só do lado oposto: o pedido explícito de **não duplicar lógica** é resolvido reusando **sem alteração** a mutação/schema/lock que o B31 ✓ entregou (`setLeadershipStateDeputyMembership(Record)`, teto `MAX_LEADERSHIP_STATE_DEPUTIES`=20, lock `leadership-state-deputies:{id}`, helper puro `nextStateDeputyIdsAfterMembership`) — **mas B31 não extraiu** um componente compartilhado bidirecional (decisão travada no plano: extração só no 3º call site do padrão Popover+Command+chips), então este item ainda paga a extração de `LeadershipStateDeputyRelationCell` (`direction: 'fromStateDeputy' | 'fromLeadership'`) a partir de `LeadershipStateDeputiesCell`, não a herda de graça. Catálogo de busca novo (`loadLeadershipOptions`, espelha `loadStateDeputyOptions`) via `overrideAccess: false` (escopo do assessor já resolvido pelo access de `leadership`); `stateDeputyData.ts` troca `leadershipCount` por `leaderships: {id,name}[]` na mesma query batch já existente (mais `select`, não uma segunda consulta). Sem migration/collection/Consent/mudança de URL · ~0,75–1d (mutação/schema já prontos; só direção nova + loader + extração da célula compartilhada) · Janela 1–2 · Impeccable B · soft: B31 ✓ (mutação/schema/lock reusados sem alteração), B19 ✓ (padrão de origem), E4R ✓ (fichas vazias dos dois lados), B33 ✓ (mesma tabela) · cortável · [plano](plans/chips-liderancas-lista-dobradinhas.md) - **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; **Pass 2 W1 deixou trivial:** colunas já são dado (`CampaignTableColumn.id`/`mandatory`/`defaultVisible`); soft: B15 ✓ (ids) / B16 ✓ (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md) - **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); **Pass 2 W1 deixou trivial:** serialização canônica pronta e congelada em `municipalityListUrl.ts`; acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md) - ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md) @@ -176,7 +176,7 @@ flowchart TD B27n["B27 ✓ Combobox + chips
no popover de Assessores"] B28n["B28 ✓ E-mail + celular
na lista de lideranças"] B30n["B30 Convite WhatsApp
na lista de lideranças"] - B31n["B31 Coluna de dobradinhas
na lista de lideranças"] + B31n["B31 ✓ Coluna de dobradinhas
na lista de lideranças"] B32n["B32 Editar Status de apoio
na célula da lista"] B33n["B33 ✓ Sort + filtro no header
da lista de dobradinhas"] B34n["B34 Chips editáveis de município
na lista de lideranças"] diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts new file mode 100644 index 00000000..cb68b7fd --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -0,0 +1,33 @@ +'use server' + +import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership' +import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +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.`, +] as const + +/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */ +export const setLeadershipStateDeputyMembershipFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const assignedRaw = requiredFormText(formData, 'assigned') + await setLeadershipStateDeputyMembership({ + leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'), + stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'), + assigned: assignedRaw === 'true', + }) + return { message: 'Dobradinhas atualizadas.' } + }, + safeMessages, + genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 1301e85c..b4036dd0 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' +import { LeadershipStateDeputiesCell } from '@/components/campaign/leadership/LeadershipStateDeputiesCell' import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge' import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' @@ -24,6 +25,10 @@ import { Button } from '@/components/ui/button' import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' +import { + loadStateDeputyOptions, + type StateDeputyRelationOption, +} from '@/utilities/campaignRelationOptions' import { buildLeadershipListHref, loadLeadershipListPageData, @@ -31,11 +36,15 @@ import { type LeadershipRowViewModel, } from '@/utilities/leadershipData' +import { setLeadershipStateDeputyMembershipFormAction } from './formActions' + type LeadershipsPageProps = { searchParams: Promise> } -const leadershipColumns: Array> = [ +const leadershipColumns = ( + stateDeputyOptions: StateDeputyRelationOption[], +): Array> => [ { id: 'name', mandatory: true, @@ -84,6 +93,19 @@ const leadershipColumns: Array> = [ cellClassName: 'max-w-56 whitespace-normal text-muted-foreground', cell: (row) => row.organizationNames.join(', ') || '—', }, + { + id: 'stateDeputies', + head: Dobradinhas, + cellClassName: 'max-w-56 whitespace-normal', + cell: (row) => ( + + ), + }, { id: 'appAccess', head: Acesso ao app, @@ -137,7 +159,11 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP if (!isCampaignStaff(user)) redirect('/campanha') const state = parseLeadershipListParams(rawSearchParams) - const { rows, totalDocs, totalPages } = await loadLeadershipListPageData(payload, user, state) + const [{ rows, totalDocs, totalPages }, stateDeputyOptions] = await Promise.all([ + loadLeadershipListPageData(payload, user, state), + loadStateDeputyOptions(payload, user), + ]) + const columns = leadershipColumns(stateDeputyOptions) return ( @@ -167,7 +193,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP {rows.length ? ( <> - row.id} /> + row.id} /> { + revalidatePath('/campanha/liderancas', 'page') + revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page') + revalidatePath('/campanha/dobradinhas', 'page') + if (stateDeputySlug) { + revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page') + } +} + +/** + * Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas` + * (B31) — the other side of the same `leadership.stateDeputies` relation that + * `updateLeadershipInternalRecord` replaces wholesale from the ficha form. + * Under auto-save-per-chip a replace would let two actors on the same ficha + * clobber each other, so this locks per leadership and writes only the delta. + */ +export const setLeadershipStateDeputyMembershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipStateDeputyMembershipInput, +) => { + const { leadershipId, stateDeputyId, assigned } = + leadershipStateDeputyMembershipSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await getFreshStaffActor(payload, actor, req) + + await acquireTextAdvisoryLocks(payload, req, [`leadership-state-deputies:${leadershipId}`]) + + // Row access verifies the leadership is in the actor's scope (same + // guard `updateLeadershipInternalRecord` relies on). + const current = await payload.findByID({ + collection: 'leadership', + id: leadershipId, + depth: 0, + select: { stateDeputies: true }, + user: currentActor, + overrideAccess: false, + req, + }) + + const currentStateDeputyIDs = uniqueRelationshipIds(current.stateDeputies) + const nextStateDeputyIDs = nextStateDeputyIdsAfterMembership( + currentStateDeputyIDs, + stateDeputyId, + assigned, + ) + + // No-op: nothing to write, and nothing for the caller to revalidate — + // skip the slug lookup below, which exists only to target that revalidate. + if (nextStateDeputyIDs === null) { + return { leadership: current, stateDeputySlug: undefined } + } + + // Intentional admin bypass: only used to resolve the slug of the + // touched deputy for a targeted revalidate; existence is otherwise + // enforced by Payload's relationship validation on `update`. + const stateDeputySlug = ( + await payload.findByID({ + collection: 'stateDeputy', + id: stateDeputyId, + depth: 0, + select: { slug: true }, + overrideAccess: true, + req, + }) + ).slug + + const updated = await payload.update({ + collection: 'leadership', + id: leadershipId, + data: { stateDeputies: nextStateDeputyIDs }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + return { leadership: updated, stateDeputySlug } + }, + { beginFailureMessage: 'Não foi possível atualizar as dobradinhas.' }, + ) +} + +export const setLeadershipStateDeputyMembership = async ( + input: LeadershipStateDeputyMembershipInput, +) => { + const { payload, actor } = await getCampaignActionContext() + const { leadership, stateDeputySlug } = await setLeadershipStateDeputyMembershipRecord( + payload, + actor, + input, + ) + // No-op writes nothing, so there is nothing to revalidate. + if (stateDeputySlug) revalidateLeadershipStateDeputyPaths(input.leadershipId, stateDeputySlug) + return leadership +} diff --git a/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx b/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx new file mode 100644 index 00000000..d27479b3 --- /dev/null +++ b/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx @@ -0,0 +1,222 @@ +'use client' + +import { PencilIcon, XIcon } from 'lucide-react' +import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { toast } from 'sonner' + +import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips' +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 type { CampaignFormActionState } from '@/utilities/campaignFormActionError' +import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions' +import type { StateDeputySummary } from '@/utilities/stateDeputyData' + +const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as dobradinhas. Tente novamente.' + +type LeadershipStateDeputiesCellProps = { + leadershipId: number + stateDeputies: StateDeputySummary[] + options: StateDeputyRelationOption[] + membershipAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +/** + * "Dobradinhas" column cell (B31) — read chips reuse `StateDeputyChips` + * (same look as the leadership ficha / município strategy card); a separate + * pencil affordance opens the Popover+Command editor, mirroring the + * mechanics of `MunicipalityListAdvisorsControl` (B27) but writing through a + * server action + `useTransition` like `AdvisorMunicipalityCell` (B19) + * instead of a JSON endpoint. The catalog arrives as a prop, computed once + * by the page's column factory (mirrors `municipalityListColumns`) instead + * of a table-scoped context — one static catalog, one place that builds it. + */ +export const LeadershipStateDeputiesCell = ({ + leadershipId, + stateDeputies, + options, + membershipAction, +}: LeadershipStateDeputiesCellProps) => { + const [open, setOpen] = useState(false) + const [current, setCurrent] = useState(stateDeputies) + const [query, setQuery] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [isPending, startTransition] = useTransition() + const lastPropsRef = useRef(stateDeputies) + + // 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 = stateDeputies.map((deputy) => deputy.id) + if ( + sameIdSet( + propIds, + lastPropsRef.current.map((deputy) => deputy.id), + ) + ) + return + lastPropsRef.current = stateDeputies + setCurrent(stateDeputies) + }, [stateDeputies]) + + const currentIds = useMemo(() => new Set(current.map((deputy) => deputy.id)), [current]) + const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options]) + + const filteredOptions = useMemo( + () => + options.filter( + (option) => !currentIds.has(option.id) && matchesAtWordStart(option.name, query), + ), + [options, currentIds, query], + ) + + const toggle = (stateDeputyId: 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 a deputy that has + // since dropped out of `options` — same asymmetry as the `optionById` + // lookup below, which only needs to resolve *additions*. + const removedSummary = assigned + ? undefined + : current.find((deputy) => deputy.id === stateDeputyId) + if (assigned && !optionById.has(stateDeputyId)) return + + setErrorMessage(null) + setCurrent((previous) => { + if (assigned) { + if (previous.some((deputy) => deputy.id === stateDeputyId)) return previous + const option = optionById.get(stateDeputyId) + if (!option) return previous + return [ + ...previous, + { id: stateDeputyId, name: option.plainName, slug: option.slug, party: option.party }, + ] + } + return previous.filter((deputy) => deputy.id !== stateDeputyId) + }) + setQuery('') + + const formData = new FormData() + formData.set('leadershipId', String(leadershipId)) + formData.set('stateDeputyId', String(stateDeputyId)) + formData.set('assigned', assigned ? 'true' : 'false') + + 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((deputy) => deputy.id !== stateDeputyId) + : removedSummary + ? [...previous, removedSummary] + : previous, + ) + const message = result.message ?? SAVE_ERROR_MESSAGE + setErrorMessage(message) + toast.error(message) + }) + } + + const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando dobradinhas.' : '' + + return ( +
+ {current.length > 0 ? ( + + ) : ( + — + )} + + + + + +
+

Atribuir dobradinhas

+

+ Deputados estaduais em dobradinha com esta liderança. +

+ {isPending ? ( + + ) : null} +
+ {current.length > 0 ? ( +
+ {current.map((deputy) => ( + + ))} +
+ ) : null} + + + + {filteredOptions.length === 0 ? ( +

+ Nenhum resultado. +

+ ) : ( + filteredOptions.map((option) => ( + toggle(option.id, true)} + > + {option.name} + + )) + )} +
+
+

+ {statusMessage} +

+
+
+
+ ) +} diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx index 2f65cd0a..b801bd15 100644 --- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx @@ -15,6 +15,7 @@ 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 type { @@ -25,12 +26,6 @@ import type { const ADVISORS_ENDPOINT = '/campanha/municipios/advisors' const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.' -const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => { - if (left.length !== right.length) return false - const rightSet = new Set(right) - return left.every((id) => rightSet.has(id)) -} - type MunicipalityListAdvisorsControlProps = { municipalityID: number currentAdvisorIDs: number[] diff --git a/src/lib/leadershipStateDeputyMembership.ts b/src/lib/leadershipStateDeputyMembership.ts new file mode 100644 index 00000000..d71e4de0 --- /dev/null +++ b/src/lib/leadershipStateDeputyMembership.ts @@ -0,0 +1,28 @@ +import { 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`). + */ +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) +} diff --git a/src/lib/sameIdSet.ts b/src/lib/sameIdSet.ts new file mode 100644 index 00000000..b7e88888 --- /dev/null +++ b/src/lib/sameIdSet.ts @@ -0,0 +1,11 @@ +/** + * Order-independent equality for two id lists — the guard every list-cell + * optimistic-state hook uses to tell a genuine external prop change + * (navigation/RSC refresh) apart from a re-render carrying the same + * pre-edit content (which must not clobber in-flight optimistic state). + */ +export const sameIdSet = (left: readonly number[], right: readonly number[]): boolean => { + if (left.length !== right.length) return false + const rightSet = new Set(right) + return left.every((id) => rightSet.has(id)) +} diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index ce644ea0..e37de8e8 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -37,7 +37,7 @@ export const isSupportStatus = (value: unknown): value is SupportStatus => export const MAX_LEADERSHIP_MUNICIPALITIES = 30 const MAX_LEADERSHIP_ORGANIZATIONS = 20 -const MAX_LEADERSHIP_STATE_DEPUTIES = 20 +export const MAX_LEADERSHIP_STATE_DEPUTIES = 20 const municipalitiesArraySchema = z .array(positiveRelationshipId) @@ -83,3 +83,14 @@ export const leadershipInternalUpdateSchema = z.object({ }) export type LeadershipInternalUpdateInput = z.input + +/** Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas` (B31). */ +export const leadershipStateDeputyMembershipSchema = z.object({ + leadershipId: positiveRelationshipId, + stateDeputyId: positiveRelationshipId, + assigned: z.boolean(), +}) + +export type LeadershipStateDeputyMembershipInput = z.input< + typeof leadershipStateDeputyMembershipSchema +> diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts index bd643082..dff474b2 100644 --- a/src/utilities/campaignRelationOptions.ts +++ b/src/utilities/campaignRelationOptions.ts @@ -69,17 +69,30 @@ export const loadActivityOptions = async ( }) } +/** + * `name` folds in the party for select/typeahead disambiguation (e.g. + * `RelationMultiSelect`); `plainName`/`party`/`slug` ride along so cell-level + * consumers (B31's "Dobradinhas" column) can build a `StateDeputySummary` + * chip for an optimistic add and link to `/campanha/dobradinhas/` + * without a second catalog fetch. + */ +export type StateDeputyRelationOption = RelationOption & { + plainName: string + party: string | null + slug: string +} + export const loadStateDeputyOptions = async ( payload: Payload, user: CampaignUser, -): Promise => { +): Promise => { const result = await payload.find({ collection: 'stateDeputy', depth: 0, limit: 0, pagination: false, sort: 'name', - select: { name: true, party: true }, + select: { name: true, party: true, slug: true }, where: {}, user, overrideAccess: false, @@ -87,5 +100,8 @@ export const loadStateDeputyOptions = async ( return result.docs.map((stateDeputy) => ({ id: stateDeputy.id, name: stateDeputy.party ? `${stateDeputy.name} (${stateDeputy.party})` : stateDeputy.name, + plainName: stateDeputy.name, + party: stateDeputy.party ?? null, + slug: stateDeputy.slug, })) } diff --git a/src/utilities/leadershipData.ts b/src/utilities/leadershipData.ts index 6bd46dae..3472b885 100644 --- a/src/utilities/leadershipData.ts +++ b/src/utilities/leadershipData.ts @@ -28,7 +28,7 @@ export type LeadershipRowViewModel = { sector: string | null municipalityNames: string[] organizationNames: string[] - stateDeputyNames: string[] + stateDeputies: StateDeputySummary[] hasAppAccess: boolean /** Last write to the leadership row — the dossier's freshness readout. */ updatedAt: string @@ -50,7 +50,7 @@ const contactSummary = ( const namesForIds = async ( payload: Payload, - collection: 'municipality' | 'organization' | 'stateDeputy', + collection: 'municipality' | 'organization', ids: number[], ): Promise> => { if (ids.length === 0) return new Map() @@ -90,11 +90,12 @@ const toLeadershipRows = async ( } } - const [municipalityNames, organizationNames, stateDeputyNames] = await Promise.all([ + const [municipalityNames, organizationNames, stateDeputySummaries] = await Promise.all([ namesForIds(payload, 'municipality', [...municipalityIDs]), namesForIds(payload, 'organization', [...organizationIDs]), - namesForIds(payload, 'stateDeputy', [...stateDeputyIDs]), + loadStateDeputySummaries(payload, [...stateDeputyIDs]), ]) + const stateDeputyById = new Map(stateDeputySummaries.map((summary) => [summary.id, summary])) return docs.map((doc) => { const contact = contactSummary(doc.contact) @@ -114,10 +115,11 @@ const toLeadershipRows = async ( .map(relationshipId) .filter((id): id is number => id !== null) .map((id) => organizationNames.get(id) ?? 'Organização'), - stateDeputyNames: (doc.stateDeputies ?? []) + stateDeputies: (doc.stateDeputies ?? []) .map(relationshipId) .filter((id): id is number => id !== null) - .map((id) => stateDeputyNames.get(id) ?? 'Dobradinha'), + .map((id) => stateDeputyById.get(id)) + .filter((summary): summary is StateDeputySummary => summary !== undefined), hasAppAccess: relationshipId(doc.user) !== null, updatedAt: doc.updatedAt, } @@ -239,7 +241,6 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & { municipalityIDs: number[] organizationIDs: number[] stateDeputyIDs: number[] - stateDeputies: StateDeputySummary[] sectorNotes: string | null notes: string | null consentNote: string | null @@ -265,11 +266,6 @@ export const loadLeadershipDetail = async ( const [row] = await toLeadershipRows(payload, [doc]) if (!row) return null - const stateDeputyIDs = (doc.stateDeputies ?? []) - .map(relationshipId) - .filter((id): id is number => id !== null) - const stateDeputies = await loadStateDeputySummaries(payload, stateDeputyIDs) - return { ...row, municipalityIDs: (doc.municipalities ?? []) @@ -278,8 +274,7 @@ export const loadLeadershipDetail = async ( organizationIDs: (doc.organizations ?? []) .map(relationshipId) .filter((id): id is number => id !== null), - stateDeputyIDs, - stateDeputies, + stateDeputyIDs: row.stateDeputies.map((summary) => summary.id), sectorNotes: doc.sectorNotes ?? null, notes: doc.notes ?? null, consentNote: doc.consentNote ?? null, diff --git a/tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts b/tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts new file mode 100644 index 00000000..3ac690d8 --- /dev/null +++ b/tests/int/campaignLeadershipStateDeputyMembership.int.spec.ts @@ -0,0 +1,177 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import { setLeadershipStateDeputyMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership' +import config from '@/payload.config' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +const stateDeputyIds = (leadership: { + stateDeputies?: (number | { id: number })[] | null +}): number[] => + (leadership.stateDeputies ?? []).map((entry) => (typeof entry === 'number' ? entry : entry.id)) + +describe('setLeadershipStateDeputyMembershipRecord (B31)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it.each(['coordinator', 'candidate'] as const)( + 'lets a %s assign and remove a state deputy by delta', + async (role) => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser(role) + const municipality = await fixtures.getMunicipality() + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [municipality], + }) + const stateDeputy = await fixtures.createStateDeputy() + + const { leadership: assigned, stateDeputySlug } = + await setLeadershipStateDeputyMembershipRecord(payload, actor, { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }) + expect(stateDeputyIds(assigned)).toContain(stateDeputy.id) + expect(stateDeputySlug).toBe(stateDeputy.slug) + + const { leadership: removed } = await setLeadershipStateDeputyMembershipRecord( + payload, + actor, + { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: false, + }, + ) + expect(stateDeputyIds(removed)).not.toContain(stateDeputy.id) + }, + ) + + it('lets an advisor manage a leadership within their administered municipality', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const municipality = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [municipality], + }) + const stateDeputy = await fixtures.createStateDeputy() + + const { leadership: assigned } = await setLeadershipStateDeputyMembershipRecord( + payload, + advisor, + { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }, + ) + expect(stateDeputyIds(assigned)).toContain(stateDeputy.id) + }) + + it('denies an advisor outside the leadership scope and a leader actor', async () => { + const fixtures = campaignFixtures() + const advisorActor = await fixtures.createCampaignUser('advisor') + const outsideMunicipality = await fixtures.getMunicipality() + const otherMunicipality = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(outsideMunicipality, [advisorActor]) + const leaderActor = await fixtures.createCampaignUser('leader') + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [otherMunicipality], + }) + const stateDeputy = await fixtures.createStateDeputy() + + await expect( + setLeadershipStateDeputyMembershipRecord(payload, advisorActor, { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }), + ).rejects.toThrow() + + await expect( + setLeadershipStateDeputyMembershipRecord(payload, leaderActor, { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }), + ).rejects.toThrow(/coordenação e a assessoria/i) + }) + + it('is idempotent (no-op returns the same document without re-writing)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [municipality], + }) + const stateDeputy = await fixtures.createStateDeputy() + + await setLeadershipStateDeputyMembershipRecord(payload, coordinator, { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }) + + const { leadership: again } = await setLeadershipStateDeputyMembershipRecord( + payload, + coordinator, + { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }, + ) + expect(stateDeputyIds(again)).toEqual([stateDeputy.id]) + }) + + it('rejects once the leadership is at the 20-state-deputy cap', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact, + municipalities: [municipality], + }) + + for (let index = 0; index < 20; index += 1) { + const stateDeputy = await fixtures.createStateDeputy() + await setLeadershipStateDeputyMembershipRecord(payload, coordinator, { + leadershipId: leadership.id, + stateDeputyId: stateDeputy.id, + assigned: true, + }) + } + + const twentyFirst = await fixtures.createStateDeputy() + await expect( + setLeadershipStateDeputyMembershipRecord(payload, coordinator, { + leadershipId: leadership.id, + stateDeputyId: twentyFirst.id, + assigned: true, + }), + ).rejects.toThrow(/no máximo 20 dobradinhas/i) + }) +}) diff --git a/tests/unit/leadershipStateDeputyMembership.unit.spec.ts b/tests/unit/leadershipStateDeputyMembership.unit.spec.ts new file mode 100644 index 00000000..b9248710 --- /dev/null +++ b/tests/unit/leadershipStateDeputyMembership.unit.spec.ts @@ -0,0 +1,26 @@ +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.`, + ) + }) +})