From 9150092e17a39805ab0d0bd379c69755901e860c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 15:28:46 -0300 Subject: [PATCH 1/2] =?UTF-8?q?B28:=20e-mail=20+=20celular=20na=20lista=20?= =?UTF-8?q?de=20lideran=C3=A7as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - colunas E-mail/Celular (copy-on-click, toast, "—" vazio) + coluna de Ações com WhatsApp (wa.me), no padrão de /campanha/assessores (B19) - shared/CampaignCopyableCell.tsx nasce compartilhado já no 2º call site; AdvisorsTable.tsx migrado no mesmo PR para consumi-lo - whatsAppHrefForPhone promovido a lib/phone.ts - leadershipData.ts: contactSummary (+ email), loadLeadershipDetail deduplicado (reusa row.email) - simplify: remove try/catch morto em whatsAppHrefForPhone, remove casts redundantes em contactSummary, displayEmail retorna string | null direto - docs: nucleos-eleitorais.mdc, roadmap.md, plano B28 marcados entregues --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- docs/plans/email-celular-lista-liderancas.md | 6 +- docs/roadmap.md | 6 +- .../campanha/(app)/liderancas/page.tsx | 57 +++++++++++++++- .../campaign/advisor/AdvisorsTable.tsx | 68 ++++--------------- .../campaign/shared/CampaignCopyableCell.tsx | 64 +++++++++++++++++ src/lib/phone.ts | 6 ++ src/utilities/leadershipData.ts | 21 +++--- 8 files changed, 157 insertions(+), 73 deletions(-) create mode 100644 src/components/campaign/shared/CampaignCopyableCell.tsx diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index d6be9682..9e94651f 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -24,7 +24,7 @@ alwaysApply: false - **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md). - **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md). - **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md). -- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md). +- **B28 entregue em código (2026-07-26):** e-mail + celular na lista `/campanha/liderancas` — colunas **E-mail** e **Celular** (copy-on-click, toast, "—" vazio) mais uma coluna de **Ações** com botão WhatsApp (`wa.me`, desabilitado com `aria-label` honesto sem celular válido), ordem final Nome → E-mail → Celular → Status → Municípios → Organizações → Acesso ao app → Ações, no mesmo padrão visual/interativo de `/campanha/assessores` (**B19 ✓**). `whatsAppHrefForPhone` promovido de `AdvisorsTable.tsx` para `src/lib/phone.ts` (pura, client-safe); novo `shared/CampaignCopyableCell.tsx` (+ `campaignReadCellClassName` exportado) nasce **já no 2º call site** — `AdvisorsTable.tsx` migrado no mesmo PR para consumi-lo, eliminando o `copyText`/`READ_CELL_CLASS` duplicados. `leadershipData.ts`: `contactNameAndPhone` virou `contactSummary` (+ `email`), `LeadershipRowViewModel.email` populado sem query nova (`depth: 1` já trazia o `Contact` inteiro), e `loadLeadershipDetail` parou de reparsear `doc.contact` — reusa `row.email` de `toLeadershipRows`. Sem migration/collection/Consent/URL novo; access inalterado. Gate verde: tsc/lint/format/check:cycles limpos, 393 unit+int, build produção ok; `pnpm exec knip` mantém o erro pré-existente ao carregar `payload.config.ts` (P3, não relacionado); `pnpm test:e2e` reproduziu só o flake pré-existente já ledgerado em `campaignMunicipalities.e2e.spec.ts` (checkbox de consentimento do `LeaderContactsPanel` antes da hidratação) — nenhuma rota tocada por este item falhou. Aikido 0 achados nos 5 arquivos novos/editados. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md). - **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md). - **Fill-in registrado (2026-07-25):** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + `?kind=` do contrato URL; schema/catálogo `kind` intacto (mapa, B8, dossiê). Appetite ~0,25–0,5d; Janela 1; cortável; Impeccable B. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md). - **Pass 2 — Consolidação de engenharia entregue (2026-07-25, W0–W5 + D3):** tudo que foi one-shotted desde o Pass 1 virou infraestrutura compartilhada e arquitetura documentada. **W1 sistema de listas:** colunas como dado em `shared/CampaignTable.tsx` (seams `mandatory`/`defaultVisible` deixam **B17 trivial**; serialização canônica congelada deixa **B18 trivial**), o god module `municipalityUi.ts` (765 linhas/59 exports) dividido em `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`, 8 superfícies migradas com URLs byte-idênticas (pins do W0 verdes) e **B16+ absorvido** (`useOptimistic` no popover, facet por slugs, hrefs via serializador canônico com pin de byte-igualdade, facets no mesmo `Promise.all`); fora por decisão D5: planos (cards), `TerritoryOverviewTable`, comparação, LeaderContacts, preview de import. **W2 fronteiras:** `slug`/`phone`/`wordStartFilter`/`voteEstimate` → `lib/`, chaves de consent em `lib/campaignConsentKeys.ts`, 21 loaders `server-only`, `lib/campaignRoles.ts` client-safe (sidebar sem o barrel), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em **15 subpastas de domínio**; decisão D1: **sem `src/domains/`**, sem ports-and-adapters (`utilities/` adiado com gatilho: 3º módulo novo de um domínio num mês). **W4a:** `electionInsights.ts` (941 linhas, 2 exports vivos) **deletado** — sobreviventes em `electionFormat.ts`/`voteTrend.ts`; **E10 nasce em módulo novo**. **W4b:** knip `exports`/`types`/`enumMembers` em **ERROR** no CI, 0 findings (kit ui sem peças mortas; `duplicates` off — únicos hits eram os 6 aliases deliberados `canUpdateX = canReadX`). **W4c:** tabs de detalhe extraídas + factory `detailTabUi`. **W4d:** `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções comentadas). **W4e:** tipos de fonte única (cenários, trend, strategy VM nomeado). **W4f:** copy Praça→Município varrida (~70 strings; apoiadores desambiguado: cidade do contato = "Cidade"). **D3:** `submitWhatsapp` resolve `Consent.key='whatsapp-inscricao'` fail-closed; migration `20260725_170000` tagueia o doc vivo — **revisar o SQL antes de push/deploy**. Artefatos novos: `docs/ARCHITECTURE.md` + `.cursor/rules/codebase-map.mdc` (atualizados no mesmo PR que mover coisas). Suites: 46 unit / 48 int / 7 e2e; gates verdes por entrega. Tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2. diff --git a/docs/plans/email-celular-lista-liderancas.md b/docs/plans/email-celular-lista-liderancas.md index 6f09bd2d..9a4a9fd9 100644 --- a/docs/plans/email-celular-lista-liderancas.md +++ b/docs/plans/email-celular-lista-liderancas.md @@ -1,12 +1,14 @@ # E-mail e celular na lista de lideranças -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue +Atualizado em: 2026-07-26 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B28**) Impeccable: B — encaixe em `/campanha/liderancas` (colunas + células no `CampaignTable` existente); sem rota nova Appetite: ~0,5–0,75 dia eng; expor `email` no view model da lista + 2 colunas + células copiáveis + ícone WhatsApp (padrão B19 ✓); sem migration Responsável: — +**Nota de revisão (2026-07-26):** entregue como especificado — `CampaignCopyableCell` nasceu compartilhado (`shared/`) já no 2º call site (`AdvisorsTable.tsx` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `LeadershipRowViewModel.email` populado sem query nova, e `loadLeadershipDetail` deduplicado (reusa `row.email` de `toLeadershipRows` em vez de reparsear `doc.contact`). Ordem de colunas final: Nome → E-mail → Celular → Status → Municípios → Organizações → Acesso ao app → Ações (WhatsApp), exatamente como travado abaixo. Gate completo (tsc/lint/format/knip/cycles/test/build) verde; `pnpm exec knip` mantém o erro pré-existente ao carregar `payload.config.ts` (P3, não relacionado); `pnpm test:e2e` reproduziu apenas o flake pré-existente já documentado em `campaignMunicipalities.e2e.spec.ts` (checkbox de consentimento do `LeaderContactsPanel` antes da hidratação) — nenhuma falha nas rotas tocadas por este item. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (princípios 2 clareza sob pressão, 8 Feel the action; anti spreadsheet) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedente de interação [`AdvisorsTable.tsx`](../../src/components/campaign/advisor/AdvisorsTable.tsx) (B19 ✓) · shells `CampaignTable` / `CampaignPageShell`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 490becba..020b7997 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**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 — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); 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 — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [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 (**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 — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [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) @@ -103,7 +103,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B25** link do território na lista de municípios — pedido de produto (2026-07-25): na coluna "Território" de `/campanha/municipios` o nome do TI vira link para **a página daquele território**. Hoje a célula é texto morto (`cell: (m) => m.region`) e a navegação é assimétrica: o painel do Início (**E17 ✓**) já linka TI → `/campanha/municipios?region=`, mas do município não se chega ao território. Destino = a página do **B21** ancorada na linha do TI (`/campanha/territorios#ti-` + `scroll-mt` e `target:` no precedente de `/campanha/conceitos`), **não** rota de detalhe `/campanha/territorios/` (Não escopo do B21, sem evidência nova: seria a linha do rollup + a lista filtrada, que já existem) nem `?region=` (tabela de uma linha destrói a comparação que é o job da tela). Âncora derivada por helper puro (`territoryAnchorId` sobre `slugify`), consumida pelas duas pontas e pinada por teste — TI não ganha slug persistido; sem migration/collection/action/query nova · ~0,25d · Janela 1–2 · Impeccable B · **dep dura: B21** · soft: B17 (coluna ocultável) · E12 é gatilho de revisitação da decisão "âncora vs rota de detalhe", não dependência · cortável · [plano](plans/link-territorio-lista-municipios.md) - ~~**B26** registrar sinal a partir da célula "Último sinal"~~ — **entregue 2026-07-25** (Popover desktop / Drawer mobile; `municipalityUpdate` `kind: 'sinal'`; submit explícito; `revalidateMunicipalityListPaths`; sem migration) · [plano](plans/registrar-sinal-lista-municipios.md) - ~~**B27** combobox + chips + auto-save no popover de Assessores da lista de municípios~~ — **entregue 2026-07-26** (chips removíveis + `Command` de busca acento-insensível + gravação **por delta**, sem botão "Salvar"; `nextAdvisorIdsAfterMembership` subiu para `src/lib/municipalityAdvisorMembership.ts`; endpoint `POST /campanha/municipios/advisors` espelha `expected-votes/`; `assignMunicipalityAdvisorsFormAction`/replace do array sobrevive só para `/editar`) · [plano](plans/combobox-assessores-lista-municipios.md) -- **B28** e-mail + celular na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores (**B19 ✓**): clique copia (toast), “—” se vazio, celular com máscara BR, ícone WhatsApp (`wa.me`) por linha quando o número normaliza. Hoje o loader já traz `phone` no view model e o e-mail só no detalhe — a lista mostra Nome · Status · Municípios · Organizações · Acesso e obriga abrir a ficha para contactar. Extrair o primitivo de copy (+ helper WhatsApp de linha) para `shared/` no mesmo PR (2º call site do B19); `CampaignTable` permanece (não clonar `AdvisorsTable`). **Fora:** toggle “Editar” / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B19 ✓, B30 (mesma linha — ícone distinto) · cortável · [plano](plans/email-celular-lista-liderancas.md) +- ~~**B28** e-mail + celular na lista de lideranças~~ — **entregue 2026-07-26** (em `/campanha/liderancas`: colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores — **B19 ✓** — clique copia (toast), "—" se vazio, celular com máscara BR, mais uma coluna de **Ações** com ícone WhatsApp (`wa.me`) por linha quando o número normaliza; `LeadershipRowViewModel.email` populado sem query nova e `loadLeadershipDetail` deduplicado; primitivo de copy extraído para `shared/CampaignCopyableCell.tsx` **já no 2º call site** — `AdvisorsTable` migrado no mesmo PR — e `whatsAppHrefForPhone` promovido a `lib/phone.ts`; `CampaignTable` permanece (sem clonar `AdvisorsTable`). **Fora:** toggle "Editar" / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita) · [plano](plans/email-celular-lista-liderancas.md) - **B30** convidar para completar cadastro por WhatsApp na lista de lideranças — pedido de produto (2026-07-25): ação ao fim de cada linha de `/campanha/liderancas` disparando o **mesmo** fluxo que já existe no detalhe (`/campanha/liderancas/[id]` → `LeadershipInviteButtons`, `kind: 'autopreenchimento'`): gera `campaignInvite` (token único, 7 dias) via `createCampaignInvite` e mostra a âncora `wa.me` com a mensagem pronta, num `Popover` compacto (two-step reveal — sem `window.open` automático, que quebra com popup blocker após o `await`). Botão **disabled** quando `contact.phone` não passa em `normalizeBrazilianPhone` (mesmo critério de `AdvisorsTable`/B19 ✓), com `aria-label` explicando o motivo. Distinto do WhatsApp de contato do **B28** (que é atalho de chat sem token) — ícone diferente para não ambiguar as duas ações na mesma linha; nenhum dos dois bloqueia o outro. Reusa 100% da action/collection/access existentes; sem migration, sem endpoint, sem mudança de access · ~0,25–0,5d · Janela 1 · Impeccable B · soft: B28 (coordenar ícone na mesma linha) · cortável · [plano](plans/convite-whatsapp-lista-liderancas.md) - **B29** ordenação e filtro no header da lista de lideranças — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) - **B31** coluna de dobradinhas na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar a coluna **Dobradinhas** com as mesmas funcionalidades da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips clicáveis (→ `/campanha/dobradinhas/`), remover chip, buscar e adicionar por typeahead, gravação automática otimista. O dado já chega e é descartado (`LeadershipRowViewModel.stateDeputyNames`, como `sector`/`phone` antes de B28/B29), e o **E4R ✓** semeou dobradinha só em `municipality.stateDeputies` — as fichas nasceram vazias e hoje só se preenchem abrindo uma a uma o `RelationMultiSelect` do detalhe e clicando "Salvar", exatamente o trabalho de mesa do onboarding. **Paridade é da coluna, não do modo:** o toggle "Editar" de tabela inteira dos assessores existe lá porque todas as colunas daquela tela são editáveis; trazê-lo para cá é o anti-goal literal do princípio 3 do `PRODUCT.md` (spreadsheet / inputs montados em toda linha) já travado no **B28** — a edição fica por célula (Popover, contrato de B9 ✓/B24/B26/B27; typeahead inline é troca de container no craft, não de contrato). Escrita **por delta** (`leadershipId`+`stateDeputyId`+`assigned`) em transação com lock `leadership-state-deputies:{id}` e teto de 20 — **não** o replace de array do `updateLeadershipInternal` (sob auto-save, dois atores na mesma ficha se sobrescrevem); ao contrário do B24/B27, aqui vale `revalidatePath` (25 linhas, e `/campanha/dobradinhas` tem `leadershipCount` derivado + a ficha do deputado lista lideranças). Catálogo entregue **uma vez por tabela** via provider cliente (props por célula multiplicariam o payload por linha). Access inalterado (`canManageLeadership` escreve, `canReadStateDeputy` lê); sem migration/collection/Consent/mudança de URL · ~1d · Janela 1–2 · Impeccable B · soft: B27 (mesma peça — chips + combobox + delta; extração para `shared/` só no 3º call site), B19 ✓ (padrão e espelho do cálculo), B28/B29/B30 (mesma tabela), B17 (a tabela chega a 8–10 colunas), E4R ✓ · cortável · [plano](plans/dobradinhas-lista-liderancas.md) @@ -169,7 +169,7 @@ flowchart TD B25n["B25 Link do território
na lista de municípios"] B26n["B26 ✓ Registrar sinal
na célula Último sinal"] B27n["B27 ✓ Combobox + chips
no popover de Assessores"] - B28n["B28 E-mail + celular
na lista de lideranças"] + B28n["B28 ✓ E-mail + celular
na lista de lideranças"] B30n["B30 Convite WhatsApp
na lista de lideranças"] B31n["B31 Coluna de dobradinhas
na lista de lideranças"] B32n["B32 Editar Status de apoio
na célula da lista"] diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index 4e3e6da7..1301e85c 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -1,10 +1,11 @@ import config from '@payload-config' -import { PlusIcon, SearchXIcon } from 'lucide-react' +import { MessageCircleIcon, PlusIcon, SearchXIcon } from 'lucide-react' import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge' +import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { @@ -20,6 +21,7 @@ import { 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 { @@ -47,6 +49,24 @@ const leadershipColumns: Array> = [ ), }, + { + id: 'email', + head: E-mail, + cellClassName: 'max-w-56', + cell: (row) => , + }, + { + id: 'phone', + head: Celular, + cell: (row) => ( + + ), + }, { id: 'supportStatus', head: Status, @@ -73,6 +93,41 @@ const leadershipColumns: Array> = [ ), }, + { + id: 'actions', + head: ( + + Ações + + ), + cellClassName: 'text-right', + cell: (row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phone) + return whatsAppHref ? ( + + ) : ( + + ) + }, + }, ] export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) { diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index 99bf3e6d..f532cfbb 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -17,6 +17,10 @@ import { toast } from 'sonner' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorMunicipalityCell } from '@/components/campaign/advisor/AdvisorMunicipalityCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' +import { + CampaignCopyableCell, + campaignReadCellClassName, +} from '@/components/campaign/shared/CampaignCopyableCell' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -31,10 +35,9 @@ import { } from '@/components/ui/Table' import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio' import { - buildWhatsAppUrl, formatBrazilianPhoneInput, - normalizeBrazilianPhone, sanitizeBrazilianPhoneInput, + whatsAppHrefForPhone, } from '@/lib/phone' import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { cn } from '@/lib/utils' @@ -83,30 +86,8 @@ const emptyDraft = (): DraftAdvisor => ({ municipalities: [], }) -const whatsAppHrefForPhone = (phone: string | null): string | null => { - if (!phone || !normalizeBrazilianPhone(phone)) return null - try { - return buildWhatsAppUrl(phone) - } catch { - return null - } -} - -const copyText = async (label: string, value: string) => { - try { - await navigator.clipboard.writeText(value) - toast.success(`${label} copiado.`) - } catch { - toast.error(`Não foi possível copiar o ${label.toLowerCase()}.`) - } -} - -/** Mirrors the inline input box (height, border, padding) so toggling edition never reflows. */ -const READ_CELL_CLASS = - 'flex min-h-10 w-full items-center rounded-full border border-transparent px-2 text-left' - -const displayEmail = (email: string | null): string => { - if (!email || isPlanilhaPlaceholderEmail(email)) return '' +const displayEmail = (email: string | null): string | null => { + if (!email || isPlanilhaPlaceholderEmail(email)) return null return email } @@ -321,7 +302,6 @@ export const AdvisorsTable = ({ {rows.map((row) => { const whatsAppHref = whatsAppHrefForPhone(row.phone) const emailShown = displayEmail(row.email) - const phoneShown = row.phone ? formatBrazilianPhoneInput(row.phone) : '' return ( @@ -338,7 +318,7 @@ export const AdvisorsTable = ({ @@ -358,18 +338,8 @@ export const AdvisorsTable = ({ placeholderEmailFallback={row.email} formAction={updateProfileAction} /> - ) : emailShown ? ( - ) : ( - - — - + )} @@ -383,21 +353,13 @@ export const AdvisorsTable = ({ ariaLabel={`Celular de ${row.name}`} formAction={updateProfileAction} /> - ) : phoneShown ? ( - ) : ( - - — - + )} diff --git a/src/components/campaign/shared/CampaignCopyableCell.tsx b/src/components/campaign/shared/CampaignCopyableCell.tsx new file mode 100644 index 00000000..9553f787 --- /dev/null +++ b/src/components/campaign/shared/CampaignCopyableCell.tsx @@ -0,0 +1,64 @@ +'use client' + +import { toast } from 'sonner' + +import { cn } from '@/lib/utils' + +/** Mirrors the inline input box (height, border, padding) so a future editable variant never reflows. */ +export const campaignReadCellClassName = + 'flex min-h-10 w-full items-center rounded-full border border-transparent px-2 text-left' + +const copyText = async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value) + toast.success(`${label} copiado.`) + } catch { + toast.error(`Não foi possível copiar o ${label.toLowerCase()}.`) + } +} + +type CampaignCopyableCellProps = { + /** Raw value copied to the clipboard. `null`/empty renders `emptyDisplay` instead of a button. */ + value: string | null + /** Used in the toast ("`label` copiado.") and the button's `aria-label`. */ + label: string + /** Transforms `value` for display only — the clipboard still gets the raw value (e.g. phone masking). */ + formatDisplay?: (value: string) => string + emptyDisplay?: string + className?: string +} + +/** + * Click-to-copy read cell for the campaign list system (`CampaignTable`). + * First promoted on B28 (2nd call site after `/campanha/assessores`'s inline + * copy — B19) so the copy/toast/empty-state behavior can't drift between the + * two tables. + */ +export const CampaignCopyableCell = ({ + value, + label, + formatDisplay, + emptyDisplay = '—', + className, +}: CampaignCopyableCellProps) => { + if (!value) { + return ( + + {emptyDisplay} + + ) + } + + const displayValue = formatDisplay ? formatDisplay(value) : value + + return ( + + ) +} diff --git a/src/lib/phone.ts b/src/lib/phone.ts index ed86421f..48c871b5 100644 --- a/src/lib/phone.ts +++ b/src/lib/phone.ts @@ -53,3 +53,9 @@ export const buildWhatsAppUrl = (phone: string, message?: string): string => { return url.toString() } + +/** `wa.me` href for a row's phone, or `null` when it doesn't normalize — the shared guard behind every per-row WhatsApp button in the campaign lists. */ +export const whatsAppHrefForPhone = (phone: string | null): string | null => { + if (!phone || !normalizeBrazilianPhone(phone)) return null + return buildWhatsAppUrl(phone) +} diff --git a/src/utilities/leadershipData.ts b/src/utilities/leadershipData.ts index 493e75e8..6bd46dae 100644 --- a/src/utilities/leadershipData.ts +++ b/src/utilities/leadershipData.ts @@ -23,6 +23,7 @@ export type LeadershipRowViewModel = { contactID: number name: string phone: string | null + email: string | null supportStatus: SupportStatus | null sector: string | null municipalityNames: string[] @@ -33,17 +34,18 @@ export type LeadershipRowViewModel = { updatedAt: string } -const contactNameAndPhone = ( +const contactSummary = ( contact: Leadership['contact'], -): { id: number; name: string; phone: string | null } => { +): { id: number; name: string; phone: string | null; email: string | null } => { if (typeof contact === 'object' && contact !== null) { return { id: contact.id, name: contact.name ?? 'Contato', - phone: (contact as { phone?: string | null }).phone ?? null, + phone: contact.phone ?? null, + email: contact.email ?? null, } } - return { id: Number(contact), name: 'Contato', phone: null } + return { id: Number(contact), name: 'Contato', phone: null, email: null } } const namesForIds = async ( @@ -95,12 +97,13 @@ const toLeadershipRows = async ( ]) return docs.map((doc) => { - const contact = contactNameAndPhone(doc.contact) + const contact = contactSummary(doc.contact) return { id: doc.id, contactID: contact.id, name: contact.name, phone: contact.phone, + email: contact.email, supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null, sector: doc.sector ?? null, municipalityNames: (doc.municipalities ?? []) @@ -237,7 +240,6 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & { organizationIDs: number[] stateDeputyIDs: number[] stateDeputies: StateDeputySummary[] - email: string | null sectorNotes: string | null notes: string | null consentNote: string | null @@ -263,12 +265,6 @@ export const loadLeadershipDetail = async ( const [row] = await toLeadershipRows(payload, [doc]) if (!row) return null - const contact = doc.contact - const email = - typeof contact === 'object' && contact !== null - ? ((contact as { email?: string | null }).email ?? null) - : null - const stateDeputyIDs = (doc.stateDeputies ?? []) .map(relationshipId) .filter((id): id is number => id !== null) @@ -284,7 +280,6 @@ export const loadLeadershipDetail = async ( .filter((id): id is number => id !== null), stateDeputyIDs, stateDeputies, - email, sectorNotes: doc.sectorNotes ?? null, notes: doc.notes ?? null, consentNote: doc.consentNote ?? null, From 7cafc2297b003e39dfbaae88b2954a9f61f9e8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 15:32:57 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(B28):=20registra=20d=C3=A9bitos=20do?= =?UTF-8?q?=20/simplify=20no=20plano-pai=20(adiado=20com=20gatilho)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - botão WhatsApp por linha duplicado (2 call sites) — gatilho: 3º site - copyText vs copyMessage em SupporterShareKit — gatilho: 3º site de copy-to-clipboard --- docs/plans/email-celular-lista-liderancas.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plans/email-celular-lista-liderancas.md b/docs/plans/email-celular-lista-liderancas.md index 9a4a9fd9..51210ee0 100644 --- a/docs/plans/email-celular-lista-liderancas.md +++ b/docs/plans/email-celular-lista-liderancas.md @@ -122,6 +122,8 @@ Depth check: reusa `CampaignTable`, `phone` helpers, padrão visual do B19; extr - **Editar e-mail/celular da liderança (detalhe e/ou lista com debounce).** Revisitar quando: (1) produto pedir correção de contato sem `/admin`, **ou** (2) E4R/name-only seed gerar volume de fichas sem telefone que o time precise completar em massa na lista. Entrega mínima futura: action `updateLeadershipContact` + campos na ficha interna; lista in-place só se o 2º gesto se repetir. - **Incluir e-mail/telefone na busca `q`.** Revisitar quando: ≥1 feedback de uso pedindo achar liderança pelo número. +- **Achado do `/simplify` (2026-07-26): botão de ação WhatsApp por linha duplicado verbatim entre `liderancas/page.tsx` e `AdvisorsTable.tsx`** (mesma estrutura `Button`/``/disabled — só o `whatsAppHrefForPhone` foi promovido a `lib/phone.ts`, não o JSX do botão). Só 2 call sites hoje — extrair um `CampaignWhatsAppIconButton` agora seria abstração prematura (padrão do repo: 3+ call sites). Revisitar quando um 3º botão de WhatsApp por linha aparecer (candidato natural: `/campanha/apoiadores`). +- **Achado do `/simplify` (2026-07-26): `copyText` (`CampaignCopyableCell.tsx`) duplica a forma de `copyMessage` em `SupporterShareKit.tsx`** (clipboard write + toast sucesso/erro; formatos de UI diferentes — célula vs. botão com label toggle). Pré-existente, não introduzido por este item. Revisitar quando um 3º site de copy-to-clipboard-com-toast aparecer — aí sim vale um `copyToClipboard(value, { successMessage, errorMessage })` em `src/lib/`. ## Referências