diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 5a63d61a..2c51a0d0 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md).
- **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md).
- **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).
diff --git a/docs/plans/chips-liderancas-lista-dobradinhas.md b/docs/plans/chips-liderancas-lista-dobradinhas.md
index be0fbc81..37449d3a 100644
--- a/docs/plans/chips-liderancas-lista-dobradinhas.md
+++ b/docs/plans/chips-liderancas-lista-dobradinhas.md
@@ -1,12 +1,23 @@
# Chips de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores)
-Status: rascunho
-Atualizado em: 2026-07-25
-Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36**)
-Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação desenhada pelo **B31** para o lado inverso da mesma relação
-Appetite: ~0,5–0,75 dia se **B31** já tiver aterrissado (só a direção nova + o loader da lista); ~1–1,5 dia se **B36** entrar primeiro (paga a extração do componente/ação compartilhados que o B31 passaria a consumir)
+Status: entregue (2026-07-26)
+Atualizado em: 2026-07-26
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B36** ✓)
+Impeccable: B — encaixe na coluna "Lideranças" de `/campanha/dobradinhas`, reusando o padrão visual já em produção em `/campanha/assessores` (**B19 ✓**) e a mutação entregue pelo **B31 ✓** para o lado inverso da mesma relação
+Appetite: ~0,75–1d (B31 já aterrissado; paga a extração da célula compartilhada que o B31 deixou pendente)
Responsável: —
+## Revisão na entrega (2026-07-26)
+
+Auditoria pré-implementação contra o B31 as-built:
+
+1. **Context → columns factory.** O plano pedia `RelationOptionsProvider`; o B31 `/simplify` rodada 2 já tinha deletado o Context em favor de `leadershipColumns(options)`. Entrega seguiu o precedente: `stateDeputyColumns(..., leadershipOptions)` + `toLeadershipRelationOptions`.
+2. **Casca por rota.** `setLeadershipStateDeputyMembershipFormAction` ganhou cópia fina em `dobradinhas/formActions.ts` (não importa de `liderancas/` — `formActions` são cascas por rota no codebase-map). Mutação/schema/lock inalterados.
+3. **Célula compartilhada.** `LeadershipStateDeputiesCell` deletado; nasce `shared/LeadershipStateDeputyRelationCell` com `direction` + `measureOverflow` (default true; lado liderança passa `false`). Tipos de UI: `RelationCellItem` / `RelationCellOption`.
+4. **Loader.** `leadershipCount` → `leaderships: {id,name}[]` em `StateDeputyRowViewModel`; `loadLeadershipOptions` em `campaignRelationOptions.ts`.
+
+As-built bate com as Decisões travadas de mutação/reuso; só o packaging de UI/catalogo divergiu do rascunho de 25/07, alinhado ao B31 real.
+
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 3 **Edit where you see**, 4 **Auto-save, no Save button**, 8 Feel the action; anti-goals "spreadsheet mode" e "always-mounted inputs on every row") / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedentes de interação [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) (B19 ✓ — chips removíveis + typeahead + otimista + `ResizeObserver`/"Ver mais…") e o desenho ainda não implementado do **B31** ([`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) — chips + Popover + `Command`, sem "Ver mais…", para a mesma relação `leadership.stateDeputies` na direção oposta) · shells `CampaignTable` / `CampaignPageShell` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index df7479f5..1c961a14 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-26 (**B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **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 — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [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 (**B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **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 — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [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)
@@ -113,7 +113,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**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 — 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)
+- ~~**B36** coluna de lideranças na lista de dobradinhas~~ — **entregue 2026-07-26** (coluna **Lideranças** em `/campanha/dobradinhas`: chips clicáveis → `/campanha/liderancas/`, edição por Popover+`Command` com escrita por server action + `useTransition`, reusando **sem alteração** `setLeadershipStateDeputyMembership`/`leadershipStateDeputyMembershipSchema`/lock `leadership-state-deputies:{id}`/`nextStateDeputyIdsAfterMembership` do **B31 ✓**; `LeadershipStateDeputiesCell` promovido a `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction` + `measureOverflow` — clamp 3 linhas + "Ver mais…" no lado do deputado; lado liderança passa `false`); columns factory `stateDeputyColumns(..., leadershipOptions)` — não Context; casca própria `dobradinhas/formActions.ts`; `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Sem migration) · [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)
@@ -181,7 +181,7 @@ flowchart TD
B33n["B33 ✓ Sort + filtro no header da lista de dobradinhas"]
B34n["B34 Chips editáveis de município na lista de lideranças"]
B37n["B37 Chips editáveis de município na lista de dobradinhas"]
- B36n["B36 Chips de lideranças na lista de dobradinhas"]
+ B36n["B36 ✓ Chips de lideranças na lista de dobradinhas"]
B29n["B29 Sort + filtro no header da lista de lideranças"]
C13n["C13 ✓ Plano de Ação → Atividade (rename + guard de vocabulário)"]
E4R["E4R ✓ Import planilha (seed estratégia)"]
@@ -324,7 +324,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`).
-**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins.
+**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins.
## Bloqueadores atuais
diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts
index 7f1008ba..2dec4e6e 100644
--- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts
@@ -7,7 +7,12 @@ import {
setAdvisorMunicipalityMembership,
updateAdvisorProfile,
} from '@/app/(campaign)/campanha/actions/advisor'
-import { nullableFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
+import {
+ nullableFormText,
+ requiredFormBoolean,
+ requiredFormText,
+ requiredRelationshipFormValue,
+} from '@/lib/formData'
import { ADVISOR_ACTION_SAFE_MESSAGES } from '@/lib/schemas/advisor'
import {
runCampaignFormAction,
@@ -65,11 +70,10 @@ export const setAdvisorMunicipalityMembershipFormAction = async (
): Promise =>
runCampaignFormAction({
execute: async () => {
- const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalityMembership({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'),
- assigned: assignedRaw === 'true',
+ assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Carteira atualizada.' }
},
@@ -87,11 +91,10 @@ export const setAdvisorMunicipalitiesBatchFormAction = async (
if (municipalityIds.length === 0) {
throw new Error(EMPTY_BATCH_MESSAGE)
}
- const assignedRaw = requiredFormText(formData, 'assigned')
await setAdvisorMunicipalitiesBatch({
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
municipalityIds,
- assigned: assignedRaw === 'true',
+ assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Carteira atualizada.' }
},
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
new file mode 100644
index 00000000..eb9a7b43
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts
@@ -0,0 +1,32 @@
+'use server'
+
+import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
+import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
+import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
+import {
+ 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 "Lideranças" column of `/campanha/dobradinhas` (B36). */
+export const setLeadershipStateDeputyMembershipFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ await setLeadershipStateDeputyMembership({
+ leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
+ stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
+ assigned: requiredFormBoolean(formData, 'assigned'),
+ })
+ return { message: 'Lideranças atualizadas.' }
+ },
+ safeMessages,
+ genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
+ })
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
index 33aa1919..d5e8b5bc 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
@@ -15,6 +15,10 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
+import {
+ LeadershipStateDeputyRelationCell,
+ type RelationCellOption,
+} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
@@ -30,6 +34,7 @@ import {
import { cn } from '@/lib/utils'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
+import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
import {
loadStateDeputyListPageData,
type StateDeputyRowViewModel,
@@ -46,6 +51,8 @@ import {
type StateDeputyListState,
} from '@/utilities/stateDeputyListUrl'
+import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
+
type StateDeputiesPageProps = {
searchParams: Promise>
}
@@ -79,6 +86,7 @@ const stateDeputyColumns = (
state: StateDeputyListState,
partyFilterOptions: StateDeputyFilterOption[],
hasNoPartyOption: boolean,
+ leadershipOptions: RelationCellOption[],
): Array> => [
{
id: 'name',
@@ -121,8 +129,20 @@ const stateDeputyColumns = (
{
id: 'leaderships',
head: Lideranças,
- cellClassName: 'tabular-nums',
- cell: (row) => row.leadershipCount,
+ cellClassName: 'max-w-72 whitespace-normal',
+ cell: (row) => (
+ ({
+ id: leadership.id,
+ label: leadership.name,
+ href: `/campanha/liderancas/${leadership.id}`,
+ }))}
+ options={leadershipOptions}
+ membershipAction={setLeadershipStateDeputyMembershipFormAction}
+ />
+ ),
},
]
@@ -135,11 +155,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
if (!user) redirect('/campanha/login')
if (!isCampaignStaff(user)) redirect('/campanha')
- const { rows, totalDocs, totalPages, filterFacets } = await loadStateDeputyListPageData(
- payload,
- user,
- canonicalUrl.state,
- )
+ const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
+ loadStateDeputyListPageData(payload, user, canonicalUrl.state),
+ loadLeadershipOptions(payload, user),
+ ])
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@@ -147,6 +166,20 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
const { sort, dir } = resolveStateDeputyListSort(state)
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
+ const columns = stateDeputyColumns(
+ state,
+ partyFilterOptions,
+ filterFacets.hasNoParty,
+ leadershipOptions.map((option) => ({
+ id: option.id,
+ searchLabel: option.name,
+ item: {
+ id: option.id,
+ label: option.name,
+ href: `/campanha/liderancas/${option.id}`,
+ },
+ })),
+ )
return (
@@ -154,8 +187,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
Dobradinhas
- Deputados estaduais com quem a campanha dobra — vincule a municípios e lideranças nas
- fichas correspondentes.
+ Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
+ municípios nas fichas correspondentes.
row.id}
empty={}
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
index cb68b7fd..179c43fb 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
@@ -1,7 +1,7 @@
'use server'
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
-import { requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
+import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
import {
runCampaignFormAction,
@@ -20,11 +20,10 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
): Promise =>
runCampaignFormAction({
execute: async () => {
- const assignedRaw = requiredFormText(formData, 'assigned')
await setLeadershipStateDeputyMembership({
leadershipId: requiredRelationshipFormValue(formData, 'leadershipId'),
stateDeputyId: requiredRelationshipFormValue(formData, 'stateDeputyId'),
- assigned: assignedRaw === 'true',
+ assigned: requiredFormBoolean(formData, 'assigned'),
})
return { message: 'Dobradinhas atualizadas.' }
},
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
index 65ff6ba1..9b5205b8 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
@@ -5,7 +5,6 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
-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'
@@ -20,16 +19,17 @@ import {
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
+import {
+ LeadershipStateDeputyRelationCell,
+ type RelationCellOption,
+} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
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 { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
buildLeadershipListHref,
loadLeadershipListPageData,
@@ -44,7 +44,7 @@ type LeadershipsPageProps = {
}
const leadershipColumns = (
- stateDeputyOptions: StateDeputyRelationOption[],
+ stateDeputyOptions: RelationCellOption[],
): Array> => [
{
id: 'name',
@@ -99,11 +99,18 @@ const leadershipColumns = (
head: Dobradinhas,
cellClassName: 'max-w-56 whitespace-normal',
cell: (row) => (
- ({
+ id: deputy.id,
+ label: deputy.name,
+ href: `/campanha/dobradinhas/${deputy.slug}`,
+ ...(deputy.party ? { party: deputy.party } : {}),
+ }))}
options={stateDeputyOptions}
membershipAction={setLeadershipStateDeputyMembershipFormAction}
+ measureOverflow={false}
/>
),
},
@@ -173,7 +180,18 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
loadLeadershipListPageData(payload, user, state),
loadStateDeputyOptions(payload, user),
])
- const columns = leadershipColumns(stateDeputyOptions)
+ const columns = leadershipColumns(
+ stateDeputyOptions.map((option) => ({
+ id: option.id,
+ searchLabel: option.name,
+ item: {
+ id: option.id,
+ label: option.plainName,
+ href: `/campanha/dobradinhas/${option.slug}`,
+ ...(option.party ? { party: option.party } : {}),
+ },
+ })),
+ )
return (
diff --git a/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx b/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx
deleted file mode 100644
index d27479b3..00000000
--- a/src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-'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 (
-