From 06ea1ff2954019953756eb1fb023298fbca28e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 21:14:38 -0300 Subject: [PATCH] =?UTF-8?q?B36:=20chips=20de=20lideran=C3=A7as=20na=20list?= =?UTF-8?q?a=20de=20dobradinhas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Espelha a aresta leadership.stateDeputies do B31 do lado do deputado, com célula compartilhada bidirecional, loadLeadershipOptions e casca própria de formActions. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + .../chips-liderancas-lista-dobradinhas.md | 21 +- docs/roadmap.md | 8 +- .../campanha/(app)/assessores/formActions.ts | 13 +- .../campanha/(app)/dobradinhas/formActions.ts | 32 ++ .../campanha/(app)/dobradinhas/page.tsx | 53 ++- .../campanha/(app)/liderancas/formActions.ts | 5 +- .../campanha/(app)/liderancas/page.tsx | 38 +- .../LeadershipStateDeputiesCell.tsx | 222 ---------- .../LeadershipStateDeputyRelationCell.tsx | 385 ++++++++++++++++++ src/lib/formData.ts | 8 + src/utilities/campaignRelationOptions.ts | 29 +- src/utilities/relationship.ts | 7 + src/utilities/stateDeputyData.ts | 51 ++- tests/int/campaignEntityListData.int.spec.ts | 31 +- 15 files changed, 624 insertions(+), 280 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts delete mode 100644 src/components/campaign/leadership/LeadershipStateDeputiesCell.tsx create mode 100644 src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx 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.

- - -
-

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/shared/LeadershipStateDeputyRelationCell.tsx b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx new file mode 100644 index 00000000..feae608f --- /dev/null +++ b/src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx @@ -0,0 +1,385 @@ +'use client' + +import { PencilIcon, XIcon } from 'lucide-react' +import Link from 'next/link' +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { toast } from 'sonner' + +import { 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' + +export type RelationCellItem = { + id: number + label: string + href: string + party?: string +} + +export type RelationCellOption = { + id: number + searchLabel: string + item: RelationCellItem +} + +type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeputy' + +type LeadershipStateDeputyRelationCellProps = { + direction: LeadershipStateDeputyRelationDirection + fixedId: number + items: RelationCellItem[] + options: RelationCellOption[] + membershipAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + /** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */ + measureOverflow?: boolean +} + +const COLLAPSED_CHIP_ROWS = 3 +/** Matches `gap-1` on the rest chip row. */ +const CHIP_GAP_PX = 4 + +const COPY = { + fromLeadership: { + editLabel: 'Editar dobradinhas', + title: 'Atribuir dobradinhas', + description: 'Deputados estaduais em dobradinha com esta liderança.', + searchPlaceholder: 'Buscar deputado estadual…', + searchAriaLabel: 'Buscar deputado estadual', + saving: 'Salvando dobradinhas.', + savingAria: 'Salvando dobradinhas', + saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.', + }, + fromStateDeputy: { + editLabel: 'Editar lideranças', + title: 'Atribuir lideranças', + description: 'Lideranças em dobradinha com este deputado estadual.', + searchPlaceholder: 'Buscar liderança…', + searchAriaLabel: 'Buscar liderança', + saving: 'Salvando lideranças.', + savingAria: 'Salvando lideranças', + saveError: 'Não foi possível atualizar as lideranças. Tente novamente.', + }, +} as const + +const itemLabel = (item: RelationCellItem): string => + item.party ? `${item.label} (${item.party})` : item.label + +/** + * Bidirectional cell for the `leadership.stateDeputies` edge. + * Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the + * same membership action. + */ +export const LeadershipStateDeputyRelationCell = ({ + direction, + fixedId, + items, + options, + membershipAction, + measureOverflow = true, +}: LeadershipStateDeputyRelationCellProps) => { + const copy = COPY[direction] + const [open, setOpen] = useState(false) + const [current, setCurrent] = useState(items) + const [query, setQuery] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [isPending, startTransition] = useTransition() + const lastPropsRef = useRef(items) + + const [expanded, setExpanded] = useState(false) + const [collapsedHeight, setCollapsedHeight] = useState(null) + /** `null` means "render every chip so the next effect can measure them". */ + const [visibleChipCount, setVisibleChipCount] = useState(null) + const [measureToken, setMeasureToken] = useState(0) + const lastRowWidth = useRef(0) + const chipRowRef = useRef(null) + const editRef = useRef(null) + + // Adopt server props only when they change from outside (navigation / RSC + // refresh) — an in-flight delta's optimistic state must not be clobbered by + // a prop identical in content to what we already had. + useEffect(() => { + const propIds = items.map((item) => item.id) + if ( + sameIdSet( + propIds, + lastPropsRef.current.map((item) => item.id), + ) + ) + return + lastPropsRef.current = items + setCurrent(items) + }, [items]) + + const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current]) + const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options]) + + const filteredOptions = useMemo(() => { + if (!open) return [] + return options.filter( + (option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query), + ) + }, [open, options, currentIds, query]) + + const invalidateMeasurement = useCallback(() => { + setVisibleChipCount(null) + setMeasureToken((token) => token + 1) + }, []) + + const chipsKey = current.map((item) => item.id).join('|') + useEffect(() => { + if (!measureOverflow) return + invalidateMeasurement() + }, [chipsKey, measureOverflow, invalidateMeasurement]) + + useEffect(() => { + if (!measureOverflow) return + const row = chipRowRef.current + if (!row) return + lastRowWidth.current = row.clientWidth + const observer = new ResizeObserver(() => { + if (row.clientWidth === lastRowWidth.current) return + lastRowWidth.current = row.clientWidth + invalidateMeasurement() + }) + observer.observe(row) + return () => observer.disconnect() + }, [measureOverflow, invalidateMeasurement]) + + useEffect(() => { + if (!measureOverflow || visibleChipCount !== null) return + const row = chipRowRef.current + if (!row) return + + const chipElements = [...row.querySelectorAll('[data-relation-chip]')] + if (chipElements.length === 0) { + setVisibleChipCount(0) + return + } + + const rowRect = row.getBoundingClientRect() + const chipRects = chipElements.map((element) => element.getBoundingClientRect()) + const firstChip = chipRects[0] + if (!firstChip) { + setVisibleChipCount(0) + return + } + const chipHeight = firstChip.height + setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1)) + + const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1) + let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length + + // Reserve room on the last visible line for "Ver mais…" (and the sibling pencil). + const toggleWidth = + row.querySelector('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0 + const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0 + const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth + while (fitting > 0 && fitting < chipElements.length) { + const trailing = chipRects[fitting - 1] + if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break + fitting -= 1 + } + + setVisibleChipCount(fitting) + }, [measureOverflow, measureToken, visibleChipCount]) + + const toggle = (itemId: number, assigned: boolean) => { + // The removed summary is captured up front (not re-derived from the + // catalog) so a failed removal can be undone even for an item that has + // since dropped out of `options` — same asymmetry as the `optionById` + // lookup below, which only needs to resolve *additions*. + const removedItem = assigned ? undefined : current.find((item) => item.id === itemId) + if (assigned && !optionById.has(itemId)) return + + setErrorMessage(null) + setCurrent((previous) => { + if (assigned) { + if (previous.some((item) => item.id === itemId)) return previous + const option = optionById.get(itemId) + if (!option) return previous + return [...previous, option.item] + } + return previous.filter((item) => item.id !== itemId) + }) + setQuery('') + + const formData = new FormData() + if (direction === 'fromLeadership') { + formData.set('leadershipId', String(fixedId)) + formData.set('stateDeputyId', String(itemId)) + } else { + formData.set('leadershipId', String(itemId)) + formData.set('stateDeputyId', String(fixedId)) + } + formData.set('assigned', assigned ? 'true' : 'false') + + startTransition(async () => { + const result = await membershipAction({}, formData) + if (result.status === 'success') return + // Undo only this delta — not the whole cell back to `lastPropsRef` — + // so a failed toggle can't wipe an earlier toggle from the same batch + // that already saved successfully. + setCurrent((previous) => + assigned + ? previous.filter((item) => item.id !== itemId) + : removedItem + ? [...previous, removedItem] + : previous, + ) + const message = result.message ?? copy.saveError + setErrorMessage(message) + toast.error(message) + }) + } + + const statusMessage = errorMessage ?? (isPending ? copy.saving : '') + + const showAllChips = !measureOverflow || expanded + const measuring = measureOverflow && visibleChipCount === null + const hasHiddenChips = + measureOverflow && visibleChipCount !== null && visibleChipCount < current.length + const visibleItems = (() => { + if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current + return current.slice(0, visibleChipCount) + })() + /** + * Only the pre-measurement render needs a height cap; once the chip list is + * trimmed the row is three lines tall on its own. + */ + const clamping = measuring && !showAllChips + const chipRowStyle = + clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined + + // Mounted while measuring so width is reserved; hidden until overflow is known. + const expandToggle = + measureOverflow && (measuring || hasHiddenChips) ? ( + + ) : null + + return ( +
+
0 ? 'min-w-0 flex-1' : undefined, + clamping && 'overflow-hidden', + )} + style={chipRowStyle} + > + {current.length === 0 ? ( + — + ) : ( + visibleItems.map((item) => ( + + + {itemLabel(item)} + + + )) + )} + {expandToggle} +
+ + + + + +
+

{copy.title}

+

{copy.description}

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

+ Nenhum resultado. +

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

+ {statusMessage} +

+
+
+
+ ) +} diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 9a91281e..bb1cf045 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -32,6 +32,14 @@ export const requiredFormText = (formData: FormData, field: string): string => { return value } +/** Membership toggles send `'true'` / `'false'` as form text. */ +export const requiredFormBoolean = (formData: FormData, field: string): boolean => { + const value = requiredFormText(formData, field) + if (value === 'true') return true + if (value === 'false') return false + throw new FormDataBoundaryError(field, 'Valor inválido.') +} + export const requiredFormSecret = (formData: FormData, field: string): string => { const entry = formEntry(formData, field) const value = entry.present ? entry.value : '' diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts index dff474b2..5821eeef 100644 --- a/src/utilities/campaignRelationOptions.ts +++ b/src/utilities/campaignRelationOptions.ts @@ -4,7 +4,7 @@ import type { Payload } from 'payload' import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import type { CampaignUser } from '@/payload-types' -import { relationshipId } from '@/utilities/relationship' +import { populatedContactName, relationshipId } from '@/utilities/relationship' export type ActivityRelationOption = RelationOption & { municipalityId: number @@ -105,3 +105,30 @@ export const loadStateDeputyOptions = async ( slug: stateDeputy.slug, })) } + +/** + * Leadership catalog for typeahead cells (B36). Access of `canReadLeadership` + * already scopes an advisor to leaderships of administered municipalities — + * no manual intersection with `getAccessibleLeadershipIds`. + */ +export const loadLeadershipOptions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + const result = await payload.find({ + collection: 'leadership', + depth: 1, + limit: 0, + pagination: false, + select: { contact: true }, + where: {}, + user, + overrideAccess: false, + }) + return result.docs + .map((leadership) => ({ + id: leadership.id, + name: populatedContactName(leadership.contact), + })) + .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) +} diff --git a/src/utilities/relationship.ts b/src/utilities/relationship.ts index fa2314a5..2dd0ee67 100644 --- a/src/utilities/relationship.ts +++ b/src/utilities/relationship.ts @@ -13,6 +13,13 @@ export const relationshipId = (value: unknown): number | null => { return isPopulatedRelationship(value) ? value.id : null } +/** Display name from a populated `Contact` relation (or `fallback` when unpopulated). */ +export const populatedContactName = (contact: unknown, fallback = 'Contato'): string => { + if (typeof contact !== 'object' || contact === null || !('name' in contact)) return fallback + const { name } = contact + return typeof name === 'string' && name.length > 0 ? name : fallback +} + export const requireRelationshipId = ( value: unknown, message = 'Relacionamento inválido.', diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index 5c403b34..748acf9d 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -2,8 +2,8 @@ import 'server-only' import type { Payload } from 'payload' -import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types' -import { relationshipId } from '@/utilities/relationship' +import type { CampaignUser, StateDeputy } from '@/payload-types' +import { populatedContactName, relationshipId } from '@/utilities/relationship' import { buildStateDeputyListWhere, NO_PARTY_FILTER_VALUE, @@ -13,13 +13,18 @@ import { type StateDeputyListState, } from '@/utilities/stateDeputyListUrl' +type LeadershipRelationSummary = { + id: number + name: string +} + export type StateDeputyRowViewModel = { id: number name: string slug: string party: string | null municipalityCount: number - leadershipCount: number + leaderships: LeadershipRelationSummary[] } /** Values still reachable under the OTHER active filters (the Partido popover). */ @@ -94,7 +99,7 @@ export const loadStateDeputyListPageData = async ( const stateDeputyIDs = result.docs.map((doc) => doc.id) const municipalityCounts = new Map() - const leadershipCounts = new Map() + const leadershipsByDeputy = new Map() if (stateDeputyIDs.length) { const [municipalities, leaderships] = await Promise.all([ @@ -110,11 +115,14 @@ export const loadStateDeputyListPageData = async ( payload.find({ collection: 'leadership', where: { stateDeputies: { in: stateDeputyIDs } }, - depth: 0, + depth: 1, limit: 0, pagination: false, - select: { stateDeputies: true }, - overrideAccess: true, + select: { stateDeputies: true, contact: true }, + // Names ship in the list cell — honour canReadLeadership (unlike + // municipalityCounts above, which stays aggregate-only). + user, + overrideAccess: false, }), ]) @@ -128,13 +136,24 @@ export const loadStateDeputyListPageData = async ( } for (const leadership of leaderships.docs) { + const summary: LeadershipRelationSummary = { + id: leadership.id, + name: populatedContactName(leadership.contact), + } for (const deputy of leadership.stateDeputies ?? []) { const id = relationshipId(deputy) if (id !== null && stateDeputyIDs.includes(id)) { - leadershipCounts.set(id, (leadershipCounts.get(id) ?? 0) + 1) + const list = leadershipsByDeputy.get(id) ?? [] + list.push(summary) + leadershipsByDeputy.set(id, list) } } } + + for (const [id, list] of leadershipsByDeputy) { + list.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) + leadershipsByDeputy.set(id, list) + } } return { @@ -144,7 +163,7 @@ export const loadStateDeputyListPageData = async ( slug: doc.slug, party: doc.party ?? null, municipalityCount: municipalityCounts.get(doc.id) ?? 0, - leadershipCount: leadershipCounts.get(doc.id) ?? 0, + leaderships: leadershipsByDeputy.get(doc.id) ?? [], })), totalDocs: result.totalDocs, totalPages: result.totalPages, @@ -217,16 +236,10 @@ export const loadStateDeputyDetail = async ( name: municipality.name, slug: municipality.slug, })), - leaderships: leaderships.docs.map((leadership) => { - const contact = leadership.contact as Leadership['contact'] - return { - id: leadership.id, - name: - typeof contact === 'object' && contact !== null && 'name' in contact - ? String(contact.name) - : 'Contato', - } - }), + leaderships: leaderships.docs.map((leadership) => ({ + id: leadership.id, + name: populatedContactName(leadership.contact), + })), } } diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index 37e5211f..ea0ba2c2 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -132,7 +132,36 @@ describe('loadStateDeputyListPageData', () => { slug: stateDeputy.slug, party: 'PT', municipalityCount: 0, - leadershipCount: 0, + leaderships: [], + }) + }) + + it('resolves named leaderships linked to the deputy (not a bare count)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const stateDeputy = await fixtures.createStateDeputy({ party: 'PT' }) + const contact = await fixtures.createContact() + const leadership = await fixtures.createLeadership({ + contact: contact.id, + municipalities: [municipality.id], + }) + await payload.update({ + collection: 'leadership', + id: leadership.id, + data: { stateDeputies: [stateDeputy.id] }, + depth: 0, + overrideAccess: true, + }) + + const result = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: stateDeputy.name, + }) + + expect(result.rows[0]).toMatchObject({ + slug: stateDeputy.slug, + leaderships: [{ id: leadership.id, name: contact.name }], }) })