mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Docs: registra B20–B37, C13 (Atividade) e fill-ins de listas no roadmap e notebook.
Inclui 19 planos de produto (municípios, lideranças, dobradinhas, territórios) e alinha E12/B21 com camada-territorios-identidade.
This commit is contained in:
parent
c00f83d870
commit
288a9265d7
24 changed files with 2851 additions and 7 deletions
|
|
@ -7,6 +7,11 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **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).
|
||||
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.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).
|
||||
- **B20 registrado (2026-07-25):** Municípios prioritários no Início (`/campanha`) — a seção hoje faz `.slice(0, 6)` sem sort sobre ~50 `priority=alta` (E4R) e renderiza `<ul>` com bullets. Plano: atalho das 8 com maior déficit de cobertura (mesmo critério E9/`central`) + contagem + “Ver todas” → `?priority=alta`; markup `list-none`. Appetite ~0,5d; Janela 1; cortável; Impeccable B. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.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.
|
||||
- **Correções e débitos pós-B16 (2026-07-25):** a revisão paralela achou três defeitos funcionais, **corrigidos na própria entrega**: as facetas omitiam só o param multi, então "Prioritária" escondia os não-prioritários do popover de Município e "Sem assessor" esvaziava o de Assessores (cada faceta passou a omitir o grupo do seu popover — `slugs`+`priority`, `advisors`+`coverage`, com int test); "todas as tendências" tinha duas codificações (as três marcadas deixavam `trend=` inútil na URL com o funil inativo — agora `parseMunicipalityListParams` canonicaliza o conjunto completo para ausente e `narrowingMunicipalityTrends` foi deletado); e o Território havia sumido do mobile ao virar multi (voltou como `MobileMultiFilterField` alimentado pela faceta, via novo prop `regionFilterOptions`). O `/simplify` da entrega (quality / performance / reuse em paralelo) aplicou o cleanup barato no próprio branch — setter único sobre params crus em `municipalityUi.ts` (fim de três casts e da escada de `if`), resumo de filtros de três funções para uma, remoção do array `municipalitySlugFilterOptions` (435 objetos que iam no bundle sem leitor), linha de opção única no Popover, facetas reusando o escopo já carregado e `getEligibleAdvisorOptions` de volta a coordenador-only. O que sobrou é maior que cleanup e virou o fill-in **B16+**: `useOptimistic` no lugar do par `{baseKey,next}` manual, payload dos rótulos de município (~20 KB por navegação) e hrefs do Popover a partir de uma base `URLSearchParams` em vez de duas serializações por opção — [`escala-dry-pos-b16.md`](../../../docs/plans/escala-dry-pos-b16.md). Adiados com gatilho no plano: gêmeo `MunicipalityFilterHead` (3º header com filtro), `ui/Command` no Popover (2º popover de busca), predicado de busca unificado, multi-seleção mobile honesta. Fora do `/campanha`: o `next build` inteiro morre quando o global `metadata` vem vazio (`/abaixo-assinado/[id]` chama `stripTrailingSlash` no `undefined`) — registrado em "Site público" no roadmap.
|
||||
- **B16 entregue em código (2026-07-25):** filtros no header das colunas em `/campanha/municipios` — funil + Popover (`MunicipalityHeaderFilter`) ao lado do sort do B15, com multi-seleção OR em Território (`region`), Município (`slug`, com busca acento-insensível e o check "Prioritária" no mesmo popover), Assessores (`advisor`, que também hospeda o toggle com/sem assessor) e Tendência (`trend`); "Limpar" nos multi grandes; mobile mantém os selects empilhados. Header sticky resolvendo contra o scroller do `<main>` (sem scroller interno na tabela) e empty state que troca só as linhas de conteúdo — visão geral e linha de filtros continuam montadas, senão o usuário fica preso em zero resultados. Facetas cross-filtradas (as opções de um filtro respeitam os demais, mantendo o que já está selecionado visível) reaproveitando o escopo já carregado em `loadMunicipalityListFilterFacets`. A pedido do usuário, a coluna "Assessoria" saiu: a ordenação `coverage` passou para "Assessores", o filtro com/sem assessor virou opção do popover, e o alerta "sem responsável" do E9 (prioridade alta sem ninguém) migrou para a célula de assessores, onde antes só haveria um "Sem assessor" apagado. Plano: [`filtros-no-header-lista-municipios.md`](../../../docs/plans/filtros-no-header-lista-municipios.md).
|
||||
|
|
@ -92,6 +97,9 @@ alwaysApply: false
|
|||
|
||||
## Decisões
|
||||
|
||||
- Em 2026-07-25, **coluna de cobertura da meta = "Cobertura" (só na lista):** header curto na fila (padrão A11); definição no tooltip B22 / glossário E18. Soft B16 (sem "Assessoria", o nome curto não ambigua). Strips/card/conceitos mantêm "Cobertura da meta". **Rejeitado:** B26 de trilha; varredura global da string; absorver só em B22.
|
||||
- Em 2026-07-25, **lista de municípios sem coluna Tipo:** Município e Zona eleitoral são a mesma unidade operacional na coordenação; a coluna/filtro/sort `kind` na lista não desbloqueia decisão. Path para ZE de Salvador = multi-select/busca de slugs. Schema `kind` permanece (geografia). **Rejeitado:** B24 de trilha; hide via B17 em vez de remover; drop do campo no modelo; manter `?kind=` zumbi na URL.
|
||||
- Em 2026-07-25, **B20 — Início = atalho das prioritárias, não inventário:** a seção “Municípios prioritários” no dashboard mostra amostra (≥8) ordenada por déficit de cobertura (`central`, E9) + contagem + CTA para `?priority=alta`; não lista os ~50 chips nem mente com `.slice(0, 6)` na ordem do escopo. Markup sem bullets (`list-none`, precedente assessores/recentes). **Rejeitado:** inventário completo no Início; KPI só-número sem nomes; segunda fila “só sem assessor” (já é E9 coluna da vergonha).
|
||||
- Em 2026-07-24, **glossário de inteligência (E18) mora em código e é staff-only:** as explicações são 1:1 com as fórmulas de `municipalityPotential.ts`/`goalCoverage.ts`, então vivem num módulo `src/lib` revisado no mesmo PR que muda a fórmula; toda a lista descreve campos que a liderança nunca vê, então a rota é `isCampaignStaff`. Descoberta por link contextual nos tooltips que já expõem os números, não por item na `staffNav`. **Rejeitado:** collection Payload com richText (cópia dessincroniza da fórmula sem teste nenhum pegar); leitura liberada a todo `campaignUser`; KaTeX (fórmula em texto basta para staff de campo).
|
||||
- Em 2026-07-24, **B19 — Candidato designa assessores e vê e-mails:** `isCampaignUnrestricted` passa a cobrir update de `campaignUser` de terceiros, phone create/update, e atribuição `municipality.advisors` (field access + action). Lista/detalhe de assessores leem e-mail com `overrideAccess: true` após o gate (job: ativar `@planilha.invalid`). **Rejeitado:** candidate só-leitura na carteira; ocultar e-mail.
|
||||
- Em 2026-07-24, **header da coluna A11 = `2022` (não "Concentração 2022"):** o título longo competia com células curtas (`%` + `12º`); no contexto da mesa o ano basta. Definição da métrica fica no hover do header (`formatMunicipalityConcentrationHint`). **Rejeitado:** manter "Concentração" no rótulo visível.
|
||||
|
|
|
|||
128
docs/plans/autosave-status-lista-liderancas.md
Normal file
128
docs/plans/autosave-status-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Auto-save do Status de apoio na lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B32)
|
||||
Impeccable: B — encaixe em `LeadershipsPage` / `CampaignTable` de `/campanha/liderancas` (coluna "Status"); sem rota nova de UI
|
||||
Appetite: ~0,4–0,5 dia eng; 1 componente novo + 1 route handler JSON espelhando `expected-votes/`; extração do guard CSRF compartilhado; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button**, princípio 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `.cursor/rules/campanha-action-feedback.mdc` · precedente vivo: [`MunicipalityListExpectedVotesControl.tsx`](../../src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx) (máquina de auto-save com rollback) e o [`MunicipalityListTrendControl.tsx`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) atual (Popover + select — o que este item **não** deve repetir é o botão "Salvar" dele, hoje ainda sem o passe do B24).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (sem shape — é um único select sobre um campo que já existe, na mesma família visual dos outros popovers de lista).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral / Assessor) varrendo `/campanha/liderancas` durante o onboarding, marcando quem já engajou ou quem ainda precisa ser abordado, uma linha após a outra.
|
||||
- **Job principal:** trocar o Status de apoio sem sair da lista, sem clicar em um botão "Editar" da tela inteira antes, e sem confirmar a gravação — o clique na célula **é** a edição.
|
||||
- **Estratégia de cor:** Restrained (inalterada) — a única cor da superfície continua sendo a `SupportStatusBadge`.
|
||||
- **Edit where you see:** sim — a affordance já está no contexto (a célula já mostra o Badge); falta só tornar essa mesma célula editável, sem gate de "modo de edição" na tela.
|
||||
- **Anti-goals:** botão "Salvar" no popover; **modo de edição** da tela inteira (padrão `AdvisorsTable`/B19 — pedido explícito do produto para **não** repetir aqui); spreadsheet/data-grid mode; editar qualquer outro campo da liderança nesta célula (Setor, Observações, Municípios continuam em `/liderancas/[id]`).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — o item é affordance de **escrita** sobre um campo que já existe e já é lido na lista (`leadership.supportStatus`, hoje um `Badge` estático). Nenhuma métrica, contagem, série ou ranking novo; a leitura da coluna "Status" (badge, e o futuro filtro/sort do B29) não muda.
|
||||
|
||||
## Contexto
|
||||
|
||||
Em `/campanha/liderancas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>), linhas 49–54), a coluna "Status" é **somente leitura**: `row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'`. O campo em si já é editável em outro lugar — `LeadershipInternalForm` ([`LeadershipInternalForm.tsx`](../../src/components/campaign/leadership/LeadershipInternalForm.tsx), linhas 108–123) tem um `NativeSelect` de `supportStatus` dentro do formulário multi-campo de `/campanha/liderancas/[id]` (municípios, organizações, dobradinhas, setor, status, observações, consentimento — tudo com um único botão "Salvar", correto para um formulário longo). Editar apenas o status hoje exige abrir a ficha inteira.
|
||||
|
||||
O campo já está pronto para escrita pontual: `leadershipInternalUpdateSchema` ([`schemas/leadership.ts`](../../src/lib/schemas/leadership.ts), linha 79) trata `supportStatus` como **opcional** — enviar só `{ id, supportStatus }` não toca municípios/organizações/dobradinhas/notas. `updateLeadershipInternalRecord` ([`actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>), linhas 163–197) já faz o `findByID` de escopo (o registro precisa estar no alcance do ator) e o `payload.update` com `user`/`overrideAccess: false`; o access de campo (`canManageCampaignStaffField` em `Leadership.ts`, linha 143) já restringe quem pode escrever o status.
|
||||
|
||||
Dois precedentes vizinhos mostram os dois caminhos possíveis e por que um serve melhor aqui:
|
||||
|
||||
1. **`/campanha/municipios`** (B9 → B24 em rascunho): cada célula editável é um Popover sempre montado, sem "modo de edição" — `MunicipalityListExpectedVotesControl` já grava por debounce + endpoint JSON dedicado, sem `revalidatePath`, com rollback e live region; `MunicipalityListTrendControl` ainda usa `<form>` + botão "Salvar" (o alvo do B24, ainda não implementado).
|
||||
2. **`/campanha/assessores`** (B19): `AdvisorsTable` só revela células editáveis depois que o coordenador clica em "Editar" (um botão que alterna `editing` para a tabela inteira) — é o padrão que o pedido de produto (2026-07-25) nomeia explicitamente para **não** repetir aqui ("não precisa estar com o modo de edição ativado").
|
||||
|
||||
Pedido de produto (2026-07-25): editar o Status de apoio clicando na própria célula, com auto-save e sem o gate de "modo de edição". Isso é exatamente o gatilho que o **B29** ([ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md), seção "Adiado com gatilho") já havia registrado: _"Edição rápida de `supportStatus` na célula (Popover + auto-save, padrão B9/B24). Revisitar quando: a mesa reportar que abre a ficha só para mudar o status, ou ≥1 pedido em sessão/R6."_ — o pedido chegou.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Clicar na célula "Status" de `/campanha/liderancas` abre um controle de edição (Popover + select) **sempre disponível**, sem depender de nenhum toggle de "modo de edição" da tela.
|
||||
- Trocar o valor grava sozinho — sem botão "Salvar", sem confirmação.
|
||||
- Feedback honesto na própria célula (princípio 8): pendente (`aria-busy` + spinner), erro com mensagem + rollback para o último valor confirmado, live region `sr-only`.
|
||||
- Guardrails: **sem migration**, sem collection, sem Consent; access inalterado (mesma `updateLeadershipInternal`, `overrideAccess: false`, `canManageCampaignStaffField`); `leader` continua sem acesso à rota (`isCampaignStaff` já bloqueia); `/liderancas/[id]` continua com submit explícito (formulário multi-campo).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Popover + `NativeSelect` sempre montado na célula, sem gate de "modo de edição".** Mirroring `MunicipalityListExpectedVotesControl` (máquina de estado com rollback/live region), não `AdvisorsTable` (toggle "Editar" da tabela inteira). Fonte: pedido de produto 2026-07-25, que nomeia explicitamente o anti-goal ("não precisa estar com o modo de edição ativado"). **Rejeitado:** reproduzir o toggle "Editar" do B19/`AdvisorsTable` (é literalmente o padrão que o pedido pede para não usar); select inline sem Popover, no estilo `AdvisorDebouncedTextCell` (perde a chrome de spinner/erro/live region já padronizada nos popovers de lista; e cores de Badge dentro de um `<select>` nativo pedem CSS frágil sem ganho real, já que o job é só um clique a mais para abrir o Popover); reusar `LeadershipInternalForm` inteiro num Popover (expõe Municípios/Organizações/Dobradinhas/Notas fora de contexto e mantém o botão "Salvar" — anti-goal do princípio 4).
|
||||
- **Gravação por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`), espelhando `expected-votes/route.ts` — não por `<form action>`/`revalidatePath`.** O form action de `/liderancas/[id]` hoje faz `revalidatePath('/campanha/liderancas/[id]', 'page')`; estendê-lo para a lista exigiria revalidar as 25 linhas da página a cada clique, e — quando o **B29** trouxer filtro por Status — a linha poderia desaparecer sob o filtro ativo no meio da edição (mesmo raciocínio do B24 para Tendência). **Rejeitado:** estender `updateLeadershipInternalFormAction` com `revalidatePath('/campanha/liderancas', 'page')` (custo + linha some sob filtro futuro); chamar a action imperativamente + `router.refresh()`, como `AdvisorDebouncedTextCell` (funciona lá porque a lista de assessores é curta; a de lideranças cresce com a base nominal real da C2).
|
||||
- **Extrair `isSameOriginRequest` para um helper compartilhado agora.** Hoje vive privado em `expected-votes/route.ts`; este item cria o **segundo** endpoint JSON da campanha, e o próprio plano do B24 já havia registrado essa extração como devida "com o segundo endpoint" (política de segurança nomeada mesmo com 2 call sites — exceção explícita ao "3+ call sites" do `engineering-standards`). Se o B24 for implementado primeiro, este item só reusa o helper já extraído. **Rejeitado:** duplicar a função no novo route (diverge no 3º endpoint, que é só questão de tempo nesta família).
|
||||
- **Sem revalidação/refresh após gravar.** O Badge exibido é local (otimista no controle, confirmado pela resposta do endpoint); qualquer sort/filtro futuro por Status (B29) só reconcilia na próxima navegação — mesma escolha já adotada para votos estimados e (em rascunho) para Tendência. **Rejeitado:** `revalidatePath`/`router.refresh()` por gravação (custo por clique e risco de reordenar a página sob o coordenador no meio da varredura).
|
||||
- **Mensagens de erro compartilhadas entre o form action existente e o novo route.** `updateLeadershipInternalFormAction` hoje define `safeMessages` inline; este item extrai para `src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts` (mesmo padrão de `municipalityStaffEditMessages.ts`), consumido pelos dois. **Rejeitado:** duplicar o array de mensagens no route novo.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês — rota `support-status`, `LeadershipListSupportStatusResponse`, `LeadershipListSupportStatusControl`; strings visíveis em pt-BR ("Status de apoio", "Salvando…", "Não foi possível salvar o status.").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Cadência do auto-save no `onChange` do select?** **Opções:** A) grava imediatamente, sem debounce; B) ~150 ms para coalescer trocas rápidas (mesma cadência que o B24 propõe para a troca de `status` da Tendência); C) 600 ms (cadência de texto livre). **Recomendação:** B — um select de 4 opções não precisa da folga de texto livre, mas uma coalescência mínima evita disparar duas gravações se o usuário navegar as opções por teclado rapidamente.
|
||||
- **O Popover fecha sozinho depois de salvar?** **Opções:** A) permanece aberto até clique fora/Escape (padrão Radix); B) fecha automaticamente ~600 ms após confirmar. **Recomendação:** A — não há um segundo campo a preencher depois da troca (diferente do B24, que tem justificativa em texto livre), então o comportamento padrão do Popover já é suficiente e evita acoplar `open` ao resultado da gravação (o mesmo bug que o B24 evita).
|
||||
- **Card mobile precisa de um controle separado?** **Opções:** A) não — `/campanha/liderancas` não tem variante de cards (só tabela, ao contrário de `/campanha/municipios`); a mesma célula funciona com toque (`min-h-11`); B) criar um card mobile agora. **Recomendação:** A — confirmado no código atual (`page.tsx` usa só `CampaignTable`), então não há uma segunda superfície a espelhar.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cell["Célula Status<br/>(coluna supportStatus)"] --> pop["Popover:<br/>select Status de apoio"]
|
||||
pop -->|"onChange"| deb["scheduleSave<br/>(~150ms, cancela anterior)"]
|
||||
deb --> post["POST /campanha/liderancas/support-status"]
|
||||
post --> act["updateLeadershipInternal<br/>({ id, supportStatus }, overrideAccess: false)"]
|
||||
act --> resp["{ supportStatus } confirmado"]
|
||||
resp --> cell
|
||||
post -.erro.-> rollback["rollback + Alert + live region"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx`** (novo, client): Popover cujo trigger é o `SupportStatusBadge` atual; conteúdo = `NativeSelect` com as 4 opções de `leadershipSupportStatuses`. Espelha a máquina de `MunicipalityListExpectedVotesControl` (`draft`/`displayStatus`/`committedRef`/`saveGenerationRef`/`abortRef`/`saveTimeoutRef`), simplificada para um único valor enum (sem os múltiplos cenários de votos): `onChange` chama `scheduleSave` (debounce ~150 ms), erro faz rollback para `committedRef.current` + `Alert` + `sr-only aria-live`, sem `Button`/submit.
|
||||
- **`src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts` + `types.ts`** (novos): `POST` com o guard de mesma origem compartilhado, `zod` (`leadershipId: positiveRelationshipId`, `supportStatus: z.enum(leadershipSupportStatuses)`), chamando `updateLeadershipInternal({ id: leadershipId, supportStatus })` e devolvendo `{ status: 'success', message, savedSupportStatus }`; erros via `mapCampaignFormActionError` + `leadershipStaffEditMessages`, `401` em sessão expirada — cópia fiel do contrato de `expected-votes/route.ts` e `types.ts`.
|
||||
- **`src/utilities/sameOriginRequest.ts`** (novo): `isSameOriginRequest` extraído de `expected-votes/route.ts`; os dois routes passam a importar daqui.
|
||||
- **`src/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages.ts`** (novo): `leadershipStaffEditMessages` (o array `safeMessages` hoje inline em `formActions.ts`), consumido pelo form action existente e pelo route novo.
|
||||
- **`liderancas/page.tsx`**: a célula da coluna `supportStatus` passa de `row.supportStatus ? <SupportStatusBadge .../> : '—'` para `<LeadershipListSupportStatusControl leadershipID={row.id} status={row.supportStatus} />` (o controle já trata o caso "Não registrada" internamente, já que `supportStatus` é `required` com `defaultValue: 'a_abordar'` no schema — na prática nunca chega nulo pela lista, mas o componente aceita `null` para não quebrar a leitura existente).
|
||||
- **Depth check:** nenhum `useAutosave`/`EditableCell` genérico — dois controles com máquinas parecidas (este + o futuro B24) continuam nomeados; extração só no 3º call site (ver _Adiado com gatilho_).
|
||||
- **Migration:** nenhuma — `leadership.supportStatus` já existe em `src/collections/Leadership.ts`.
|
||||
- **Testes:** unit do parse/erro do novo route handler (molde do que já existiria para `expected-votes`); e2e leve em `tests/e2e/` — abrir a célula, trocar o status, esperar o estado "salvo" **sem** clicar em botão e reabrir a lista para confirmar persistência (sem `revalidatePath`, então a confirmação é via reload da página).
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Reusa `updateLeadershipInternal` ([`actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>)), `leadershipInternalUpdateSchema`/`leadershipSupportStatuses` ([`schemas/leadership.ts`](../../src/lib/schemas/leadership.ts)), `SupportStatusBadge` ([`SupportStatusBadge.tsx`](../../src/components/campaign/leadership/SupportStatusBadge.tsx)) e o padrão de endpoint de `expected-votes/`.
|
||||
- **Suave:** **B24** ([autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md)) — mesma família de decisão (Popover sem "Salvar", endpoint JSON dedicado, extração do guard CSRF); a ordem de chegada só define quem extrai o helper primeiro. **B29** ([ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md)) — registrou este item em "Adiado com gatilho" e o gatilho disparou; se o B29 chegar depois, sua ordenação/filtro por Status passa a reconciliar sobre o valor gravado aqui na próxima navegação, sem trabalho extra. **B31** ([dobradinhas-lista-liderancas.md](dobradinhas-lista-liderancas.md)) — mesma tabela e mesmo anti-goal nomeado ("não é o modo Editar" de tabela inteira); célula vizinha, sem acoplamento de dado. **B19 ✓** — é o padrão explicitamente **rejeitado** (não uma dependência de reuso).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Modo de edição da tela inteira (toggle "Editar", padrão `AdvisorsTable`/B19).** Anti-goal nomeado no pedido de produto — este item nunca depende dele.
|
||||
- **Editar Setor, Observações, Municípios, Organizações ou Dobradinhas na lista.** Continuam em `/campanha/liderancas/[id]` (`LeadershipInternalForm`), formulário multi-campo com submit explícito.
|
||||
- **Ordenar/filtrar por Status na lista** → **B29** ([ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md)).
|
||||
- **Justificativa/nota ao trocar o status.** O campo `supportStatus` não tem nota associada (diferente de `politicalTrend.note` do B24); não inventar uma aqui.
|
||||
- **Bulk edit de status em várias lideranças de uma vez.** Sem pedido, sem evidência.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que são dois popovers parecidos (este + B24), extrai o hook genérico de auto-save."** Nasceria um `useCampaignAutosave`/`EditableCell` que precisa acomodar select, número por cenário e (no B24) texto livre — vira design system paralelo. **Mitigação:** dois controles nomeados; extração só no 3º call site, como já registrado no plano do B24.
|
||||
- **"Aproveita e já faz a lista atualizar/ordenar sozinha."** Puxa revalidação e reordenação viva. **Mitigação:** decisão travada de não revalidar.
|
||||
- **"Já que dá pra editar status na célula, edita setor/observações também."** Explode o appetite e reabre o formulário multi-campo dentro de um popover de lista. **Mitigação:** Não escopo explícito.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Hook/shell compartilhado de auto-save em popover de lista.** Revisitar quando: 3º controle com a mesma máquina (candidatos: este, o B24 de Tendência, e qualquer popover futuro de campo único).
|
||||
- **Filtro por Status reconciliando em tempo real.** Revisitar quando: o B29 existir e houver reclamação real de linha "presa" sob filtro de status.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, B32; grafo; Janela 1; cortes seguros)
|
||||
- `src/app/(campaign)/campanha/(app)/liderancas/page.tsx` — célula alvo (coluna `supportStatus`, linhas 49–54)
|
||||
- `src/components/campaign/leadership/SupportStatusBadge.tsx` — presentation do Badge atual
|
||||
- `src/components/campaign/leadership/LeadershipInternalForm.tsx` — formulário multi-campo que permanece com submit explícito
|
||||
- `src/app/(campaign)/campanha/actions/leadership.ts` — `updateLeadershipInternal`/`updateLeadershipInternalRecord`
|
||||
- `src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts` — form action existente (fonte do array `safeMessages` a extrair)
|
||||
- `src/lib/schemas/leadership.ts` — `leadershipSupportStatuses`, `leadershipInternalUpdateSchema`
|
||||
- `src/collections/Leadership.ts` — campo `supportStatus` + access `canManageCampaignStaffField`
|
||||
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` — precedente completo de auto-save + rollback + live region
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts` e `types.ts` — contrato do endpoint a espelhar (inclui `isSameOriginRequest` a extrair)
|
||||
- `src/components/campaign/advisor/AdvisorsTable.tsx` — o padrão de "modo de edição" explicitamente rejeitado
|
||||
- `docs/plans/ordenacao-filtros-lista-liderancas.md` — B29 (origem do "Adiado com gatilho" que este item fecha)
|
||||
- `docs/plans/autosave-tendencia-lista-municipios.md` — B24 (mesma família de decisão, em rascunho)
|
||||
- `docs/plans/dobradinhas-lista-liderancas.md` — B31 (mesma tabela; mesmo anti-goal do "modo de edição")
|
||||
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
|
||||
125
docs/plans/autosave-tendencia-lista-municipios.md
Normal file
125
docs/plans/autosave-tendencia-lista-municipios.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Auto-save e justificativa no popover de Tendência da lista de municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B24)
|
||||
Impeccable: B — encaixe em `MunicipalityListTrendControl` (tabela desktop + card mobile de `/campanha/municipios`); sem rota nova de UI
|
||||
Appetite: ~0,5 dia eng; 1 componente reescrito + 1 route handler JSON espelhando o de votos estimados; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button**, princípio 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `.cursor/rules/campanha-action-feedback.mdc` · precedente vivo: [`MunicipalityListExpectedVotesControl.tsx`](../../src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (sem shape — o popover já existe e o padrão de auto-save já está no produto ao lado, na mesma linha da tabela).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral / Assessor) varrendo a lista de 435 municípios durante o onboarding e as convenções; anota conjuntura de vários municípios em sequência, no desktop e no celular.
|
||||
- **Job principal:** marcar a tendência **e dizer por quê** sem sair da lista e sem procurar um botão — a célula ao lado (votos estimados) já grava sozinha, esta finge que precisa de confirmação.
|
||||
- **Estratégia de cor:** Restrained (inalterada) — a única cor da superfície continua sendo a `Badge` de tendência.
|
||||
- **Edit where you see:** sim — a affordance já está no contexto; o que falta é o princípio 4 (o "Salvar" é justamente o anti-goal nomeado) e o campo de justificativa, hoje só editável em `/campanha/municipios/[slug]/editar`.
|
||||
- **Anti-goals:** botão "Salvar" no popover; textarea gravando a cada tecla; spreadsheet/data-grid mode; segundo sistema de célula editável genérica; popover que fecha sozinho no meio da digitação.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não. `Dados: N/A` — o item é affordance de **escrita** sobre campos que já existem (`municipality.politicalTrend.status` / `.note`); nenhuma métrica, série, ranking ou mapa novo. A leitura da tendência na lista (Badge + filtro/sort do B15/B16) permanece exatamente como está.
|
||||
- **Efeito colateral de leitura a vigiar:** o **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)) já vai **exibir** essa justificativa (hover na célula; em touch, como leitura dentro do próprio Popover). Este item cuida só da **escrita** — se o B23 vier primeiro, aquela leitura read-only no Popover é substituída pelo textarea daqui (e o tooltip continua valendo); se este vier primeiro, o B23 só pluga o `cellTooltip`. Nenhum dos dois bloqueia o outro.
|
||||
|
||||
## Contexto
|
||||
|
||||
Em `/campanha/municipios`, a coluna "Tendência" é editável desde o **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)): [`MunicipalityListTrendControl`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) abre um `Popover` com um `NativeSelect` de status e um **botão "Salvar"**, submetendo via `useActionState` → [`setMunicipalityPoliticalTrendFormAction`](<../../src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts>) → `setMunicipalityPoliticalTrend` + `revalidateMunicipalityListPaths`. A justificativa (`politicalTrend.note`) viaja no popover como **hidden input** (só para não ser apagada) e só é editável no formulário longo `MunicipalityStrategyForm` (`/campanha/municipios/[slug]/editar`).
|
||||
|
||||
Duas coisas envelheceram desde o B9:
|
||||
|
||||
1. `PRODUCT.md` ganhou o princípio 4 ("Auto-save, no Save button", anti-goal literal: _"Save buttons on Popovers and quick-edit cells 'just in case'"_), e a célula vizinha da **mesma linha** já cumpre esse padrão: `MunicipalityListExpectedVotesControl` grava com debounce de 600 ms via `POST /campanha/municipios/expected-votes`, sem botão, com spinner + live region + rollback em erro. Duas células adjacentes com contratos de gravação diferentes é exatamente o tipo de incoerência que a lista não pode ter sob pressão de campo.
|
||||
2. O próprio B9 registrou **"Nota de tendência no popover — gatilho: feedback de uso"** em _Adiado com gatilho_. O gatilho disparou: pedido de produto em **2026-07-25** (mesma sessão que originou B22/B23).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- O popover de Tendência **não tem botão "Salvar"**: a troca de status grava sozinha e a justificativa grava com debounce (timer anterior cancelado a cada digitação).
|
||||
- A justificativa (`politicalTrend.note`, máx. 2000 caracteres) é editável no popover, com o valor atual pré-carregado — deixa de ser hidden input.
|
||||
- Feedback honesto na própria célula (princípio 8): pendente (`aria-busy` + spinner), erro com mensagem + rollback para o último valor confirmado, live region `sr-only`.
|
||||
- Ao fechar o popover, qualquer rascunho pendente é gravado (flush) antes de a UI descartar o estado — nada se perde por fechar rápido.
|
||||
- Access inalterado: mesma action `setMunicipalityPoliticalTrend` (`overrideAccess: false`, `canManageCampaignStaffField`); `leader` continua sem a coluna.
|
||||
- Guardrails: **sem migration**, sem collection, sem Consent; `/editar` continua com submit explícito (form multi-campo).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Gravação por endpoint JSON dedicado (`POST /campanha/municipios/political-trend`), espelhando o de votos estimados** — não por `<form action>` com server action. O form action atual chama `revalidateMunicipalityListPaths`, ou seja, cada auto-save recomputaria a lista inteira de 435 municípios no meio da digitação; e `useActionState` no `<form>` traz junto o par toast + `setOpen(false)`, que sob auto-save fecharia o popover a cada debounce. **Rejeitado:** (a) manter o form action e só trocar o submit por debounce — mantém a revalidação por gravação e o fechamento automático; (b) chamar o server action imperativamente + `router.refresh()`, como faz [`AdvisorDebouncedTextCell`](../../src/components/campaign/advisor/AdvisorDebouncedTextCell.tsx) — funciona lá porque a lista de assessores é curta, aqui pagaria RSC de 435 linhas por gravação e as server actions serializam em fila.
|
||||
- **Sem revalidação/refresh imediato após gravar.** O valor exibido é local (otimista no controle, confirmado pela resposta do endpoint); ordenação por `trend`, filtro `?trend=` e contagens de facet só reconciliam na próxima navegação — mesma escolha já em produção no controle de votos estimados (que afeta `sort=expectedVotes`/`deficit` do mesmo jeito). Motivo: puxar a linha para fora da viewport enquanto o coordenador ainda está escrevendo a justificativa é pior que um facet momentaneamente defasado. **Rejeitado:** `revalidatePath` por gravação (custo + linha somindo sob filtro ativo); revalidação em cada fechamento de popover (mesma remoção de linha, só adiada em segundos).
|
||||
- **Duas cadências de debounce, um só timer por controle.** Troca de `status` (select) grava quase imediata (~150 ms, só para coalescer trocas seguidas de teclado); a justificativa usa o mesmo `AUTOSAVE_MS = 600` do controle de votos. O timer pendente é **sempre** cancelado antes de reagendar (pedido explícito do produto) e sofre flush no `blur` da textarea e no fechamento do popover; respostas fora de ordem são descartadas por `AbortController` + contador de geração, como no precedente. **Rejeitado:** gravar a cada keystroke (anti-goal nomeado no princípio 4); gravar só no fechamento (perde o texto se a aba morre e não dá feedback durante a escrita).
|
||||
- **Justificativa continua opcional e sem histórico.** Grava `status` e `note` juntos no mesmo payload (o hook `derivePoliticalTrendAudit` em `src/collections/Municipality.ts` já compara o snapshot `status\0note` e só re-carimba `recordedBy`/`recordedAt` quando algo muda de fato). **Rejeitado:** versionar a nota (o registro versionado do **C12** é de `votePledge`, e conjuntura não é decisão ex-ante); exigir justificativa ao mudar o status (fricção em varredura de 435 linhas).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês — rota `political-trend`, `MunicipalityListPoliticalTrendResponse`, `MunicipalityListTrendControl` (nome mantido); strings visíveis em pt-BR ("Tendência", "Justificativa", "Salvando…", "Não foi possível salvar a tendência.").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Reconciliar sort/filtro depois de editar?** **Opções:** A) nunca (só na próxima navegação); B) `router.refresh()` ao fechar o popover; C) refresh só quando a lista **não** está filtrada/ordenada por tendência. **Recomendação:** A, alinhada ao precedente de votos estimados — e, se o time reclamar de defasagem em uso real, subir para C (que é barata e não remove a linha embaixo de quem está trabalhando). Não implementar B.
|
||||
- **Contador de caracteres na justificativa?** **Opções:** A) nenhum; B) contador sempre; C) contador só a partir de ~90% dos 2000. **Recomendação:** A — 2000 caracteres é folga enorme para uma nota de conjuntura, e o `maxLength` do textarea já impede o estouro; C só se alguém realmente encostar no limite.
|
||||
- **O popover de Assessores (mesmo B9) também tem "Salvar" — entra junto?** **Opções:** A) não, item separado; B) sim, no mesmo passe. **Recomendação:** A — é multi-seleção (semântica de commit em lote, precedente de delta + advisory lock no B19) e dobraria o appetite; registrado em _Não escopo_ como fill-in próprio.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cell["Célula Tendência<br/>(tabela + card mobile)"] --> pop["Popover: select status<br/>+ textarea justificativa"]
|
||||
pop -->|"change / input"| deb["scheduleSave<br/>(cancela timer anterior)"]
|
||||
pop -->|"blur / close"| flush["flush imediato"]
|
||||
deb --> post["POST /campanha/municipios/political-trend"]
|
||||
flush --> post
|
||||
post --> act["setMunicipalityPoliticalTrend<br/>(overrideAccess: false)"]
|
||||
act --> resp["{ status, note } confirmados"]
|
||||
resp --> cell
|
||||
post -.erro.-> rollback["rollback + Alert + live region"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/MunicipalityListTrendControl.tsx`): deixa de receber `formAction`/`municipalitySlug` e passa a espelhar a máquina de estado do controle de votos — `draft` / `displayStatus` / `committedRef` / `saveGenerationRef` / `abortRef` / `saveTimeoutRef`, `onOpenChange` com flush, `Alert` de erro, `<p className="sr-only" aria-live="polite">`. Some o `<form>`, o `Button` "Salvar" e o hidden `trendNote`; entra `Textarea` (`src/components/ui/textarea`) rotulada "Justificativa" com `maxLength={2000}`.
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/political-trend/route.ts` + `types.ts`** (novos): `POST` com guarda de mesma origem, `zod` (`municipalityId`, `status` nullable enum, `note` nullable ≤2000) reusando `politicalTrendStatuses` / `trimmedNullableText` de `src/lib/schemas/municipality.ts`, chamando `setMunicipalityPoliticalTrend` e devolvendo `{ status: 'success', savedTrend: { status, note } }`; erros por `mapCampaignFormActionError` + `municipalityStaffEditSafeMessages`, `401` em sessão expirada — cópia fiel do contrato de `expected-votes/route.ts`.
|
||||
- **Guarda CSRF compartilhada** (`isSameOriginRequest`): hoje vive privada dentro de `expected-votes/route.ts`; com o segundo endpoint vira helper nomeado em `src/utilities/` (política de segurança vale nomear mesmo com 2 call sites — exceção explícita ao "3+ call sites" do `engineering-standards`). Não criar framework de rotas JSON de campanha.
|
||||
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): remove as props `trendFormAction`/`municipalitySlug` dos dois pontos de uso (coluna `trend` e card mobile).
|
||||
- **`municipios/page.tsx` + `municipalityStaffFormActions.ts`**: para de passar `setMunicipalityPoliticalTrendFormAction` para a lista. O form action **permanece** — `/[slug]/editar` continua usando (`MunicipalityStrategyForm`); só a lista deixa de consumi-lo. Rodar `pnpm exec knip` para confirmar que nada ficou órfão.
|
||||
- **Depth check:** nenhum `useAutosave` genérico nem `EditableCell` — dois controles com máquinas parecidas ainda são dois controles nomeados (ver _Adiado com gatilho_).
|
||||
- **Migration:** nenhuma — `politicalTrend.status` e `.note` já existem em `src/collections/Municipality.ts`.
|
||||
- **Testes:** unit do parse/erro do novo route handler (mesma linha do que existe para schemas de município); e2e leve em `tests/e2e/campaignMunicipalities.e2e.spec.ts` — abrir a célula, trocar status, digitar justificativa, esperar o estado "salvo" **sem** clicar em botão e reabrir para conferir persistência.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Reusa `setMunicipalityPoliticalTrend` (`src/app/(campaign)/campanha/actions/municipality.ts`), `municipalityPoliticalTrendSchema`, `politicalTrendLabels`/`politicalTrendBadgeVariant` (`src/utilities/municipalityLabels.ts`) e o padrão de endpoint de `expected-votes/`.
|
||||
- **Suave:** **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)) — só a ordem de chegada muda o encaixe (ver "Dados → decisão → apresentação"); nenhum bloqueia o outro, e ambos precisam de `disabled={open}` / `openOnTouch={false}` na célula para o tooltip não brigar com o Popover. **B9 ✓** (origem da célula e do adiado que este item fecha), **B16 ✓** (filtro `?trend=` no header, cuja defasagem é discutida acima), **E4R ✓** (semeou notas de tendência em massa a partir da planilha — são elas que a mesa vai querer corrigir na varredura).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Popover de Assessores sem botão "Salvar"** → fill-in próprio (multi-seleção pede delta/lock como no **B19 ✓**).
|
||||
- **Mostrar a justificativa no hover da célula** → **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)).
|
||||
- **Auto-save no formulário longo `/editar`** → continua submit explícito por decisão de produto (form multi-campo).
|
||||
- **Histórico/versionamento da tendência** → **C12 ✓** cobre registro ex-ante de decisões (`allocationDecision`), não conjuntura.
|
||||
- **Revalidação/facets em tempo real da lista** → questão em aberto acima; se virar trabalho, cai no mesmo lugar do débito de revalidação do controle de votos.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que são dois, extrai o hook genérico de auto-save."** Se alguém generalizar de passagem: nasce um `useCampaignAutosave` + `EditableCell` que precisa acomodar select, número por cenário, textarea, otimismo e rollback — vira design system paralelo (anti-goal do princípio 3). **Mitigação neste item:** dois controles nomeados; extração só no 3º call site (ver abaixo).
|
||||
- **"Aproveita e faz a lista atualizar sozinha."** Puxa revalidação, facets, reordenação e a linha somindo sob filtro ativo. **Mitigação:** decisão travada de não revalidar; opção C registrada como upgrade barato se houver reclamação real.
|
||||
- **"A justificativa merece rich text / anexos."** É textarea de 2000 caracteres num popover de lista. **Mitigação:** `Textarea` simples; qualquer coisa além disso é `/editar` ou `municipalityUpdate` (sinal tipado do C12).
|
||||
- **Popover que fecha ao gravar.** Herança do `useActionState` atual; sob auto-save é bug. **Mitigação:** `setOpen` deixa de ser efeito do resultado da gravação.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Hook/shell compartilhado de auto-save em popover de lista.** Revisitar quando: **3º** controle com a mesma máquina (candidatos: assessores na lista, prioridade, `budgetNotes`).
|
||||
- **Reconciliação de sort/filtro pós-edição (opção C).** Revisitar quando: alguém relatar em uso real linha "presa" no filtro de tendência errado.
|
||||
- **Contador de caracteres da justificativa.** Revisitar quando: houver nota real perto de 2000 caracteres.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, B24; grafo; Janela 1–2; cortes seguros)
|
||||
- `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` — superfície alvo
|
||||
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` — precedente completo de auto-save + rollback + live region
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts` e `types.ts` — contrato do endpoint a espelhar
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts` — form action que permanece para `/editar`
|
||||
- `src/app/(campaign)/campanha/actions/municipality.ts` — `setMunicipalityPoliticalTrend`
|
||||
- `src/collections/Municipality.ts` — grupo `politicalTrend` + hook `derivePoliticalTrendAudit`
|
||||
- `src/lib/schemas/municipality.ts` — `politicalTrendStatuses`, `municipalityPoliticalTrendSchema`
|
||||
- `docs/plans/edicao-rapida-lista-pracas.md` — B9 (origem da célula e do adiado "nota no popover")
|
||||
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Status: rascunho
|
||||
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E12; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
|
||||
Impeccable: B — agrupamento/rollup nas superfícies existentes (lista/overview, detalhe do município, mapa TI opcional); sem rota nova
|
||||
Impeccable: B — agrupamento/rollup nas superfícies existentes (lista/overview, detalhe do município, mapa TI opcional); sem rota nova **própria** — desde 2026-07-25 a superfície regional dedicada é **B21** (`/campanha/territorios`, [plano](pagina-territorios-identidade.md)), onde as métricas deste item pousam
|
||||
Appetite: ~1,5 dia eng; sem migration (mapeamento município→TI é estático)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ Componentes:
|
|||
|
||||
## Rabbit holes
|
||||
|
||||
- **Rollup virar segunda página de analytics.** É um agrupamento da lista + números no header — não um dashboard regional novo.
|
||||
- **Rollup virar segunda página de analytics.** É um agrupamento da lista + números no header — não um dashboard regional novo. _(Atualização 2026-07-25: a página regional existe como item próprio, **B21** — tabela comparativa com sort/filtro, sem cards de KPI. As métricas deste item entram como colunas lá; a proibição continua valendo contra transformá-la em dashboard.)_
|
||||
- **Zonas de Salvador no rollup do Metropolitano.** As 19 Municípios-zona têm `region` = Metropolitano; agregar por soma funciona, mas o breakdown obrigatório precisa listar zonas E municípios RMS separados — não "resolver" juntando tudo.
|
||||
- **Comparação entre TIs virar ranking público.** Mesmo risco de gaming do benchmark (T4-contraindicação): benchmark é para aprender mecanismo, não régua punitiva — copy da UI reflete isso.
|
||||
|
||||
|
|
|
|||
130
docs/plans/chips-liderancas-lista-dobradinhas.md
Normal file
130
docs/plans/chips-liderancas-lista-dobradinhas.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# 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)
|
||||
Responsável: —
|
||||
|
||||
## 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`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto (a maior parte da interação já foi decidida no B31; craft aqui é sobretudo a direção nova + o clamp de overflow) → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG / Assessor olhando `/campanha/dobradinhas` e querendo ver, de um deputado estadual, **quais lideranças já estão amarradas a ele** — e corrigir isso sem abrir uma ficha de liderança por vez. É o mesmo job do B31, visto do lado do deputado em vez do lado da liderança.
|
||||
- **Job principal:** ver e editar as lideranças de uma dobradinha direto na tabela, com o mesmo gesto com que já se edita a carteira de município de um assessor.
|
||||
- **Estratégia de cor:** Restrained — chips `Badge variant="secondary"`, sem paleta nova.
|
||||
- **Edit where you see:** sim — mesma affordance do B31/Assessores (chips removíveis, busca inline, sem toggle de tabela inteira); o pedido do usuário nomeia explicitamente "não duplicar lógica" — a resposta de design é reusar a mesma mutação e a mesma forma de célula, não inventar uma segunda.
|
||||
- **Anti-goals:** modo "Editar" de tabela inteira; segundo componente de célula fazendo a mesma coisa que o do B31 com um nome diferente; agrupamento por município/TI nos chips (essa relação não tem hierarquia territorial).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim — a mesma relação categórica `leadership.stateDeputies`, vista do lado do `stateDeputy`. Hoje a lista de dobradinhas descarta o detalhe e mostra só `leadershipCount: number`.
|
||||
- **Decisões desbloqueadas:**
|
||||
- Coordenador/Assessor olhando a ficha de um deputado: "quais lideranças já estão de fato amarradas a ele — e há alguma faltando?" (mesmo flanco de fogo amigo intra-campo nomeado em [CUSTOMER.md](../CUSTOMER.md)).
|
||||
- Coordenador fazendo triagem por deputado (em vez de por liderança, como o B31 já resolve): completar em lote as fichas de um deputado específico, útil quando o dado chega organizado "por candidato" em vez de "por pessoa" (o caso comum de planilha de campanha).
|
||||
- **Forma escolhida:** tabela/lista — coluna com chips nomeados (link para `/campanha/liderancas/[id]`), igual ao tratamento já decidido para "Municípios"/"Organizações" e ao que o B31 decidiu para "Dobradinhas" na tabela de lideranças. **Rejeitado:** manter a contagem (esconde o nome, que é a decisão); qualquer chart/mapa (dado nominal/categórico, "quem" não "onde"/"quanto").
|
||||
- **Profile:** categórico; granularidade `stateDeputy` × `leadership`; tamanho **potencialmente maior** por linha do que no B31 — um deputado popular pode acumular dezenas de lideranças engajadas, enquanto uma liderança tipicamente dobra com 0–2 deputados. Essa assimetria é a razão de design central deste item (ver Decisões travadas #3).
|
||||
- **Anti-goals de dado:** sem métrica eleitoral derivada; sem ranking de deputados por número de lideranças; sem inferir a partir de `municipality.stateDeputies` (união automática misturaria vínculo sabido com suposto, mesmo anti-goal do B31).
|
||||
|
||||
Self-check dados: 5/5.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/dobradinhas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx>)) lista deputados estaduais com colunas Nome · Partido · Municípios · Lideranças. As duas últimas são só contagens (`municipalityCount`/`leadershipCount`) calculadas em [`stateDeputyData.ts`](../../src/utilities/stateDeputyData.ts) por uma query batch em `leadership`/`municipality` filtrada por `stateDeputies: { in: stateDeputyIDs } }` — a mesma forma de agregação que a ficha [`loadStateDeputyDetail`](../../src/utilities/stateDeputyData.ts) já usa para resolver **nomes** (não só contagem) de município e liderança, só que ali para um único deputado.
|
||||
|
||||
O usuário pediu explicitamente que a coluna "Lideranças" ganhe "as mesmas funcionalidades e UI" da coluna "Municípios" de `/campanha/assessores` (**B19 ✓** — chips clicáveis, chips removíveis + busca no modo edição, gravação por delta com estado otimista, "Ver mais…" quando a carteira não cabe em 3 linhas) — e pediu para **não duplicar lógica**, reutilizando funções e componentes.
|
||||
|
||||
Isso importa porque existe hoje, em plano (ainda não implementado), o item **B31** ([`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md)) resolvendo **exatamente a mesma relação `leadership.stateDeputies`**, só que a partir da tabela de lideranças: lá, cada linha é uma liderança fixa e os chips são as dobradinhas dela; aqui, cada linha é uma dobradinha fixa e os chips são as lideranças dela. É a mesma aresta de grafo, editada dos dois lados — exatamente o cenário em que "reusar, não duplicar" tem uma resposta concreta: a mutação (ler o array `leadership.stateDeputies`, aplicar um toggle, escrever) é **idêntica** nas duas direções, porque o campo relacional só existe do lado da `leadership`. O que muda é qual id fica fixo e qual vem da busca — e a UI da célula (chips + Popover + `Command` + otimista) também é idêntica na forma, mudando só a lista de origem/destino e o link do chip.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Coluna "Lideranças" em `/campanha/dobradinhas` troca a contagem morta por chips nomeados, cada um linkando para `/campanha/liderancas/[id]`.
|
||||
- Adicionar/remover liderança **na própria linha**, com busca acento-insensível por nome sobre o catálogo de lideranças acessíveis ao ator, **sem botão "Salvar"**: grava por delta, com chip otimista, erro revertido + toast.
|
||||
- **Clamp de 3 linhas + "Ver mais…"** (mesma medição por `ResizeObserver` do `AdvisorMunicipalityCell`) — necessário aqui porque, ao contrário do B31, o volume de chips por linha pode crescer bastante (ver Decisões travadas #3).
|
||||
- **Zero segunda implementação da mutação ou da mecânica de célula**: reutiliza a action/schema/lock que o B31 introduz para a mesma relação, e um único componente de célula cobre as duas direções (B31 e este item).
|
||||
- `StateDeputyRowViewModel.leadershipCount` é substituído por `leaderships: LeadershipRelationSummary[]` (id + nome), resolvido pela mesma query batch já existente (mais um `depth`/`select`, não uma segunda consulta).
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem mudança de contrato de URL da lista; access reaproveitado (a leitura/escrita de `leadership` com `overrideAccess: false` já resolve o escopo do assessor); `leader` continua fora da rota (`isCampaignStaff` já exclui).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Reusar literalmente a mutação do B31, sem um segundo caminho de código.** A relação mora só em `leadership.stateDeputies`; "editar do lado do deputado" continua sendo ler-modificar-escrever o documento da `leadership`, só que com `leadershipId` variável (escolhido na busca) e `stateDeputyId` fixo (o da linha) — o espelho exato de como o B31 chama a mesma função com `stateDeputyId` variável e `leadershipId` fixo. Este item **não** introduz uma action nova: usa `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` (schema `leadershipStateDeputyMembershipSchema`, lock `leadership-state-deputies:{id}`) exatamente como o B31 os deixar. **Por quê:** é a definição operacional do pedido "não duplicar lógica" — a mesma checagem de escopo, o mesmo lock, o mesmo teto (`MAX_LEADERSHIP_STATE_DEPUTIES`, 20) e a mesma transação já resolvem os dois sentidos. **Rejeitado:** uma action "do lado do `stateDeputy`" espelhando a lógica — duplicaria exatamente a leitura-modificação-escrita que o B31 já resolve; um endpoint JSON dedicado só para esta direção — mesmo raciocínio, sem ganho.
|
||||
- **Um único componente de célula bidirecional para a mesma aresta, não dois componentes que fazem a mesma coisa com nomes diferentes.** `LeadershipStateDeputyRelationCell` (client, em `src/components/campaign/shared/`) recebe `direction: 'fromStateDeputy' | 'fromLeadership'`, o id da linha fixa, os itens já vinculados (`{id, name, href}[]`) e o catálogo de opções do outro lado (já filtrado/rotulado pelo chamador). O B31 monta essa célula com `direction: 'fromLeadership'` (chips = dobradinhas, catálogo = `loadStateDeputyOptions`); este item monta com `direction: 'fromStateDeputy'` (chips = lideranças, catálogo = `loadLeadershipOptions`, novo). **Por quê:** as duas montagens compartilham 100% da mecânica (Popover, `Command`, `matchesAtWordStart`, `useTransition`, estado otimista, reversão em erro, toast) — só o rótulo dos chips, o link e qual lado da tupla `{leadershipId, stateDeputyId}` é fixo mudam. **Rejeitado:** um componente `StateDeputyLeadershipsCell` próprio deste item, irmão gêmeo do `LeadershipStateDeputiesCell` do B31 — é exatamente a "segunda cópia da mesma lógica" que o pedido do usuário proíbe; um editor genérico `RelationshipChipEditor` agnóstico de qual relação edita — prematuro (só existe uma relação com esta forma até aqui; a de municípios tem agrupamento por TI/ZE, é outra forma — ver Adiado com gatilho).
|
||||
- **Incluir o clamp de 3 linhas + "Ver mais…" (portado do `AdvisorMunicipalityCell`) no componente compartilhado, atrás de um prop opcional (`measureOverflow`, default `true`).** O B31 decidiu **não** portar essa medição para a direção "de liderança" porque uma liderança tipicamente dobra com 0–2 deputados (poucos chips, sem estouro). Este item pede exatamente a paridade com Assessores — cujo motivo de existir é uma carteira que **pode** crescer além de 3 linhas — e aqui a assimetria é real: um deputado popular pode acumular dezenas de lideranças engajadas. **Por quê:** ligar/desligar por prop custa uma linha de código no componente compartilhado; não portar deixaria a linha de um deputado popular crescer sem limite ao lado de Nome/Partido/Municípios, quebrando a densidade da própria lista que este item está tentando tornar legível. **Rejeitado:** sem clamp em nenhuma direção (`whitespace-normal`) — reproduziria em `/campanha/dobradinhas` o mesmo problema que motivou o "Ver mais…" em Assessores; clamp obrigatório sempre — گasto de `ResizeObserver` sem necessidade real no lado "de liderança", que o B31 já mediu como raro.
|
||||
- **Catálogo de busca (`loadLeadershipOptions`, novo em `campaignRelationOptions.ts`) via `payload.find({ collection: 'leadership', user, overrideAccess: false })`, sem interseção manual com `getAccessibleLeadershipIds`.** **Por quê:** é o mesmo padrão que já sustenta `loadStateDeputyOptions`/`loadMunicipalityOptions`/`loadOrganizationOptions` — o access de linha de `canReadLeadership` já resolve o escopo do assessor no próprio `find`; a autoridade real de qualquer forma está na mutação (`overrideAccess: false` no read-modify-write), então escopar a busca de novo seria replicar uma regra que o Payload já aplica. **Rejeitado:** buscar todas as lideranças com `overrideAccess: true` e filtrar em memória — reintroduz manualmente uma regra de acesso que já existe.
|
||||
- **`stateDeputyData.ts`: a query batch de `leadership` (já filtrada por `stateDeputies: { in: stateDeputyIDs } }`) ganha `depth: 1` + `select: { stateDeputies: true, contact: true }` e passa a agrupar `{id, name}` por deputado, no lugar da contagem — mesma forma que `loadStateDeputyDetail` já usa para a ficha, promovida para a query em lote da lista.** `StateDeputyRowViewModel.leadershipCount` é substituído por `leaderships: LeadershipRelationSummary[]`. **Por quê:** é uma extensão de campos (`select`) na mesma query já existente, não uma segunda consulta — literalmente a definição de "não duplicar lógica" aplicada ao loader. **Rejeitado:** manter `leadershipCount` e adicionar uma segunda query de nomes sobre o mesmo `where` — dois round-trips ao banco para o mesmo filtro é a duplicação que o pedido nomeia.
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`LeadershipStateDeputyRelationCell`, `loadLeadershipOptions`, `LeadershipRelationSummary`), strings visíveis em pt-BR ("Lideranças", "Adicionar liderança…", "Ver mais…").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O componente compartilhado (`LeadershipStateDeputyRelationCell`) mora em `shared/` ou em `stateDeputy/`?** **Opções:** A) `src/components/campaign/shared/` — é consumido por duas rotas de dois domínios diferentes (`dobradinhas/page.tsx` e `liderancas/page.tsx`) | B) `src/components/campaign/stateDeputy/`, importado cross-domínio pela rota de lideranças. **Recomendação:** A — `codebase-map.mdc` já reserva `shared/` para o que mais de um domínio consome (precedente: `CampaignTable`, `CampaignListFooter`); manter em `stateDeputy/` obrigaria `liderancas/` a importar de outro domínio, o acoplamento que o mapa de código proíbe.
|
||||
- **Este item entra na mesma entrega do B31, ou depois, consumindo o que o B31 já tiver extraído?** **Opções:** A) mesma PR/sessão — constrói a célula e a action uma vez, já cobrindo as duas direções | B) B36 entra depois do B31 (padrão de corrida já usado em B21/B29/B33: quem chega primeiro paga a peça compartilhada, o outro só consome). **Recomendação:** B — mantém cada entrega revisável e no appetite calculado acima; se produto preferir as duas juntas, o appetite combinado (~1,5–2 dias) cobre.
|
||||
- **Existe algum teto simétrico do lado do `stateDeputy` (limite de lideranças por deputado)?** **Opções:** A) nenhum teto novo — o teto de 20 lideranças por dobradinha (`MAX_LEADERSHIP_STATE_DEPUTIES`, do lado da `leadership`) já limita o fan-out sem necessidade de um segundo campo | B) adicionar teto simétrico. **Recomendação:** A — sem evidência de necessidade; o clamp visual (item de design acima) já resolve a legibilidade da célula independente de quantos chips existem de fato.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
loader["loadStateDeputyListPageData<br/>+ leaderships (id/nome, mesma query batch)"] --> page["dobradinhas/page.tsx<br/>coluna Lideranças"]
|
||||
options["loadLeadershipOptions<br/>(novo, overrideAccess: false)"] --> provider["RelationOptionsProvider<leadership><br/>(1× por tabela)"]
|
||||
provider --> cell["LeadershipStateDeputyRelationCell<br/>direction='fromStateDeputy'"]
|
||||
page --> cell
|
||||
cell -->|delta assigned| action["setLeadershipStateDeputyMembership<br/>(do B31 — reusada sem alteração)"]
|
||||
action --> revalidate["revalidatePath<br/>dobradinhas · liderancas (mesmas 4 rotas do B31)"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/components/campaign/shared/LeadershipStateDeputyRelationCell.tsx`** (novo — se o B31 já tiver entregue seu `LeadershipStateDeputiesCell`, este item **promove/renomeia** aquele componente para `shared/` e adiciona `direction`/`measureOverflow` em vez de criar um arquivo paralelo). Props: `direction: 'fromStateDeputy' | 'fromLeadership'`, `fixedId: number`, `items: { id: number; name: string; href: string }[]`, `options: RelationOption[]`, `measureOverflow?: boolean` (default `true`), `disabled?: boolean`. Internamente: chips (`Badge` + `Link`) no repouso; Popover com chips removíveis + `Command` (busca via `matchesAtWordStart`) para adicionar; `useTransition` + estado otimista + `toast.error` com reversão; quando `measureOverflow`, o mesmo `ResizeObserver` de 3 linhas + "Ver mais…" do `AdvisorMunicipalityCell`.
|
||||
- **`src/components/campaign/shared/RelationOptionsProvider.tsx`** (novo, genérico só na forma — não na relação): `Context<RelationOption[]>` + `useRelationOptions()`; monta uma vez em volta do `CampaignTable`. Reusado pelo B31 (catálogo de dobradinhas) e por este item (catálogo de lideranças) — mesmo formato (`RelationOption[]`), dois consumidores reais, sem parametrizar nada específico de domínio.
|
||||
- **`src/utilities/campaignRelationOptions.ts`**: `loadLeadershipOptions(payload, user)` (novo) — espelha `loadStateDeputyOptions`: `payload.find({ collection: 'leadership', depth: 1, select: { contact: true }, user, overrideAccess: false })`, mapeando para `{ id, name: contact.name }`.
|
||||
- **`src/utilities/stateDeputyData.ts`**: `StateDeputyRowViewModel.leadershipCount` → `leaderships: LeadershipRelationSummary[]` (`{id, name}`); a query batch de `leadership` em `loadStateDeputyListPageData` ganha `depth: 1` e `select: { stateDeputies: true, contact: true }`, agrupando por deputado no lugar de só contar. `LeadershipRelationSummary` exportado para o componente da célula construir `href`/label.
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`**: a coluna `leaderships` passa a renderizar `<LeadershipStateDeputyRelationCell direction="fromStateDeputy" fixedId={row.id} items={row.leaderships.map(l => ({...l, href: \`/campanha/liderancas/${l.id}\`}))} .../>`; a página carrega `loadLeadershipOptions(payload, user)`no mesmo`Promise.all`do loader da lista e envolve a tabela em`<RelationOptionsProvider options={...}>`.
|
||||
- **Sem migration, sem collection, sem action nova, sem endpoint JSON, sem schema novo** — reusa integralmente `setLeadershipStateDeputyMembership`/`leadershipStateDeputyMembershipSchema`/lock do B31.
|
||||
- **Testes:** estende os testes de `nextStateDeputyIdsAfterMembership`/action do B31 com um caso de invocação "pelo lado do deputado" (mesmo resultado, ids trocados); teste de `loadLeadershipOptions` respeitando escopo do assessor; teste do loader de `stateDeputyData.ts` confirmando `leaderships` (não mais `leadershipCount`) no row.
|
||||
|
||||
Depth check: reusa `CampaignTable`, `Popover`/`Command`/`Badge` do kit, `wordStartFilter`, a action/schema/lock do B31, `campaignAccess`. Não introduz um segundo caminho de mutação nem um segundo componente de célula — o ponto central do pedido.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Soft, corrida com B31** (`docs/plans/dobradinhas-lista-liderancas.md`): quem entrar primeiro paga a extração de `LeadershipStateDeputyRelationCell` (a partir do `LeadershipStateDeputiesCell` que o B31 desenha) + `RelationOptionsProvider` + a action/schema/lock; o outro consome sem alterar. Mesmo padrão de corrida já documentado para B21/B29/B33.
|
||||
- Soft: **B19 ✓** (padrão de interação de origem, `AdvisorMunicipalityCell`), **E4R ✓** (criou o catálogo de dobradinhas e deixou as fichas de liderança vazias — é o motivo de haver trabalho de preenchimento em lote dos dois lados), **B33** (mesma tabela — se o header de sort/filtro de dobradinhas entrar primeiro, esta coluna nasce sob o header rico; se entrar depois, herda).
|
||||
- Reusa sem alterar: `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `canManageLeadership`/`canReadLeadership` (`src/utilities/access/leaderships.ts`), `canReadStateDeputy` (`src/utilities/access/stateDeputies.ts`), `runCampaignFormAction`.
|
||||
- Nenhuma dependência dura de outro item aberto do roadmap.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Agrupamento de chips por município/Território de Identidade (a metade "lote" do widget de Assessores) — a relação dobradinha↔liderança não tem hierarquia territorial análoga; mesma decisão do B31 para a direção espelhada.
|
||||
- Filtro/ordenação por lideranças no header de `/campanha/dobradinhas` — o **B33** cobre apenas Nome/Partido; vira gatilho registrado ali se a mesa pedir.
|
||||
- Editar a coluna "Municípios" de `/campanha/dobradinhas` pelo mesmo padrão — vínculo indireto via `municipality.stateDeputies` (outra collection, outro dono de escrita), fora do pedido.
|
||||
- Criar liderança nova a partir da célula ("adicionar e cadastrar") — `/campanha/liderancas/nova` continua sendo o caminho.
|
||||
- Generalizar `LeadershipStateDeputyRelationCell`/`RelationOptionsProvider` para qualquer relação (municípios, organizações) — ver Adiado com gatilho.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Generalizar demais enquanto o arquivo já está aberto.** É tentador, ao extrair a célula para `shared/`, torná-la um editor de relação totalmente agnóstico (qualquer collection, qualquer campo). **Mitigação:** parar em `LeadershipStateDeputyRelationCell`, escopado literalmente à aresta `leadership.stateDeputies`; a de municípios (`MunicipalityPortfolioCell`, do plano [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md)) tem agrupamento TI/ZE — é uma forma diferente, não o mesmo componente com um nome trocado.
|
||||
- **Migrar consumidores de `leadershipCount` sem verificar quem mais lê esse campo.** Antes de remover `leadershipCount` de `StateDeputyRowViewModel`, `git grep -w leadershipCount` — se algum outro lugar (painel/overview) só precisa do número, `row.leaderships.length` substitui sem tocar naquele markup fora do escopo deste item.
|
||||
- **Ajustar o clamp de 3 linhas do B31 "por tabela ter mudado" quando na verdade o volume só mudou no lado do deputado.** O prop `measureOverflow` existe exatamente para isolar essa decisão por direção; não elevar/reduzir o limiar do lado "de liderança" sem evidência nova.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Editor de relação totalmente genérico (`RelationshipChipEditor`), incluindo municípios/organizações.** Revisitar quando uma 3ª relação sem agrupamento territorial pedir exatamente esta forma (mesmo gatilho já registrado no B31).
|
||||
- **Busca de liderança no servidor em vez de catálogo client-side completo.** Revisitar quando o catálogo de lideranças passar de ~200 registros (mesmo gatilho/métrica do B31).
|
||||
- **Teto simétrico de lideranças por dobradinha.** Revisitar só com evidência real de um deputado acumulando um volume que quebre a legibilidade mesmo com o clamp.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B — item B36, ao lado de B31/B33)
|
||||
- [`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) (B31) — desenho da mutação/ação/lock/schema que este item reusa sem alteração, e da célula que este item promove para `shared/`
|
||||
- [`gerenciar-assessores.md`](gerenciar-assessores.md) (B19 ✓) — origem do padrão de chips + busca + otimista + "Ver mais…"
|
||||
- `src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`, `src/utilities/stateDeputyData.ts` — superfície e view model a estender
|
||||
- `src/components/campaign/advisor/AdvisorMunicipalityCell.tsx` — origem do clamp de 3 linhas + `ResizeObserver`/"Ver mais…" a portar
|
||||
- `src/utilities/campaignRelationOptions.ts` (`loadStateDeputyOptions`, `loadMunicipalityOptions`) — padrão a espelhar em `loadLeadershipOptions`
|
||||
- `src/utilities/access/leaderships.ts` (`canReadLeadership`, `canManageLeadership`) — access que a leitura/escrita reusa sem alterar
|
||||
- `src/lib/wordStartFilter.ts` (`matchesAtWordStart`, `normalizeSearchPhrase`) — busca acento-insensível reusada
|
||||
- AGENTS.md — Campaign Municípios model, Transaction Safety, naming em inglês/copy em pt-BR
|
||||
- `.cursor/rules/campanha-edit-where-you-see.mdc`, `.cursor/rules/campanha-action-feedback.mdc` — princípios de UX herdados de Assessores/B31
|
||||
143
docs/plans/chips-municipios-lista-dobradinhas.md
Normal file
143
docs/plans/chips-municipios-lista-dobradinhas.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Chips editáveis de municípios na lista de dobradinhas (paridade com Assessores)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B37**)
|
||||
Impeccable: B — encaixe na coluna "Municípios" de `/campanha/dobradinhas`; sem rota nova
|
||||
Appetite: ~0,75–1 dia eng — 1 componente de célula novo (Popover + Command), 2 server actions + schema + helper puro, ajuste no loader da lista; sem migration. (Menor que o B34 análogo: sem piso mínimo, sem `assertMunicipalitiesWithinScope`, lock por município em vez de por documento.)
|
||||
Responsável: —
|
||||
|
||||
## ⚠️ Conflito com o pedido literal — leia antes de implementar
|
||||
|
||||
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 3 **Edit where you see** com os anti-goals explícitos "spreadsheet mode" / "always-mounted inputs on every row", princípio 4 **Auto-save, no Save button**, princípio 8 **Feel the action**) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedentes vivos: [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md) (**B34** — mesmo pedido, mesma resolução, para a coluna "Municípios" de Lideranças; este plano copia a arquitetura, não o container), [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) / [`advisorMunicipalityPortfolio.ts`](../../src/lib/advisorMunicipalityPortfolio.ts) (**B19 ✓** — lógica pura de agrupar território/ZE e buscar, que o B34 já promove para nomes neutros e este item reaproveita), [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (**B27** — chips + `Command` em Popover, delta sem "Salvar"), [`setAdvisorMunicipalityMembershipRecord`](<../../src/app/(campaign)/campanha/actions/advisor.ts>) (delta com lock **por município** — a forma certa aqui, porque o array mora em `municipality.stateDeputies`, não num campo do dobradinha).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto (Popover + `Command` já são peças do produto) → critique → polish.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG/Assessor completando as fichas de dobradinha que o **E4R ✓** semeou só no lado do município (`municipality.stateDeputies`) — hoje a única leitura na lista é um contador (`row.municipalityCount`), sem nome, sem link, sem edição; a única escrita é abrir cada município e usar o combobox de `MunicipalityStrategyForm`.
|
||||
- **Job principal:** ver quais municípios uma dobradinha já cobre — pelo nome, não por número — e ajustar sem sair da lista, no mesmo gesto de digitar-e-adicionar já em produção em `/campanha/assessores`.
|
||||
- **Estratégia de cor:** Restrained — chips `Badge variant="secondary"`, chip de território com `MapPinIcon`, sem cor semântica nova.
|
||||
- **Edit where you see:** sim — o campo já é editável pelo staff (`municipality.stateDeputies` tem `access` de staff, não admin-only) e hoje é um contador morto na lista de dobradinhas.
|
||||
- **Anti-goals:** modo "Editar" de tabela inteira (ver conflito acima); inputs montados em todas as linhas; "Salvar" no Popover; segundo design system de célula de relação (reaproveitar `Popover`/`Command`/`Badge`, e a lib pura do B19/B34 — não inventar).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície já existente (`StateDeputyRowViewModel.municipalityCount`, hoje um número) — este item troca a apresentação por nomes navegáveis, sem introduzir agregação nova.
|
||||
- **Decisões desbloqueadas:** CG/Assessor: "quais municípios esta dobradinha já cobre — e qual falta completar?" (hoje só se sabe abrindo cada ficha de município, um a um, para ler o combobox de dobradinhas); insumo direto do preenchimento em lote que a coordenação faz durante o onboarding logo depois do E4R.
|
||||
- **Forma escolhida:** **tabela/lista** — chips nomeados com link, igual ao tratamento da mesma coluna em Assessores (**B19 ✓**) e ao que o **B34** propõe para Lideranças. **Rejeitado:** manter a contagem (esconde justamente o nome que decide o preenchimento); mapa (vínculo nominal, não leitura territorial agregada).
|
||||
- **Profile:** categórico; granularidade `stateDeputy` × `municipality`; tamanho típico 0–15 chips (sem piso, sem teto de schema deste lado — limitado só pelo universo de 435); catálogo de dezenas de dobradinhas; absoluto (o nome em si).
|
||||
- **Anti-goals de dado:** sem métrica eleitoral na célula; sem ranking de dobradinhas por município.
|
||||
|
||||
Self-check dados: 5/5.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/dobradinhas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx>)) lista dobradinhas com `CampaignTable` (Nome · Partido · Municípios · Lideranças). A coluna "Municípios" hoje é só `row.municipalityCount` — um número, sem nome, sem link — porque `StateDeputy` não guarda a relação; ela é **invertida**: `municipality.stateDeputies` (hasMany, `access.update: canManageCampaignStaffField` — staff, não admin-only) e `leadership.stateDeputies` são os dois lados que apontam para `stateDeputy` ([`stateDeputyData.ts:69-101`](../../src/utilities/stateDeputyData.ts) conta agregando as duas coleções). A única forma de vincular hoje é abrir a ficha do município (`MunicipalityStrategyForm`, combobox `stateDeputies`, submit explícito) ou da liderança, um documento por vez — exatamente o trabalho de mesa que o **E4R ✓** deixou pendente ao semear dobradinha só do lado do município.
|
||||
|
||||
O mesmo pedido de produto, na mesma data, já foi feito para a coluna "Municípios" de `/campanha/liderancas` — **B34** ([chips-municipios-lista-liderancas.md](chips-municipios-lista-liderancas.md)) — e resolvido com Popover por célula em vez do toggle de tabela inteira, promovendo a lib pura de `/campanha/assessores` (**B19 ✓**) para nomes neutros e construindo um cell novo, mais simples (Popover + `Command`, sem o `ResizeObserver` de `AdvisorMunicipalityCell`, cuja medição resolve um problema — empacotar 3 linhas numa célula sempre visível — que não existe dentro de um Popover). Este item segue a mesma arquitetura, com duas diferenças estruturais importantes:
|
||||
|
||||
1. **A relação mora no lado errado para reaproveitar `leadership.municipalities`.** Em Lideranças (B34), o array vive no próprio documento editado (`leadership.municipalities`) e tem piso 1 (`requireAtLeastOneMunicipality`). Aqui, como em Assessores (B19), o array-alvo vive no **município** (`municipality.stateDeputies`) — a escrita é uma leitura+atualização do município tocado, não do dobradinha. **Não há piso mínimo**: uma dobradinha pode não cobrir nenhum município (estado válido, o deputado ainda não tem território definido).
|
||||
2. **Sem `assertMunicipalitiesWithinScope`.** Esse helper existe para validar o array inteiro de `leadership.municipalities` contra o escopo do assessor. Aqui a escrita é por delta direto no `municipality` — `canUpdateMunicipality` (acesso do próprio Payload) já restringe um assessor a atualizar só municípios que administra; não é preciso reimplementar a checagem.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Coluna "Municípios" de `/campanha/dobradinhas` mostra **chips** (não mais um número); cada chip de município navega para `/campanha/municipios/[slug]`; chip de território (quando a dobradinha cobre o TI inteiro) mostra `MapPinIcon` e não navega — mesma leitura de `AdvisorMunicipalityCell`/da futura `LeadershipMunicipalitiesCell` (B34).
|
||||
- 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 de identidade ou zona eleitoral — adicionar/remover grava **na hora**, sem botão "Salvar".
|
||||
- Adicionar/remover um **território ou ZE inteiro** de uma vez (lote), igual ao padrão de `/campanha/assessores` — paridade explicitamente pedida ("todas as outras funcionalidades").
|
||||
- Pode ir a zero: remover o último município vinculado é permitido (dobradinha sem território ainda é um estado válido); nenhum piso client-side ou server-side análogo ao de Lideranças.
|
||||
- Assessor só consegue **adicionar** municípios que administra — o servidor recusa fora do escopo (`canUpdateMunicipality`) e a busca já sugere só o escopo dele, para não oferecer becos sem saída; chips já atribuídos (mesmo fora do escopo do assessor logado) continuam visíveis, agrupados contra o catálogo completo.
|
||||
- Access idêntico ao já existente (staff geral escreve, dentro do escopo do próprio município — não é `isCampaignUnrestricted`-only); nenhuma migration, nenhuma collection, nenhum Consent novo, nenhuma mudança no contrato de URL da lista.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Popover disparado por célula, não modo "Editar" de tabela inteira.** Ver o aviso de conflito no topo — é a mesma decisão travada quatro vezes em 2026-07-25 (B28, B31, B32, B34), agora numa quinta tabela com o mesmo argumento estrutural. **Rejeitado:** toggle "Editar" de tabela inteira (pedido literal do usuário — reabriria os quatro precedentes); célula sempre em edição sem Popover (inputs montados em 25+ linhas).
|
||||
- **Mutação por delta direto em `municipality.stateDeputies`** (ler o array atual do município tocado, aplicar o toggle/lote, `payload.update`), nunca substituição do array inteiro via `updateMunicipalityStrategyRecord`. Mesmo argumento do B19/B27: aquele record recebe o array **inteiro** de `stateDeputies` e reenviá-lo a partir de um snapshot da lista de dobradinhas apagaria edições concorrentes feitas pela ficha do município. **Rejeitado:** reenviar o array completo do município a cada delta (colisão entre atores); calcular o array a partir da lista carregada no cliente (fica obsoleto assim que outra aba edita o mesmo município).
|
||||
- **Lock por município (`municipality-state-deputies:{id}`), não por dobradinha.** A escrita real acontece no documento `municipality`; travar o `stateDeputy` não protegeria a corrida de verdade (dois deltas concorrentes na mesma dobradinha, mas em municípios diferentes, não colidem — e o mesmo município sendo tocado por duas dobradinhas ao mesmo tempo, sim). Mesmo padrão de B19 (`municipality-advisors:{id}`), diferente do B31/B34 (lock por documento do lado que guarda o array). **Rejeitado:** lock por dobradinha (protege o documento errado); lock composto dobradinha+município (complexidade sem ganho — o município já é a granularidade certa).
|
||||
- **Sem piso mínimo.** `stateDeputy` não tem hook análogo a `requireAtLeastOneMunicipality` porque o array não é dele — é inteiramente razoável (e hoje já possível pela ficha do município) uma dobradinha sem nenhum município vinculado ainda. **Rejeitado:** replicar o piso de 1 do B34 "por paridade visual" (paridade pedida é da coluna de Assessores — que **também** não tem piso, o array pode ir a zero — não da de Lideranças).
|
||||
- **Cap de 20 dobradinhas por município reaproveitado do schema existente**, não um cap novo do lado "municípios por dobradinha" (que não existe, porque não há array nesse lado — o teto natural já é o universo de 435). O helper puro de próximo-array reaproveita a mesma constante de `stateDeputiesArraySchema` (`src/lib/schemas/municipality.ts`) em vez de duplicar o número 20. **Rejeitado:** inventar um teto de "municípios por dobradinha" sem pedido nem evidência.
|
||||
- **Promoção da lib pura (`advisorMunicipalityPortfolio.ts` → `municipalityPortfolio.ts`, `loadAdvisorMunicipalityIndex` → `loadMunicipalityPortfolioIndex`) é comum com o B34 — quem chegar primeiro faz a renomeação; o outro só importa.** Os dois itens pedem exatamente a mesma promoção pelo mesmo motivo (`codebase-map.mdc`: prefixo de domínio não pode vazar para um 2º consumidor). Implementar aqui sem checar se o B34 já rodou duplicaria o `git mv`; a Fase 0 da implementação deste item **é** verificar se `src/lib/municipalityPortfolio.ts` já existe antes de tocar em `advisorMunicipalityPortfolio.ts`. **Rejeitado:** cada item manter sua própria cópia da lib (diverge no primeiro bugfix); esperar o B34 landar antes de começar este (nenhuma dependência dura de fato — só a ordem de quem faz o `git mv`).
|
||||
- **Server action + `revalidatePath`, não endpoint JSON.** A lista de dobradinhas tem poucas linhas (não 435) e há dois derivados a jusante que ficariam obsoletos: a própria ficha `/campanha/dobradinhas/[slug]` (contagem de municípios) e a lista/detalhe de `/campanha/municipios` (o município tocado, cujo card de estratégia mostra `stateDeputies`). Revalidar `/campanha/dobradinhas`, `/campanha/dobradinhas/[slug]` do deputado tocado e as rotas de município via `revalidateMunicipalityListPaths({ slug })` — mesmo padrão do B19/B31/B34. **Rejeitado:** endpoint JSON espelho do `expected-votes/` (deixaria a ficha do município e a ficha da dobradinha mentindo).
|
||||
- **Sugestões de busca restritas ao escopo do assessor via `loadMunicipalityOptions`; chips já atribuídos agrupados pelo catálogo completo.** Mesma decisão do B34: o agrupamento "cobre o TI inteiro" só é correto com o catálogo completo (435); as sugestões de **adição**, quando o ator é `advisor`, ficam restritas a `loadMunicipalityOptions(payload, user)` (já usado em outros combobox de município escopado) para não sugerir um município que o servidor vai recusar. **Rejeitado:** índice único sem escopo (sugere becos sem saída para o assessor); índice único escopado em toda parte (quebra o agrupamento territorial para coordenador/candidato, que não têm escopo).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`StateDeputyMunicipalitiesCell`, `setStateDeputyMunicipalityMembership`, `setStateDeputyMunicipalitiesBatch`, `nextStateDeputyMunicipalityIdsAfterMembership`), strings visíveis em pt-BR ("Editar municípios", "Buscar município, território ou ZE…", "Remover Salvador").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Toggle de tabela (pedido literal) vs Popover por célula (precedente travado quatro vezes).** Já registrado como conflito no topo — **Recomendação: Popover**, por consistência com B28/B31/B32/B34 em três tabelas distintas no mesmo dia. _(proposto — validar com produto antes de implementar; é a decisão mais cara de reverter deste plano.)_
|
||||
- **Restringir a busca de assessor por `loadMunicipalityOptions` mesmo sem `assertMunicipalitiesWithinScope` aqui.** **Opções:** A) restringir (mesma decisão do B34, evita becos sem saída) | B) não restringir (mais simples; servidor já recusa). **Recomendação:** **A** — o custo é uma query já existente (`loadMunicipalityOptions`), e o ganho de UX (não deixar o assessor "adicionar" algo que o servidor vai rejeitar) é o mesmo do B34. _(assumido)_
|
||||
- **Editar dentro do escopo de um único município já basta pela ficha — vale a pena a coluna aqui?** **Opções:** A) sim, mesmo pedido de produto de B34 aplicado à tabela de dobradinhas | B) não, porque a ficha de município já resolve. **Recomendação:** **A** — o pedido explícito é "mesma funcionalidade da coluna de Assessores"; a ficha do município resolve **um** município por vez, a coluna resolve o preenchimento em lote por **dobradinha**, que é o padrão de trabalho de mesa depois do E4R (uma pessoa por dobradinha, não uma pessoa por município).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
loader["loadStateDeputyListPageData<br/>+ municipalities (id/nome/slug)"] --> page["dobradinhas/page.tsx<br/>coluna Municípios"]
|
||||
index["loadMunicipalityPortfolioIndex<br/>(catálogo completo, 435 — B19/B34)"] --> provider["MunicipalityPortfolioOptionsProvider<br/>(1× por tabela — reusa B34 se já existir)"]
|
||||
scope["loadMunicipalityOptions<br/>(escopo do assessor, se advisor)"] --> provider
|
||||
provider --> cell["StateDeputyMunicipalitiesCell<br/>chips + ícone editar + Popover + Command"]
|
||||
page --> cell
|
||||
cell -->|delta ou lote| action["setStateDeputyMunicipalityMembership /\nsetStateDeputyMunicipalitiesBatch<br/>(tx + lock por município + canUpdateMunicipality)"]
|
||||
action --> revalidate["revalidatePath dobradinhas ·<br/>revalidateMunicipalityListPaths(slug)"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/lib/municipalityPortfolio.ts`** / **`src/utilities/municipalityPortfolioIndex.ts`**: reusa a promoção já decidida no **B34** (renome de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex`). Se este item for implementado antes do B34, ele executa a mesma renomeação (mecânica, comportamento intacto); se depois, só importa. Não duplicar a lib sob um terceiro nome.
|
||||
- **`src/components/campaign/stateDeputy/MunicipalityPortfolioOptionsProvider.tsx`** (novo se o B34 ainda não criou o seu; se já existir um provider genérico do B34 em `shared/` ou `leadership/`, reusar de lá — decidir no craft pela localização real após o primeiro dos dois landar): contexto `{ index, addableIds }`, montado uma vez em volta do `CampaignTable` de `/campanha/dobradinhas`.
|
||||
- **`src/components/campaign/stateDeputy/StateDeputyMunicipalitiesCell.tsx`** (novo, cliente): chips de leitura (`Badge` + `Link` para município; `Badge` + `MapPinIcon` para território completo, via `buildMunicipalityPortfolioChips` contra o índice completo) + ícone de edição (`PopoverTrigger`); `PopoverContent` com chips removíveis sobre `Command` (`shouldFilter={false}`, itens de `searchMunicipalityPortfolio` contra `addableIds`-restrito quando o ator é `advisor`); estado otimista local (`useState`/`useTransition`, sem `ResizeObserver`); `toast.error` + reversão em falha; sem lógica de piso mínimo (diferença do B34).
|
||||
- **`src/lib/schemas/stateDeputy.ts`**: `stateDeputyMunicipalityMembershipSchema` (`stateDeputyId`, `municipalityId`, `assigned: z.boolean()`) e `stateDeputyMunicipalitiesBatchSchema` (`stateDeputyId`, `municipalityIds: z.array(positiveRelationshipId).min(1).max(435)`, `assigned`), espelhando `src/lib/schemas/advisor.ts`.
|
||||
- **`src/lib/municipalityStateDeputyMembership.ts`** (novo, puro): `nextStateDeputyIdsAfterMembership(currentIds, stateDeputyId, assigned)` — `null` se não muda nada, lança a mensagem do teto quando o próximo array de `municipality.stateDeputies` excederia o limite já existente em `stateDeputiesArraySchema` (`src/lib/schemas/municipality.ts` — extrair a constante `MAX_STATE_DEPUTIES_PER_MUNICIPALITY = 20` de lá em vez de duplicar o número). Espelha `nextAdvisorIdsAfterMembership`.
|
||||
- **`src/app/(campaign)/campanha/actions/stateDeputy.ts`** (acréscimos): `setStateDeputyMunicipalityMembershipRecord`/`setStateDeputyMunicipalityMembership` e a variante em lote, cada uma em `withPayloadTransaction` + `reloadStaffActor` (mesmo guard de `updateMunicipalityStrategyRecord`) + `acquireTextAdvisoryLocks(['municipality-state-deputies:{id}'])` (ou N locks ordenados no lote) + leitura/escrita do **município** tocado com `user`/`overrideAccess: false` (o acesso real é `canUpdateMunicipality`, que já escopa o assessor — nada de bypass admin aqui, ao contrário do B19) + `revalidatePath('/campanha/dobradinhas', 'page')` + `revalidatePath('/campanha/dobradinhas/[slug]', 'page')` + `revalidateMunicipalityListPaths({ slug })` do município tocado.
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts`** (novo arquivo na rota da lista — a `[slug]/formActions.ts` existente é só do detalhe): casca sobre `runCampaignFormAction`.
|
||||
- **`src/utilities/stateDeputyData.ts`**: `StateDeputyRowViewModel.municipalities: { id: number; name: string; slug: string }[]` substitui `municipalityCount` (a query já existente que agrega `municipality.stateDeputies` passa a trazer `name`/`slug` em vez de só contar; `git grep -w municipalityCount` confirma que o único consumidor é `dobradinhas/page.tsx`).
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`**: `Promise.all` carrega `loadMunicipalityPortfolioIndex(payload)` e, só se `user.role === 'advisor'`, `loadMunicipalityOptions(payload, user)`; envolve `<CampaignTable/>` em `<MunicipalityPortfolioOptionsProvider>`; a coluna `municipalities` troca `row.municipalityCount` por `<StateDeputyMunicipalitiesCell stateDeputyId={row.id} municipalities={row.municipalities} />`.
|
||||
- **Testes:** unit de `nextStateDeputyIdsAfterMembership` (add/remove/no-op/teto 20) e da lib promovida (se este item chegar primeiro, mover `tests/unit/advisorMunicipalityPortfolio.unit.spec.ts` → `tests/unit/municipalityPortfolio.unit.spec.ts`, senão só importar); int das duas actions — assessor fora do escopo é recusado (erro de acesso do próprio Payload, sem `assertMunicipalitiesWithinScope`), assessor no escopo grava, teto de 20 respeitado, delta concorrente no mesmo município serializado pelo lock, dobradinha pode ficar com zero municípios; e2e opcional (abrir Popover, digitar, ver chip sem "Salvar").
|
||||
- **Migration:** nenhuma.
|
||||
|
||||
Depth check: reusa `Popover`/`Command`/`Badge` do kit, `CampaignTable`, a lib pura promovida pelo B19/B34, `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `runCampaignFormAction`, `loadMunicipalityOptions`, `revalidateMunicipalityListPaths`, `reloadStaffActor`. Não constrói `useAutosave`/`EditableCell`/multi-select genérico novo; não porta o `ResizeObserver` de `AdvisorMunicipalityCell`.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Soft: **B34** (mesma promoção de lib/loader e mesma decisão de container — quem chegar primeiro faz o `git mv` e a extração do provider; o outro só importa. Se os dois landarem antes de qualquer generalização, contam juntos com **B31** como o conjunto que pode disparar a extração de um `RelationshipChipEditor` agnóstico — ver Adiado com gatilho, mesmo gatilho já registrado no B34), **B31** (mesma família de pedido — coluna de relação em Popover, sem toggle de tabela — em outra tabela), **B19 ✓** (origem da lib pura e do cálculo de próximo array a espelhar), **B33** (mesma tabela — ordenação/filtro no header; ordem de chegada não importa, cada um toca uma parte diferente da página), **E4R ✓** (semeou dobradinha só no lado do município — este item é o preenchimento em lote que faltava).
|
||||
- Reusa sem alterar: `canUpdateMunicipality` (`src/utilities/access/municipalities.ts`), `reloadStaffActor`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `loadMunicipalityOptions` (`src/utilities/campaignRelationOptions.ts`), `runCampaignFormAction`, `revalidateMunicipalityListPaths`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Toggle "Editar" de tabela inteira — anti-goal travado (ver conflito no topo).
|
||||
- Editar "Nome"/"Partido" in-list — fora do pedido (a ficha `/dobradinhas/[slug]` já resolve com `StateDeputyForm`); "Lideranças" fica número (mesmo padrão do **B31**, que edita pelo lado da liderança).
|
||||
- Corrigir/tocar `updateMunicipalityStrategyRecord`/`MunicipalityStrategyForm` — continuam existindo para edição em massa por município; este item só acrescenta o caminho por delta.
|
||||
- Restrição/piso mínimo de municípios por dobradinha — não existe hoje, não é criado aqui.
|
||||
- "Ver mais…" / colapso de chips por medição de layout (`ResizeObserver`) — mesmo corte do B34, sem evidência de dobradinha com carteira grande.
|
||||
- Extrair já um `RelationshipChipEditor` genérico — ver Adiado com gatilho (mesmo gatilho do B34).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Duplicar a promoção da lib pura sob um nome diferente do B34** ("já que estou aqui, faço a minha própria versão"). Diverge no primeiro bugfix e cria dois catálogos de município no cliente. **Mitigação:** decisão travada acima — checar `src/lib/municipalityPortfolio.ts` antes de qualquer `git mv`.
|
||||
- **Generalizar o editor de chips agora** a partir de três casos ainda não implementados (B31, B34, este). **Mitigação:** construir a célula do domínio (`StateDeputyMunicipalitiesCell`); extração só sob o gatilho já registrado no B34.
|
||||
- **Portar a máquina de `ResizeObserver` do `AdvisorMunicipalityCell`** "já que existe". Resolve um problema que não existe dentro de um Popover. **Mitigação:** decisão travada; revisitar só com evidência de overflow real.
|
||||
- **"Aproveitar e consertar" `MunicipalityStrategyForm`/o combobox de `stateDeputies` da ficha do município** já que o mesmo campo está aberto. **Mitigação:** só acrescentar o caminho por delta; não tocar no formulário completo.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **`RelationshipChipEditor` agnóstico de relação (Municípios de Assessores/Lideranças/Dobradinhas num único primitivo).** Mesmo gatilho já registrado no B34: revisitar quando existirem os call sites reais (B31 + B34 + este) implementados e a forma comum (Popover + `Command` + delta, com/sem grupamento territorial, com/sem piso mínimo) puder ser lida do código em vez de imaginada.
|
||||
- **"Ver mais…" / colapso de chips por medição.** Revisitar quando aparecer dobradinha com carteira grande o suficiente para a célula ficar visualmente pesada (sem evidência hoje).
|
||||
- **Migrar para endpoint JSON (padrão B27).** Revisitar só se a lista de dobradinhas crescer a ponto de `revalidatePath` por clique ficar perceptível (sem evidência hoje — o catálogo de dobradinhas é de dezenas, não de centenas).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, item **B37**; grafo; Janela 1–2)
|
||||
- [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md) (**B34**) — mesmo pedido, mesma resolução, arquitetura-espelho
|
||||
- [`dobradinhas/page.tsx`](<../../src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx>), [`stateDeputyData.ts`](../../src/utilities/stateDeputyData.ts) — superfície e view model a estender
|
||||
- [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) / [`advisorMunicipalityPortfolio.ts`](../../src/lib/advisorMunicipalityPortfolio.ts) / [`advisorData.ts`](../../src/utilities/advisorData.ts) (**B19 ✓**) — lógica pura e índice, promovidos pelo B34 ou por este item, o que chegar primeiro
|
||||
- [`actions/advisor.ts`](<../../src/app/(campaign)/campanha/actions/advisor.ts>) (`nextAdvisorIdsAfterMembership`, `setAdvisorMunicipalityMembershipRecord`, lock por município) — espelho direto das novas actions
|
||||
- [`actions/municipality.ts`](<../../src/app/(campaign)/campanha/actions/municipality.ts>) (`updateMunicipalityStrategyRecord`, `getFreshStaffActor`) — padrão de acesso real (`overrideAccess: false`) a seguir, em vez do bypass admin do B19
|
||||
- [`src/collections/Municipality.ts`](../../src/collections/Municipality.ts) (campo `stateDeputies`, `access.update: canManageCampaignStaffField`) / [`src/utilities/access/municipalities.ts`](../../src/utilities/access/municipalities.ts) (`canUpdateMunicipality`)
|
||||
- [`src/lib/schemas/municipality.ts`](../../src/lib/schemas/municipality.ts) (`stateDeputiesArraySchema`, teto 20) / [`src/lib/schemas/stateDeputy.ts`](../../src/lib/schemas/stateDeputy.ts)
|
||||
- [`campaignRelationOptions.ts`](../../src/utilities/campaignRelationOptions.ts) (`loadMunicipalityOptions`) — escopo do assessor para sugestões
|
||||
- [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (**B27**), [`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) (**B31**), [`gerenciar-assessores.md`](gerenciar-assessores.md) (**B19 ✓**), [`ordenacao-filtros-lista-dobradinhas.md`](ordenacao-filtros-lista-dobradinhas.md) (**B33**)
|
||||
- AGENTS.md — Campaign Municípios model, Transaction Safety, naming inglês/copy pt-BR
|
||||
- `.cursor/rules/campanha-edit-where-you-see.mdc`, `.cursor/rules/campanha-action-feedback.mdc`
|
||||
145
docs/plans/chips-municipios-lista-liderancas.md
Normal file
145
docs/plans/chips-municipios-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Chips editáveis de municípios na lista de lideranças (paridade com Assessores)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B34**)
|
||||
Impeccable: B — encaixe na coluna "Municípios" de `/campanha/liderancas`; sem rota nova
|
||||
Appetite: ~1–1,25 dia eng — promoção de lib/loader puros existentes, 1 componente de célula novo (Popover + Command), 2 server actions + schema, provider de catálogo; sem migration
|
||||
Responsável: —
|
||||
|
||||
## ⚠️ Conflito com o pedido literal — leia antes de implementar
|
||||
|
||||
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma tabela** (`/campanha/liderancas`) já travou três vezes a decisão oposta: **B28** ("edição in-list adiada"), **B31** ("paridade é da coluna, não do modo... o toggle 'Editar' de tabela inteira... é o anti-goal literal do princípio 3 do `PRODUCT.md` já travado no B28") e **B32** ("sem depender do toggle 'Editar' de tabela inteira, anti-goal nomeado explicitamente no pedido e já travado no B28/B31"). Este plano **segue o precedente travado**, não o texto literal do pedido — ver "Decisões travadas" abaixo. Todas as demais funcionalidades pedidas (chips, navegação, remover, adicionar por digitação, território/ZE em lote) estão contempladas; só o **container** da edição muda de "modo de tabela" para "Popover por célula". _(proposto — validar com produto antes de implementar; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32 também.)_
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 3 **Edit where you see** com o anti-goal explícito "spreadsheet mode" / "always-mounted inputs on every row", princípio 4 **Auto-save, no Save button**, princípio 8 **Feel the action**) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · precedentes vivos: [`MunicipalityListAdvisorsControl.tsx`](../../src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx) (Popover disparado pela célula, **B9 ✓**), [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (**B27** — chips removíveis + `Command` dentro do Popover, gravação por delta sem "Salvar"), [`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) (**B31** — mesma tabela, mesma decisão de container, delta com lock + `revalidatePath` + provider de catálogo uma vez por tabela), [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) / [`advisorMunicipalityPortfolio.ts`](../../src/lib/advisorMunicipalityPortfolio.ts) (**B19 ✓** — a lógica pura de agrupar território/ZE e buscar, que este item reaproveita sem portar a UI inteira).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto (Popover + `Command` já são peças do produto) → critique → polish.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG/Assessor dividindo carteira e corrigindo cobertura territorial das lideranças durante o onboarding, varrendo `/campanha/liderancas` — hoje só é possível ver o nome dos municípios (texto morto, sem link) e só é possível editar abrindo a ficha e usando o `RelationMultiSelect`.
|
||||
- **Job principal:** ver, navegar e ajustar os municípios de uma liderança sem sair da lista, no mesmo gesto de digitar-e-adicionar já em produção em `/campanha/assessores` e `/campanha/municipios`.
|
||||
- **Estratégia de cor:** Restrained — chips `Badge variant="secondary"`, chip de território com `MapPinIcon`, sem cor semântica nova.
|
||||
- **Edit where you see:** sim — `canManageLeadership` já autoriza a escrita; a célula é hoje um readout morto do mesmo dado que o formulário completo edita.
|
||||
- **Anti-goals:** modo "Editar" de tabela inteira (ver conflito acima); inputs montados em todas as linhas; "Salvar" no Popover; segundo design system de célula de relação (reaproveitar `Command`/`Popover`/`Badge`, não inventar).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície já existente (`LeadershipRowViewModel.municipalityNames`, hoje texto puro) — este item troca a apresentação, não introduz agregação nova.
|
||||
- **Decisões desbloqueadas:** CG/Assessor: "que municípios esta liderança cobre — e onde erro a carteira?" (hoje só se sabe abrindo a ficha, um clique por liderança, no meio da varredura de 25+ linhas); Assessor: "isso já está dentro do que eu administro?" (feedback imediato ao adicionar, o servidor recusa fora do escopo). Navegação cruzada: da liderança para a ficha do município (o inverso, `MunicipalityLeadershipsPanel`/dossiê **E16 ✓**, já existe).
|
||||
- **Forma escolhida:** **tabela/lista** — chips nomeados com link, igual ao tratamento de Organizações/Dobradinhas da mesma tabela. **Rejeitado:** contagem ("3 municípios" — some o nome que decide a carteira); mapa (é vínculo nominal, não leitura territorial agregada — isso é papel do mapa em `/campanha`, não desta célula).
|
||||
- **Profile:** categórico; granularidade `leadership` × `municipality`; tamanho típico 1–5 chips (piso 1, teto de schema 30); catálogo de 435; absoluto (o nome em si).
|
||||
- **Anti-goals de dado:** sem métrica eleitoral na célula; sem ranking de lideranças por município.
|
||||
|
||||
Self-check dados: 5/5.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/liderancas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>)) lista lideranças com a coluna "Municípios" renderizando `row.municipalityNames.join(', ')` — texto puro, sem link, sem edição ([`leadershipData.ts:106-109`](../../src/utilities/leadershipData.ts)). A única forma de mudar os municípios de uma liderança hoje é abrir `/liderancas/[id]`, usar o `RelationMultiSelect` do [`LeadershipInternalForm`](../../src/components/campaign/leadership/LeadershipInternalForm.tsx) (substituição integral do array) e submeter.
|
||||
|
||||
`leadership.municipalities` é `hasMany`, `required: true`, `maxRows: MAX_LEADERSHIP_MUNICIPALITIES` (30) ([`src/lib/schemas/leadership.ts:38-46`](../../src/lib/schemas/leadership.ts)), com o hook `requireAtLeastOneMunicipality` ([`src/collections/Leadership.ts`](../../src/collections/Leadership.ts)) recusando qualquer update que zere o array — diferente do lado assessor↔município (**B19 ✓**), onde o vínculo é a reverse relationship `municipality.advisors` e pode ir a zero. `assertMunicipalitiesWithinScope` ([`actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>)) já restringe assessores a municípios que administram, hoje aplicado ao array inteiro recebido por `createLeadershipRecord`/`updateLeadershipInternalRecord`.
|
||||
|
||||
Em `/campanha/assessores` (**B19 ✓**), a coluna "Municípios" resolve o padrão pedido — mas com o container que **B27/B28/B31/B32 já deixaram obsoleto para esta geração de listas**: [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) (~470 linhas) é uma célula sempre-visível com toggle de tabela inteira (`AdvisorsTable`) e um `ResizeObserver` para empacotar chips em 3 linhas antes de "Ver mais…". A lógica **pura** de agrupar município/território/ZE e buscar (`buildAdvisorPortfolioChips`/`searchAdvisorPortfolio`, [`advisorMunicipalityPortfolio.ts`](../../src/lib/advisorMunicipalityPortfolio.ts)) é boa e reaproveitável; a **UI** de célula não é o alvo certo para copiar aqui.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Coluna "Municípios" de `/campanha/liderancas` mostra **chips** (não mais texto plano); cada chip de município navega para `/campanha/municipios/[slug]`; chip de território (quando a liderança cobre o TI inteiro) mostra `MapPinIcon` e não navega.
|
||||
- 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 de identidade ou zona eleitoral — adicionar/remover grava **na hora**, sem botão "Salvar".
|
||||
- Adicionar/remover um **território ou ZE inteiro** de uma vez (lote), igual ao padrão de `/campanha/assessores` — paridade explicitamente pedida ("todas as outras funcionalidades").
|
||||
- Nunca é possível zerar os municípios de uma liderança pela lista: o cliente desabilita a remoção do último município (ou do último território/ZE que cobriria o resto) e o servidor recusa com a mesma mensagem do formulário completo, como defesa em profundidade.
|
||||
- Assessor só pode **adicionar** municípios/territórios/ZE que administra (mesma regra de `assertMunicipalitiesWithinScope`); as sugestões de busca já vêm restritas a esse escopo para não oferecer becos sem saída. Os chips já atribuídos (mesmo fora do escopo do assessor) continuam visíveis e agrupados corretamente contra o catálogo completo.
|
||||
- Access idêntico ao já existente (`canManageLeadership` escreve, leitura já resolvida no loader); nenhuma migration, nenhuma collection, nenhum Consent novo, nenhuma mudança no contrato de URL da lista.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Popover disparado por célula, não modo "Editar" de tabela inteira.** Ver o aviso de conflito no topo. É a mesma decisão travada três vezes na mesma tabela em 2026-07-25 (B28, B31, B32): o toggle de `AdvisorsTable` existe lá porque **todas** as colunas daquela tela são editáveis; aqui só uma coluna é. **Rejeitado:** toggle "Editar" de tabela inteira (pedido literal do usuário — anti-goal do princípio 3, reabriria B28/B31/B32); célula sempre em edição sem Popover (inputs montados em 25+ linhas).
|
||||
- **Mutação por delta direto em `leadership.municipalities`** (ler o array atual do próprio documento, aplicar o toggle/lote, `payload.update`), nunca substituição do array inteiro via `updateLeadershipInternalRecord`. **Por quê:** aquele record valida o array **inteiro** contra o escopo do assessor — reenviá-lo quebraria qualquer liderança cujos municípios já ultrapassem o escopo de um assessor (criada pela coordenação, por outro assessor, ou herdada da remodelagem). A rota de delta valida só o(s) id(s) sendo **adicionado(s)**, espelhando `setAdvisorMunicipalityMembership`. **Rejeitado:** reenviar o array completo (quebra o caso cross-boundary); recalcular a validação para aceitar "o que já estava lá" (duplica lógica que o delta resolve de graça).
|
||||
- **Lock único por liderança (`leadership-municipalities:{id}`), não por município tocado.** Ao contrário do B19 (o array mora no `municipality.advisors`, então cada delta trava o **município**), aqui o array mora no próprio `leadership`, então uma única trava por documento basta — mesmo padrão do B31 (`leadership-state-deputies:{id}`). **Rejeitado:** travar por município como no B19 (a escrita real é num único documento; travar N municípios para um lote de território seria lock desnecessário e mais chance de deadlock entre lotes concorrentes).
|
||||
- **Piso de 1 respeitado em duas camadas:** cliente desabilita a remoção que zeraria o array (chip individual ou território/ZE cujo `municipalityIds` cobre o restante); servidor mantém `requireAtLeastOneMunicipality` como defesa em profundidade (a mesma mensagem "Vincule a liderança a pelo menos um município." já existe e passa pelo `mapCampaignFormActionError`). **Rejeitado:** só servidor (deixaria o clique de remover o único chip terminar em toast de erro para uma ação obviamente inválida).
|
||||
- **Território/ZE em lote incluído** (não cortado) — reaproveita `searchAdvisorPortfolio`/`buildAdvisorPortfolioChips` sem custo extra de lib; o trabalho novo real é só a action de lote espelhando `setAdvisorMunicipalitiesBatch`. Pedido explícito do usuário ("todas as outras funcionalidades").
|
||||
- **Promover a lógica pura para nomes neutros — `src/lib/advisorMunicipalityPortfolio.ts` → `src/lib/municipalityPortfolio.ts`, `loadAdvisorMunicipalityIndex` (`advisorData.ts`) → `loadMunicipalityPortfolioIndex` (`src/utilities/municipalityPortfolioIndex.ts`) — mas NÃO portar/generalizar a UI de `AdvisorMunicipalityCell`.** A lib e o índice não têm nada de específico de assessor (catálogo/busca de município), e o prefixo `advisor*` violaria a convenção de prefixo de domínio (`codebase-map.mdc`) assim que um 2º domínio consumir. A UI, porém, resolve um problema que este item não tem (medir quantos chips cabem em 3 linhas de uma célula sempre-visível) — construir um componente novo e mais simples (Popover + `Command`, sem `ResizeObserver`) é mais barato e mais alinhado ao container já decidido acima. **Rejeitado:** portar `AdvisorMunicipalityCell` inteiro para `shared/` (draft anterior deste plano) — a máquina de medição de layout não serve ao container em Popover, e extrair um "editor de chips" genérico a partir de um caso e meio (este + B27, se ainda não entregue) é prematuro (ver Rabbit holes/Adiado).
|
||||
- **Sugestões de busca restritas ao escopo do assessor; chips já atribuídos agrupados pelo catálogo completo.** O agrupamento "cobre o TI inteiro" só é correto matematicamente com o catálogo completo; já as sugestões de **adição** devem ficar restritas ao escopo do assessor (mesma regra que `RelationMultiSelect` já aplica no formulário completo) — sugerir município fora do escopo é beco sem saída, o servidor vai recusar. **Rejeitado:** índice único sem escopo em toda parte (sugere o que não pode ser adicionado); índice único escopado em toda parte (quebra o agrupamento de território para a maioria dos visitantes desta lista, que são assessores).
|
||||
- **Server action + `revalidatePath`, não endpoint JSON (diferente do B27, igual ao B31).** A lista de lideranças tem 25 linhas (não 435), e há dois derivados a jusante que ficariam obsoletos: o próprio detalhe `/campanha/liderancas/[id]` (mesma liderança) e o painel de lideranças do **município** tocado (`MunicipalityLeadershipsPanel`/dossiê **E16 ✓**, que lê `leadership.municipalities` pelo lado do município). Revalidar `/campanha/liderancas`, `/campanha/liderancas/[id]` e `/campanha/municipios/[slug]` de cada município tocado (add **e** remove) na mesma chamada. **Rejeitado:** endpoint JSON espelho do `expected-votes/` (barato aqui, mas deixaria o painel do município mentindo); só `router.refresh()` (não limpa a rota do município).
|
||||
- **Catálogo entregue uma vez por tabela via provider cliente** (`MunicipalityPortfolioOptionsProvider`, mesmo padrão do `MunicipalityEstimateScenarioContext` e do provider de dobradinhas do B31): `{ index: catálogo completo (435), addableIds: Set<number> | null (restrição do assessor) }`. **Rejeitado:** props por célula (multiplicaria o payload por linha); buscar o catálogo no cliente a cada tecla (é estático, 435 entradas cabem no cliente).
|
||||
- **Ícone de edição como alvo de clique separado dos chips, não a célula inteira.** Os chips de município são `Link`s de navegação; um `<button>` (Popover trigger) não pode conter outro elemento interativo (`<a>`) sem quebrar semântica/acessibilidade, e usar a célula inteira como trigger criaria ambiguidade entre "clicou no chip = navegar" e "clicou no fundo = editar". Um ícone (lápis/`+`) ao fim da linha de chips é o alvo de clique inequívoco. **Rejeitado:** célula inteira como `PopoverTrigger` (conflito de hit-target com os chips-link); chip clicável só em modo edição via toggle (reabre o container rejeitado acima).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`LeadershipMunicipalitiesCell`, `MunicipalityPortfolioOptionsProvider`, `setLeadershipMunicipalityMembership`, `setLeadershipMunicipalitiesBatch`, `nextMunicipalityIdsAfterLeadershipMembership`), strings visíveis em pt-BR ("Editar municípios", "Buscar município, território ou ZE…", "Remover Salvador", "Vincule a liderança a pelo menos um município.").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Toggle de tabela (pedido literal) vs Popover por célula (precedente travado).** Já registrado como conflito no topo — **Recomendação: Popover**, por consistência com B28/B31/B32 na mesma tabela no mesmo dia. _(proposto — validar com produto antes de codar; é a decisão mais cara de reverter deste plano.)_
|
||||
- **"Ver mais…" quando há muitos chips?** **Opções:** A) não — `whitespace-normal` deixa a célula crescer, sem medição por `ResizeObserver` | B) portar a medição do `AdvisorMunicipalityCell`. **Recomendação:** **A** — sem evidência de liderança com carteira grande hoje (teto de schema é 30, mas o uso real é de poucos municípios por pessoa); a medição é a parte mais cara do componente de origem e não se paga sem evidência. Registrado em Adiado com gatilho.
|
||||
- **Ícone do trigger de edição.** **Opções:** A) `PencilIcon` pequeno, `aria-label="Editar municípios"` | B) reaproveitar o mesmo affordance visual do `MunicipalityListAdvisorsControl` (hover na célula muda o cursor, sem ícone visível). **Recomendação:** **A** — os chips já são links visualmente distintos; um ícone explícito evita que o alvo de edição fique invisível numa célula que mistura navegação e edição. _(assumido)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
loader["loadLeadershipListPageData<br/>+ municipalities (id/nome/slug)"] --> page["liderancas/page.tsx<br/>coluna Municípios"]
|
||||
index["loadMunicipalityPortfolioIndex<br/>(catálogo completo, 435)"] --> provider["MunicipalityPortfolioOptionsProvider<br/>(1× por tabela)"]
|
||||
scope["loadMunicipalityOptions<br/>(escopo do assessor, se advisor)"] --> provider
|
||||
provider --> cell["LeadershipMunicipalitiesCell<br/>chips + ícone editar + Popover + Command"]
|
||||
page --> cell
|
||||
cell -->|delta ou lote| action["setLeadershipMunicipalityMembership /\nsetLeadershipMunicipalitiesBatch<br/>(tx + lock + escopo)"]
|
||||
action --> revalidate["revalidatePath<br/>liderancas · liderancas/[id] · municipios/[slug]×N"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/lib/municipalityPortfolio.ts`** (renomeado de `advisorMunicipalityPortfolio.ts`): mesma lógica pura, tipos/funções neutros (`MunicipalityPortfolioIndexEntry`, `MunicipalityPortfolioChip`, `MunicipalityPortfolioSearchHit`, `buildMunicipalityPortfolioChips`, `searchMunicipalityPortfolio`); `AdvisorMunicipalityCell.tsx` passa a importar daqui (import mecânico, comportamento intacto). `tests/unit/advisorMunicipalityPortfolio.unit.spec.ts` → `tests/unit/municipalityPortfolio.unit.spec.ts`.
|
||||
- **`src/utilities/municipalityPortfolioIndex.ts`** (novo, `server-only`): `loadMunicipalityPortfolioIndex(payload)`, corpo movido de `loadAdvisorMunicipalityIndex` (bypass admin intencional, dado de referência só-leitura); `advisorData.ts` e `assessores/page.tsx` passam a importar daqui.
|
||||
- **`src/components/campaign/leadership/MunicipalityPortfolioOptionsProvider.tsx`** (novo, cliente): contexto com `{ index, addableIds }`, montado uma vez em volta do `CampaignTable` de `/campanha/liderancas`.
|
||||
- **`src/components/campaign/leadership/LeadershipMunicipalitiesCell.tsx`** (novo, cliente): chips de leitura (`Badge` + `Link` para município; `Badge` + `MapPinIcon` para território completo, via `buildMunicipalityPortfolioChips` contra o índice completo do provider) + ícone de edição (`PopoverTrigger`); `PopoverContent` com chips removíveis (mesmo componente de chip, agora com "×") sobre `Command` (`shouldFilter={false}`, itens de `searchMunicipalityPortfolio` contra `addableIds`-restrito quando ator é assessor); estado otimista local (`useState`/`useTransition`, sem `ResizeObserver`); `disabled` no botão de remover quando removeria o último município; `toast.error` + reversão em falha.
|
||||
- **`src/lib/schemas/leadership.ts`**: `leadershipMunicipalityMembershipSchema` (`leadershipId`, `municipalityId`, `assigned: z.boolean()`) e `leadershipMunicipalitiesBatchSchema` (`leadershipId`, `municipalityIds: z.array(positiveRelationshipId).min(1).max(MAX_LEADERSHIP_MUNICIPALITIES)`, `assigned`), espelhando os schemas de assessor (`src/lib/schemas/advisor.ts`).
|
||||
- **`src/app/(campaign)/campanha/actions/leadership.ts`** (acréscimos): `nextMunicipalityIdsAfterLeadershipMembership` (helper puro — `null` se não muda nada, lança a mensagem do piso de 1 se o próximo array ficaria vazio, lança a mensagem do teto de 30 se estourasse); `setLeadershipMunicipalityMembershipRecord`/`setLeadershipMunicipalityMembership` e a variante em lote, cada uma em `withPayloadTransaction` + `getFreshStaffActor` + `acquireTextAdvisoryLocks(['leadership-municipalities:{id}'])` + `assertMunicipalitiesWithinScope` só nos ids **adicionados** + leitura/escrita do próprio doc com `user`/`overrideAccess: false` + `revalidatePath` das rotas listadas acima (slugs dos municípios lidos na mesma transação).
|
||||
- **`src/app/(campaign)/campanha/(app)/liderancas/formActions.ts`** (novo arquivo na rota da lista, precedente: `[id]/formActions.ts`/`nova/formActions.ts`): casca sobre `runCampaignFormAction`.
|
||||
- **`src/utilities/leadershipData.ts`**: `LeadershipRowViewModel.municipalities: { id: number; name: string; slug: string }[]` substitui `municipalityNames` (estender o `select` de `namesForIds` para trazer `slug`, ou reusar `loadMunicipalityPortfolioIndex` filtrado pelos ids do lote — decidir no craft pelo menor número de queries); consumidores de `municipalityNames` (nenhum fora desta página, a confirmar por `git grep`) acompanham o rename.
|
||||
- **`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`**: `Promise.all` carrega `loadMunicipalityPortfolioIndex(payload)` e, só se `user.role === 'advisor'`, `loadMunicipalityOptions(payload, user)`; envolve `<CampaignTable/>` em `<MunicipalityPortfolioOptionsProvider>`; a coluna `municipalities` troca o texto por `<LeadershipMunicipalitiesCell leadershipId={row.id} municipalities={row.municipalities} />`.
|
||||
- **Testes:** unit de `nextMunicipalityIdsAfterLeadershipMembership` (add/remove/no-op/piso 1/teto 30) e da lib renomeada (cobertura já existente, só imports); int das duas actions — assessor fora do escopo é recusado ao adicionar, assessor no escopo grava, remover o único município é recusado (mensagem do hook), teto de 30 respeitado, delta concorrente serializado pelo lock; e2e opcional (abrir Popover, digitar, ver chip sem "Salvar").
|
||||
- **Migration:** nenhuma.
|
||||
|
||||
Depth check: reusa `Popover`/`Command`/`Badge` do kit, `CampaignTable`, `wordStartFilter` (via `matchesAtWordStart` já em `municipalityPortfolio.ts`), `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `runCampaignFormAction`, `loadMunicipalityOptions`, `campaignAccess`. Não constrói `useAutosave`/`EditableCell`/multi-select genérico novo.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Soft: **B19 ✓** (origem da lib pura e do cálculo de próximo array a espelhar), **B27** (mesma peça de célula — Popover + `Command` + delta; quem entrar depois herda o padrão do primeiro), **B31** (mesma tabela e mesma decisão de container — extração de um editor de chips genérico só no 3º call site real; se B31 e este chegarem antes de qualquer extração, os dois contam como os dois primeiros call sites, não gatilho ainda), **B28/B29/B30/B32** (mesma tabela — colunas/ordem/ação de linha), **B17** (a tabela cresce; seletor de colunas fica mais valioso), **E16 ✓** (o painel de lideranças do município que este item revalida).
|
||||
- Reusa sem alterar: `assertMunicipalitiesWithinScope`, `getFreshStaffActor`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `canManageLeadership` (`src/utilities/access/leaderships.ts`), `loadMunicipalityOptions` (`src/utilities/campaignRelationOptions.ts`), `runCampaignFormAction`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Mesmo padrão de chips editáveis nas colunas "Organizações" e "Dobradinhas" de Lideranças — a de Dobradinhas é o **B31** (item próprio, mesma tabela); Organizações fica para pedido futuro.
|
||||
- Endpoint JSON dedicado (padrão do **B27**) — a lista de lideranças não tem a escala dos 435 municípios fixos; `revalidatePath` via server action basta (ver Decisões travadas).
|
||||
- Corrigir o comportamento pré-existente de `updateLeadershipInternalRecord`/`RelationMultiSelect` (valida o array inteiro contra o escopo do assessor, mesmo para municípios que já estavam lá antes de qualquer assessor existir) — bug não relacionado, fora do pedido.
|
||||
- Criação inline de liderança na própria lista (draft row) — `/liderancas/nova` continua página própria.
|
||||
- "Ver mais…" / colapso de chips por medição de layout — ver Adiado com gatilho.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Generalizar demais o editor de chips agora.** Extrair um `RelationshipChipEditor` agnóstico de coluna a partir de um caso e meio (este + B31, se ainda não entregue) produz parâmetros genéricos sem um 3º call site real para validar a forma. **Mitigação:** construir a célula do domínio (`LeadershipMunicipalitiesCell`); extração só sob o gatilho abaixo.
|
||||
- **"Aproveitar e consertar" o bug de escopo do formulário completo** já que o arquivo `actions/leadership.ts` está aberto. **Mitigação:** só acrescentar funções novas e aditivas; não tocar no fluxo de formulário completo.
|
||||
- **Portar a máquina de `ResizeObserver` do `AdvisorMunicipalityCell`** "já que existe". Ela resolve um problema (empacotar 3 linhas numa célula sempre-visível) que este item não tem, porque o container é um Popover. **Mitigação:** decisão travada acima; revisitar só com evidência de overflow real.
|
||||
- **Renomeação (`advisorMunicipalityPortfolio` → `municipalityPortfolio`) vazar para mais lugares do que o esperado.** **Mitigação:** confirmar com `git grep -w` antes de apagar o arquivo antigo; cobrir com os testes existentes de Assessores (unit + `campaignAdvisorManagement.int.spec.ts`) antes de escrever código novo de Lideranças.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **"Ver mais…" / colapso de chips por medição (`ResizeObserver`).** Revisitar quando aparecer liderança com carteira grande o suficiente para a célula ficar visualmente pesada na lista (sem evidência hoje).
|
||||
- **`RelationshipChipEditor` agnóstico de relação (Municípios/Dobradinhas/Organizações num único primitivo).** Revisitar quando existir o 3º call site real depois deste item e do B31.
|
||||
- **Migrar para o padrão de endpoint JSON do B27.** Revisitar só se a base de lideranças crescer a ponto de `revalidatePath` por clique ficar perceptivelmente lento (sem evidência hoje — ao contrário dos 435 municípios fixos).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, item **B34**; grafo; Janela 1–2)
|
||||
- [`liderancas/page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>), [`leadershipData.ts`](../../src/utilities/leadershipData.ts) — superfície e view model a estender
|
||||
- [`MunicipalityListAdvisorsControl.tsx`](../../src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx) — Popover disparado pela célula (**B9 ✓**)
|
||||
- [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (**B27**) — chips + `Command` + delta sem "Salvar", contrato a espelhar
|
||||
- [`dobradinhas-lista-liderancas.md`](dobradinhas-lista-liderancas.md) (**B31**) — mesma tabela, mesma decisão de container/lock/`revalidatePath`/provider
|
||||
- [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) / [`advisorMunicipalityPortfolio.ts`](../../src/lib/advisorMunicipalityPortfolio.ts) / [`advisorData.ts`](../../src/utilities/advisorData.ts) — lógica pura e índice a promover
|
||||
- [`actions/advisor.ts`](<../../src/app/(campaign)/campanha/actions/advisor.ts>) (`nextAdvisorIdsAfterMembership`, `setAdvisorMunicipalityMembershipRecord`, `setAdvisorMunicipalitiesBatchRecord`) — espelho direto das novas actions
|
||||
- [`actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>), [`src/lib/schemas/leadership.ts`](../../src/lib/schemas/leadership.ts), [`src/collections/Leadership.ts`](../../src/collections/Leadership.ts) (hook `requireAtLeastOneMunicipality`, `MAX_LEADERSHIP_MUNICIPALITIES`)
|
||||
- [`src/utilities/access/leaderships.ts`](../../src/utilities/access/leaderships.ts) (`canManageLeadership`)
|
||||
- [`campaignRelationOptions.ts`](../../src/utilities/campaignRelationOptions.ts) (`loadMunicipalityOptions`) — escopo do assessor para sugestões
|
||||
- [`MunicipalityEstimateScenarioContext.tsx`](../../src/components/campaign/municipality/MunicipalityEstimateScenarioContext.tsx) — precedente de provider cliente escopado
|
||||
- `tests/int/campaignLeadershipActions.int.spec.ts`, `tests/int/campaignAdvisorManagement.int.spec.ts`, `tests/unit/advisorMunicipalityPortfolio.unit.spec.ts`, `tests/helpers/campaignFixtures.ts`
|
||||
- AGENTS.md — Campaign Municípios model, Transaction Safety, naming inglês/copy pt-BR
|
||||
- `.cursor/rules/campanha-edit-where-you-see.mdc`, `.cursor/rules/campanha-action-feedback.mdc`
|
||||
139
docs/plans/combobox-assessores-lista-municipios.md
Normal file
139
docs/plans/combobox-assessores-lista-municipios.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Combobox, chips e auto-save no popover de Assessores da lista de municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B27**)
|
||||
Impeccable: B — encaixe no `MunicipalityListAdvisorsControl` (tabela desktop + card mobile de `/campanha/municipios`); sem rota nova de UI
|
||||
Appetite: ~0,75 dia eng; 1 componente reescrito + 1 route handler JSON + 1 record de delta + 1 helper puro movido; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 clareza sob pressão, 3 **Edit where you see**, 4 **Auto-save, no Save button**, 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `.cursor/rules/campanha-action-feedback.mdc` · precedentes vivos: [`MunicipalityListExpectedVotesControl.tsx`](../../src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx) (auto-save por endpoint JSON, mesma linha da tabela), [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) (chips removíveis + busca + gravação por delta), [`MunicipalityHeaderFilter.tsx`](../../src/components/campaign/municipality/MunicipalityHeaderFilter.tsx) (busca acento-insensível dentro de Popover, na **mesma coluna**).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (sem shape — o Popover, a célula e os dois padrões que este item combina já estão no produto).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) dividindo carteiras durante o onboarding (Onda 0 §4) e reatribuindo municípios ao longo da campanha, varrendo a lista de 435 linhas no desktop e conferindo no celular.
|
||||
- **Job principal:** pôr (ou tirar) um assessor de um município digitando o nome — sem caçar a linha certa numa lista de checkboxes e sem procurar um botão de confirmar.
|
||||
- **Estratégia de cor:** Restrained (inalterada) — chips usam `Badge variant="secondary"`; a única cor da célula continua sendo o alerta "sem responsável" (E9).
|
||||
- **Edit where you see:** sim — a affordance já está na célula desde o **B9 ✓**; faltam o princípio 4 (o "Salvar assessores" é o anti-goal nomeado, e a célula vizinha da mesma linha já grava sozinha) e a legibilidade da seleção atual (hoje: iniciais no gatilho, checkbox no popover).
|
||||
- **Anti-goals:** botão "Salvar" no popover; segundo design system de multi-select genérico; spreadsheet/data-grid mode; combobox que obriga a digitar para ver as opções; popover que fecha a cada atribuição (a mesa atribui 2–3 assessores de uma vez).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não. `Dados: N/A` — o item é affordance de **escrita** sobre `municipality.advisors`; nenhuma métrica, série, ranking ou mapa novo, nenhuma query nova (as opções já vêm de `getEligibleAdvisorOptions` e os nomes de `advisorNamesById`).
|
||||
- **Efeitos de leitura a vigiar:** (a) o badge "sem responsável" (**E9 ✓**) é derivado da própria seleção da célula e passa a sumir/aparecer **na hora**, junto com o estado otimista; (b) faceta do filtro `?advisor=`, o toggle com/sem assessor e a ordenação `coverage` (**B16 ✓**/**B15 ✓**) só reconciliam na próxima navegação — mesma escolha já em produção nos votos estimados e travada no **B24** (ver Decisões travadas).
|
||||
|
||||
## Contexto
|
||||
|
||||
Na coluna "Assessores" de `/campanha/municipios`, o coordenador edita a atribuição desde o **B9 ✓**: [`MunicipalityListAdvisorsControl`](../../src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx) usa a pilha de avatares (`MunicipalityAdvisorAvatarStack`) como `PopoverTrigger` e abre um `<form>` com **uma lista de checkboxes** de todas as contas elegíveis (`getEligibleAdvisorOptions` → `eligibleCampaignStaffWhere`: coordinator, advisor e — desde o **E4R ✓** — candidate), submetendo por `useActionState` → `assignMunicipalityAdvisorsFormAction` → `assignMunicipalityAdvisors` (substitui o array inteiro) → `revalidateMunicipalityListPaths`. O mesmo controle é reusado no card mobile da lista.
|
||||
|
||||
Três defeitos, todos datados:
|
||||
|
||||
1. **Botão "Salvar assessores".** `PRODUCT.md` princípio 4 nomeia literalmente _"Save buttons on Popovers and quick-edit cells 'just in case'"_ como anti-goal, e a célula ao lado na mesma linha (`MunicipalityListExpectedVotesControl`) já grava sozinha via `POST /campanha/municipios/expected-votes`. O **B24** ([autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md)) resolve isso na coluna "Tendência" e registrou este popover em _Não escopo_ ("é multi-seleção... registrado como fill-in próprio") — pedido de produto em **2026-07-25** promoveu o fill-in a item, somando combobox e chips.
|
||||
2. **Lista de checkboxes não escala nem se lê.** O escopo cresce com as contas staff (o E4R já criou uma conta por "ASSESSOR RESPONSÁVEL" da planilha, incluindo placeholders `@planilha.invalid`); rolar uma coluna de checkboxes para achar "Edizio" é o oposto de digitar três letras. O padrão de digitar-e-filtrar já existe **no header da mesma coluna** (`MunicipalityHeaderFilter`, busca acento-insensível acima de 8 opções) e na carteira de `/campanha/assessores` (**B19 ✓**).
|
||||
3. **A seleção atual não é legível dentro do popover.** Quem está atribuído aparece como checkbox marcada no meio da lista; a leitura "quem responde por este município" exige varrer tudo. Em `/campanha/assessores` o mesmo par (entidade ↔ carteira) já se lê como **chips removíveis** com clique.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- O popover **não tem botão "Salvar"**: escolher no combobox grava na hora; clicar num chip remove na hora.
|
||||
- Os assessores atribuídos aparecem como **chips removíveis acima do combobox**, com o nome por extenso (inclusive além dos 3 avatares que cabem no gatilho).
|
||||
- O combobox aceita digitação com filtro acento-insensível e navegação por teclado (setas + Enter), e mostra **todas** as opções quando o campo está vazio.
|
||||
- Feedback honesto na própria célula (princípio 8): pendente (`aria-busy` + spinner), erro com mensagem + rollback para o último estado confirmado, live region `sr-only`; o badge "sem responsável" acompanha o estado otimista.
|
||||
- O popover **permanece aberto** entre atribuições; fecha só por clique fora / Esc.
|
||||
- Access inalterado: mesma regra de hoje (`reloadUnrestrictedActor` — coordenador **ou** candidato no servidor; UI segue coordenador-only, ver _Adiado com gatilho_), limite de `MAX_ADVISORS_PER_MUNICIPALITY` (10) preservado e exibido como erro legível.
|
||||
- Guardrails: **sem migration**, sem collection, sem Consent, sem query nova, sem mudança no contrato de URL da lista. Nada muda para `advisor` (leitura) nem para `leader` (sem a coluna).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Gravação por delta (um assessor por vez: `municipalityId` + `advisorId` + `assigned`), não substituição do array.** É o que a UI de chips significa, é o que o **B19 ✓** já faz do outro lado da mesma relação (`setAdvisorMunicipalityMembershipRecord`, lock `municipality-advisors:{id}`), e evita que duas pessoas editando o mesmo município se sobrescrevam. **Rejeitado:** manter o replace do array inteiro (uma gravação atrasada apaga a adição de outro ator, e sob auto-save isso acontece sem ninguém apertar nada); acumular deltas e gravar no fechamento do popover (é o botão "Salvar" com outro nome, e perde tudo se a aba morrer).
|
||||
- **Endpoint JSON próprio `POST /campanha/municipios/advisors`, espelhando `expected-votes/route.ts`.** O form action atual chama `revalidateMunicipalityListPaths` — recomputar 435 linhas a cada chip é caro e faz a linha sumir sob um filtro `?advisor=` ativo enquanto se trabalha nela; e `useActionState` no `<form>` traz o par toast + `setOpen(false)` que fecharia o popover a cada atribuição. Mesma decisão do **B24**. **Rejeitado:** chamar o server action imperativamente + `router.refresh()` (precedente `AdvisorsTable`, que funciona porque a lista de assessores é curta); manter o form action e só trocar o submit pelo change.
|
||||
- **Não reusar `setAdvisorMunicipalityMembership` (B19); criar `setMunicipalityAdvisorMembershipRecord` em `actions/municipality.ts`.** Aquele record passa por `assertTargetAdvisor`, que recusa alvo com papel ≠ `advisor` e recusa o próprio ator — regras corretas para a tela de contas, **erradas** aqui: o popover lista coordenador e candidato como elegíveis (`eligibleCampaignStaffWhere`, decisão do E4R 2026-07-24) e marca o próprio usuário com "(você)". O que os dois compartilham de verdade é o cálculo puro do próximo array, que vai para `src/lib/municipalityAdvisorMembership.ts` (relocação de `nextAdvisorIdsAfterMembership`, hoje privado em `actions/advisor.ts`, com o limite de 10 junto) — mais o mesmo lock e a mesma validação de elegibilidade do hook `validateMunicipalityAdvisors`. **Rejeitado:** afrouxar `assertTargetAdvisor` para servir as duas telas (muda silenciosamente o que `/campanha/assessores` pode fazer com a conta do CG); duplicar a aritmética do array nos dois lugares.
|
||||
- **Combobox = `Command` (cmdk, `src/components/ui/Command.tsx`) dentro do `PopoverContent` que já existe, com `shouldFilter={false}` + `matchesAtWordStart`/`normalizeSearchPhrase` (`src/lib/wordStartFilter.ts`).** O primitivo já está no produto (`AsyncSearchCombobox`) e entrega input + `role="listbox"` + setas/Enter de graça; o filtro da casa é acento-insensível (o de cmdk não é). **Rejeitado:** repetir o padrão `Input` + lista do `MunicipalityHeaderFilter` (é lista de links de navegação, sem travessia por teclado depois de digitar); `StrictCombobox` (campo de formulário de **um** valor, que limpa no blur); copiar o input + `role="listbox"` manual do `AdvisorMunicipalityCell` (vem casado com a maquinaria de `ResizeObserver` que empacota chips numa célula de tabela, irrelevante dentro de um popover).
|
||||
- **Opções já atribuídas continuam na lista, marcadas com check — não somem.** Alternar ali remove, igual ao chip; some o "para onde foi o nome que eu acabei de marcar". **Rejeitado:** esconder as atribuídas (o `searchAdvisorPortfolio` do B19 esconde porque busca em 435 municípios; aqui são poucas dezenas de contas e a lista **é** o estado).
|
||||
- **Sem revalidação/refresh por gravação.** O estado exibido é local (otimista, confirmado pela resposta); faceta, filtro e ordenação por assessor reconciliam na próxima navegação — mesma escolha em produção nos votos estimados e travada no B24. **Rejeitado:** `revalidatePath` por delta; refresh no fechamento do popover (remove a linha debaixo de quem está trabalhando, só alguns segundos depois).
|
||||
- **Requisições em voo não são abortadas, e a resposta do servidor só é adotada quando não há delta pendente.** Cada delta é um evento distinto que precisa chegar (diferente do debounce de votos, em que o valor novo supera o anterior); o `advisors` devolvido pelo endpoint reconcilia o estado só com o contador de pendências em zero, senão uma resposta antiga desfaria visualmente um clique mais novo. **Rejeitado:** `AbortController` como no controle de votos (cancelaria uma atribuição legítima); ignorar a resposta e confiar só no otimismo (perde a reconciliação com o limite de 10 e com edições de terceiros).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (rota `advisors`, `MunicipalityListAdvisorsResponse`, `setMunicipalityAdvisorMembership`, `nextAdvisorIdsAfterMembership`, `MunicipalityListAdvisorsControl` — nome mantido); strings visíveis em pt-BR ("Buscar assessor…", "Remover <nome>", "Salvando…", "Cada município aceita no máximo 10 assessores.").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O popover de filtro (B16 ✓) e o de edição vivem na mesma coluna e vão ficar parecidos (busca + lista).** **Opções:** A) diferenciar só pelos chips no topo do de edição | B) título explícito ("Atribuir assessores") no de edição | C) redesenhar um dos dois. **Recomendação:** **A + B** — os chips já são um diferenciador forte e o cabeçalho de uma linha custa nada; C é fora do appetite. Verificar no critique. _(assumido)_
|
||||
- **Contas placeholder `@planilha.invalid` (E4R) aparecem no combobox como qualquer outra.** **Opções:** A) nada | B) marcador "conta não ativada" na linha da opção | C) filtrar fora. **Recomendação:** **A** neste item — atribuir um município a uma conta ainda não ativada é justamente o fluxo do onboarding (o B19 ✓ ativa depois); C esconderia a maioria das contas semeadas. Reavaliar se o time atribuir e estranhar que a pessoa não recebe nada. _(assumido)_
|
||||
- **Chip removido por engano — desfazer?** **Opções:** A) nada (re-adicionar pelo combobox) | B) toast com "Desfazer" | C) confirmação antes de remover. **Recomendação:** **A** — precedente direto em `AdvisorMunicipalityCell` (remove no clique, sem confirmar) e a re-adição custa três teclas; C mata o ganho de auto-save. _(assumido)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cell["Célula Assessores<br/>(avatares + 'sem responsável')"] --> pop["Popover"]
|
||||
pop --> chips["Chips removíveis<br/>(atribuídos)"]
|
||||
pop --> combo["Command: input + lista<br/>(matchesAtWordStart)"]
|
||||
chips -->|"clique = remover"| delta["delta { advisorId, assigned:false }"]
|
||||
combo -->|"Enter/clique = atribuir"| delta2["delta { advisorId, assigned:true }"]
|
||||
delta --> post["POST /campanha/municipios/advisors"]
|
||||
delta2 --> post
|
||||
post --> rec["setMunicipalityAdvisorMembership<br/>(lock + transação)"]
|
||||
rec --> resp["{ advisors: number[] } confirmado"]
|
||||
resp -->|"só com 0 pendências"| cell
|
||||
post -.erro.-> rollback["rollback + Alert + live region"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`MunicipalityListAdvisorsControl`** (`src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx`): deixa de receber `formAction`/`municipalitySlug`; some o `<form>`, o `Button` "Salvar assessores" e o `useActionState`. Passa a manter `selectedIDs` otimista + `committedRef` + contador de pendências, renderizando chips (`Badge` + `XIcon`, `aria-label="Remover <nome>"`, padrão de `AdvisorMunicipalityCell`) acima de um `Command` (`CommandInput`/`CommandList`/`CommandItem`, `shouldFilter={false}`), `Alert` de erro e `<p className="sr-only" aria-live="polite">`. O gatilho (`MunicipalityAdvisorAvatarStack` + `MissingAdvisorBadge`) **não muda** — é o que preserva a composição de tooltip do **B23**.
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/advisors/route.ts` + `types.ts`** (novos): `POST` com guarda de mesma origem, `zod` (`municipalityId`, `advisorId`, `assigned`), chamando o record e devolvendo `{ status: 'success', advisors: number[] }`; erros por `mapCampaignFormActionError` com as mensagens seguras de assessores (incl. o limite de 10), `401` em sessão expirada — cópia fiel do contrato de `expected-votes/route.ts`.
|
||||
- **`setMunicipalityAdvisorMembershipRecord` / `setMunicipalityAdvisorMembership`** (`src/app/(campaign)/campanha/actions/municipality.ts`): `withPayloadTransaction` + `reloadUnrestrictedActor` + `acquireTextAdvisoryLocks(['municipality-advisors:{id}'])` + leitura `select: { advisors: true }` + `nextAdvisorIdsAfterMembership` + `payload.update` (`overrideAccess: true` com o comentário de bypass já usado no B19; a elegibilidade do alvo é reconferida pelo hook `validateMunicipalityAdvisors`). A variante exportada **não** revalida (ver decisão); devolve o array final.
|
||||
- **`src/lib/municipalityAdvisorMembership.ts`** (novo, puro): `nextAdvisorIdsAfterMembership` movido de `actions/advisor.ts` junto com o erro do limite de 10; `actions/advisor.ts` passa a importar. Relocação, não abstração nova — nenhum wrapper a mais.
|
||||
- **`MunicipalityList` / `municipios/page.tsx` / `municipalityStaffFormActions.ts`**: a lista para de receber e repassar `assignMunicipalityAdvisorsFormAction` (dois pontos de uso: coluna `advisors` e card mobile). O form action e `assignMunicipalityAdvisors` **permanecem** — `/campanha/municipios/[slug]/editar` (`MunicipalityAdvisorsForm`) continua consumindo. Rodar `pnpm exec knip` para confirmar que nada ficou órfão.
|
||||
- **Depth check:** nenhum `useAutosave`, `EditableCell` ou multi-select genérico novo — `Command`, `Badge`, `Popover`, `wordStartFilter`, `withPayloadTransaction`, `acquireTextAdvisoryLocks` e `mapCampaignFormActionError` já existem e fazem o trabalho pesado.
|
||||
- **Migration:** nenhuma — `municipality.advisors` já existe.
|
||||
- **Testes:** unit do helper puro movido (adiciona/remove/no-op/limite de 10 — hoje não tem teste próprio); unit do parse/erro do route handler; int cobrindo o record (coordenador atribui a si mesmo e ao candidato — o caminho que `assertTargetAdvisor` bloquearia — e assessor comum recebe erro de acesso); e2e em `tests/e2e/campaignMunicipalities.e2e.spec.ts`: abrir a célula, digitar parte do nome, Enter, ver o chip **sem** clicar em botão, reabrir e conferir persistência.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Reusa `getEligibleAdvisorOptions`/`loadAdvisorSummaries` (`src/utilities/municipalityViewModels.ts`), `eligibleCampaignStaffWhere`, o lock e o padrão de delta do **B19 ✓**, o contrato de endpoint de `expected-votes/` e o filtro de `wordStartFilter`.
|
||||
- **Suaves:** **B26** (registrar sinal na célula "Último sinal" — a **exceção** que confirma a regra: lá o submit continua explícito porque é criação atômica multi-campo de registro imutável; aqui é toggle de uma relação. Os dois planos precisam contar essa diferença do mesmo jeito no critique, senão a mesma linha da tabela passa a ter três contratos de gravação sem explicação); **B24** (irmão na coluna vizinha — quem chegar primeiro extrai `isSameOriginRequest` de `expected-votes/route.ts` para `src/utilities/`; o segundo só importa); **B23** (tooltip no **mesmo gatilho**: o trigger fica intacto, e os chips melhoram o fallback de toque que aquele plano presumia da lista de checkboxes — atualizar a frase correspondente lá quando este item entrar); **B16 ✓** (padrão de busca em Popover na mesma coluna e faceta `?advisor=` que fica defasada); **B9 ✓** (origem da célula); **E4R ✓** (semeou as contas de assessor que povoam o combobox); **E9 ✓** (badge "sem responsável" que agora reage otimisticamente).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Auto-save na coluna "Tendência"** → **B24** ([autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md)).
|
||||
- **Tooltip com os nomes no hover da célula** → **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)).
|
||||
- **`MunicipalityAdvisorsForm` em `/campanha/municipios/[slug]/editar`** — continua com submit explícito; é página de formulário, não célula de edição rápida (princípio 4 admite a exceção). Se virar incômodo, ganha o mesmo tratamento no gatilho do 3º controle (ver _Adiado com gatilho_).
|
||||
- **Atribuir território/ZE inteiros de uma vez** (chips agregados do `AdvisorMunicipalityCell`) — aquele caminho é assessor → N municípios e já existe em `/campanha/assessores` (**B19 ✓**); aqui a unidade é um município.
|
||||
- **Criar conta de assessor pelo combobox** → `/campanha/assessores/novo` (**B19 ✓**).
|
||||
- **Reconciliar faceta/ordenação em tempo real** → mesma decisão (e mesmo débito) do controle de votos estimados e do B24.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que são três popovers de auto-save, extrai o shell genérico."** Nasce um `useCampaignAutosave` + `EditableCell` para acomodar select, número por cenário, textarea e multi-seleção com delta — design system paralelo (anti-goal do princípio 3). **Mitigação:** controles nomeados; extração só sob o gatilho abaixo.
|
||||
- **"Já que é combobox, faz um `CampaignMultiSelectCombobox` para todo mundo."** Os call sites divergem de verdade: `RelationMultiSelect` submete hidden inputs em formulário, `AdvisorMunicipalityCell` empacota chips medindo o layout de uma célula, este grava por delta em popover. **Mitigação:** usar o `Command` cru aqui; unificação exige que dois call sites queiram literalmente o mesmo comportamento.
|
||||
- **"Aproveita e faz a lista atualizar sozinha."** Puxa revalidação de 435 linhas, facetas, reordenação e a linha sumindo sob `?advisor=` ativo. **Mitigação:** decisão travada de não revalidar.
|
||||
- **"Se é delta, permite arrastar assessor entre linhas / editar em lote."** Vira spreadsheet mode. **Mitigação:** uma célula por vez; operação em lote é a carteira do B19.
|
||||
- **Composição Tooltip (B23) + Popover no mesmo gatilho.** Tratada de passagem, dá tap duplo e tooltip presa atrás do `PopoverContent`. **Mitigação:** não tocar no gatilho neste item; o contrato (`openOnTouch={false}`, `disabled={open}`) é do B23.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Abrir a edição de assessores na lista também para o `candidate`** (o servidor já permite desde o B19 ✓; a UI é coordenador-only em `page.tsx`, `MunicipalityList` e no `/editar`). Revisitar quando: o candidato pedir, ou o CG delegar a divisão de carteiras — é uma varredura de 3 pontos, não cabe junto da reescrita do controle.
|
||||
- **Shell compartilhado de auto-save em popover de lista.** Revisitar quando: existir o **3º** controle com a mesma máquina (candidatos: `budgetNotes`, prioridade).
|
||||
- **Mesmo tratamento em `MunicipalityAdvisorsForm` (`/editar`).** Revisitar quando: alguém editar assessores por lá com frequência (hoje o caminho natural é a lista).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, B27; grafo; Janela 1–2; cortes seguros)
|
||||
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — superfície alvo (checkbox list + "Salvar assessores")
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` (~290–320 e card mobile ~483–494) — os dois pontos de uso do controle
|
||||
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx` — máquina de auto-save (pendência, rollback, live region) a espelhar
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/expected-votes/route.ts` e `types.ts` — contrato do endpoint e `isSameOriginRequest`
|
||||
- `src/components/campaign/advisor/AdvisorMunicipalityCell.tsx` — chips removíveis + gravação por delta (padrão visual e de interação)
|
||||
- `src/app/(campaign)/campanha/actions/advisor.ts` (~90–110, 187–252) — `nextAdvisorIdsAfterMembership`, `assertTargetAdvisor`, lock `municipality-advisors:{id}`
|
||||
- `src/app/(campaign)/campanha/actions/municipality.ts` — `assignMunicipalityAdvisorsRecord` (o replace que este item substitui na lista)
|
||||
- `src/utilities/municipalityViewModels.ts` — `getEligibleAdvisorOptions`, `loadAdvisorSummaries`
|
||||
- `src/collections/Municipality.ts` — campo `advisors` (access, `filterOptions`) e hook `validateMunicipalityAdvisors`
|
||||
- `src/lib/schemas/municipality.ts` — `MAX_ADVISORS_PER_MUNICIPALITY`
|
||||
- `src/components/campaign/municipality/MunicipalityHeaderFilter.tsx` e `src/lib/wordStartFilter.ts` — busca acento-insensível em Popover na mesma coluna
|
||||
- `src/components/ui/Command.tsx` · `src/components/campaign/shared/AsyncSearchCombobox.tsx` — primitivo de combobox já em uso
|
||||
- `docs/plans/autosave-tendencia-lista-municipios.md` (**B24**) · `docs/plans/tooltip-celulas-listas.md` (**B23**) · `docs/plans/gerenciar-assessores.md` (**B19 ✓**) · `docs/plans/edicao-rapida-lista-pracas.md` (**B9 ✓**)
|
||||
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`, escrita transacional
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 2, 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
|
||||
109
docs/plans/convite-whatsapp-lista-liderancas.md
Normal file
109
docs/plans/convite-whatsapp-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Convite por WhatsApp na lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B30**)
|
||||
Impeccable: B — encaixe de uma ação na tabela existente de `/campanha/liderancas`
|
||||
Appetite: ~0,25–0,5 dia eng; zero migration/collection/action nova; um componente client + uma coluna
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (register Field Desk) · tema `data-theme='campaign'` · princípios `campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (a UI já existe em dois lugares — `LeadershipInviteButtons.tsx` no detalhe e o ícone de WhatsApp em `AdvisorsTable.tsx` — este item só compõe os dois padrões numa coluna nova).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Coordenador/assessor varrendo a lista de lideranças para cobrar cadastro incompleto, sem querer abrir cada ficha.
|
||||
- **Job principal:** disparar o convite de completar cadastro por WhatsApp sem saltar de tela.
|
||||
- **Estratégia de cor:** Restrained (default) — botão ghost/ícone, sem cor de destaque nova.
|
||||
- **Edit where you see:** sim — ação existente (`createCampaignInvite`) fica disponível na própria linha, mas **não é edição de campo** (é disparo de ação one-shot com efeito colateral externo — abrir link). Por isso o padrão aplicável é o _two-step reveal_ já usado no detalhe (gerar → depois um link real para o usuário clicar), não auto-save.
|
||||
- **Anti-goals:** não reabrir o formulário completo de convites (dois botões, login+autopreenchimento) dentro da linha — só o convite de completar cadastro, que é o pedido; não usar `window.open` imediato pós-await (risco de popup blocker, ver Decisões travadas).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — item é uma ação de disparo (convite), não introduz métrica, série, mapa ou agregado novo.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/liderancas` (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`) já lista lideranças numa `CampaignTable` (Pass 2 W1 — colunas como dado, `leadershipColumns`) com as colunas `name` | `supportStatus` | `municipalities` | `organizations` | `appAccess`. `LeadershipRowViewModel` (`src/utilities/leadershipData.ts`) já expõe `phone: string | null` por linha — não falta nenhum dado no loader.
|
||||
|
||||
A funcionalidade "convidar para completar cadastro por WhatsApp" já existe e funciona, mas só no detalhe (`/campanha/liderancas/[id]`), via `LeadershipInviteButtons` (`src/components/campaign/invite/LeadershipInviteButtons.tsx`): chama a server action `createCampaignInvite` (`src/app/(campaign)/campanha/actions/invite.ts` → `createCampaignInviteForActor` em `src/utilities/campaignInviteCreation.ts`), que cria um `campaignInvite` com token único (`generateCampaignInviteToken`, expira em 7 dias) e monta a URL do WhatsApp (`buildCampaignInviteWhatsAppLink` em `src/utilities/campaignInvite.ts` → `buildWhatsAppUrl` em `src/lib/phone.ts`). Se `contact.phone` estiver vazio, a action lança `Error('Cadastre o celular da liderança antes de gerar o convite.')` — hoje **só** validado no servidor; o botão do detalhe nunca desabilita por telefone.
|
||||
|
||||
Existe um precedente de ícone de ação por linha com disable por telefone inválido: `AdvisorsTable.tsx` (`src/components/campaign/advisor/AdvisorsTable.tsx`, linhas ~411–437) — calcula `whatsAppHrefForPhone(row.phone)` com `normalizeBrazilianPhone` (`src/lib/phone.ts`) e renderiza um `Button variant="ghost" size="icon"` com `MessageCircleIcon`, ativo (âncora `wa.me`) ou `disabled` com `aria-label` explicando a ausência de celular. Esse é o padrão de disable pedido pelo usuário — falta compor com a geração de convite (que a `AdvisorsTable` não tem, porque ali o `wa.me` é só um atalho de contato, sem token).
|
||||
|
||||
Pedido de produto (2026-07-25): adicionar, ao fim de cada linha de `/campanha/liderancas`, uma ação rápida que dispare esse mesmo convite (`kind: 'autopreenchimento'`) sem precisar abrir a ficha, desabilitada quando a liderança não tem celular válido.
|
||||
|
||||
**Coincide no tempo com o item B28** (mesma leva de pedidos de produto de 2026-07-25 — [`email-celular-lista-liderancas.md`](email-celular-lista-liderancas.md)), que também adiciona uma ação de WhatsApp por linha na mesma tabela, mas com **intenção diferente**: o ícone do B28 é o atalho de contato genérico já usado em `AdvisorsTable` (`wa.me` sem mensagem/token — "falar com esta pessoa agora"), enquanto este item é o disparo do **convite de completar cadastro** (gera `campaignInvite` com token e mensagem pronta). São duas ações distintas que, se ambas chegarem à mesma linha, precisam coexistir sem confundir o operador — por isso a coordenação de layout é uma decisão travada abaixo, e a dependência entre os dois itens é **suave** (qualquer um pode ir primeiro).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Nova coluna ao final de `leadershipColumns` com uma ação de "Convidar para completar cadastro (WhatsApp)" por linha.
|
||||
- Reusa a mesma server action `createCampaignInvite` e o mesmo `kind: 'autopreenchimento'` já usados no detalhe — nenhuma regra de negócio nova, nenhuma mudança de access (`src/utilities/access/invites.ts` já escopa advisor a lideranças acessíveis).
|
||||
- Botão desabilitado quando `row.phone` for `null` ou não passar em `normalizeBrazilianPhone` (mesmo critério do `AdvisorsTable`), com `aria-label`/tooltip explicando o motivo.
|
||||
- Sem migration, sem collection nova, sem endpoint novo.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Two-step reveal (gerar → depois um `<a>` real de "Enviar pelo WhatsApp") em vez de `window.open` automático pós-`await`.** O convite exige uma chamada de servidor (criação de token) antes de existir a URL do WhatsApp; abrir a aba automaticamente depois de um `await` quebra o vínculo síncrono com o gesto do usuário e é bloqueado por popup blockers em Safari/Firefox com frequência. O próprio detalhe (`LeadershipInviteButtons`) já resolve isso mostrando um `Alert` com um link real depois de gerar — este item replica o mesmo contrato, só compactado num `Popover` disparado pela linha. **Rejeitado:** `window.open(whatsappUrl, '_blank')` direto após o `await` da action — risco de bloqueio silencioso, sem precedente no código; o usuário não teria como saber que nada abriu.
|
||||
- **Reusar `createCampaignInvite`/`kind: 'autopreenchimento'` sem tocar na action, no schema ou na collection `campaignInvite`.** É exatamente a mesma operação já usada no detalhe (mesmo consentimento fail-closed, mesmo token, mesma expiração) — criar uma segunda entrada de convite seria duplicar sem motivo. **Rejeitado:** action dedicada "quick invite" — não há diferença de contrato que justifique um segundo caminho.
|
||||
- **Critério de "celular válido" = `normalizeBrazilianPhone(row.phone)` retorna não-nulo**, o mesmo helper já usado em `AdvisorsTable.tsx` e dentro de `buildWhatsAppUrl`. **Rejeitado:** checar só `Boolean(row.phone)` — aceitaria strings malformadas que a action rejeitaria de qualquer forma no servidor, produzindo um botão "ativo" que sempre falha.
|
||||
- **i18n e naming:** identificadores em inglês — componente `LeadershipInviteRowAction` (`src/components/campaign/invite/LeadershipInviteRowAction.tsx`), coluna `id: 'inviteAction'`; strings visíveis em pt-BR ("Convidar por WhatsApp", "Sem celular cadastrado").
|
||||
- **Ícone distinto do WhatsApp de contato do B28.** Como as duas ações podem coexistir na mesma linha, o ícone deste item usa um glifo diferente do `MessageCircleIcon` genérico (ex.: `SendIcon`/`UserPlusIcon` — craft define o exato) com `aria-label` explícito ("Convidar {nome} para completar cadastro por WhatsApp"), para não parecer um segundo botão de "falar com a pessoa". **Rejeitado:** mesmo ícone dos dois itens — ambíguo sobre qual ação cada clique dispara.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **A coluna nova aparece só para quem pode convidar, ou para todos os papéis que acessam a lista?** **Opções:** A) renderizar sempre, deixando o access da própria `createCampaignInvite` decidir (advisor sem acesso àquela liderança já recebe erro do servidor); B) esconder a coluna para quem não tem permissão de convite. **Recomendação:** A — a página já é staff-only (`isCampaignStaff`, redirect no topo do `page.tsx`) e todo staff que enxerga a linha já pode convidar aquela liderança (mesma regra do detalhe, onde o botão também não checa nada além de estar logado como staff); esconder a coluna duplicaria uma checagem de access que já existe no lado do servidor.
|
||||
- **O ícone/label da ação repete "Convidar para completar cadastro" (texto longo do detalhe) ou vira só um ícone compacto?** **Opções:** A) botão texto completo (não cabe numa linha de tabela); B) ícone `MessageCircleIcon`/similar com `aria-label` completo, igual ao ícone de WhatsApp da `AdvisorsTable`. **Recomendação:** B — consistente com o precedente e com a densidade da tabela de lideranças (5 colunas hoje).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Row[Linha da tabela<br/>leadershipColumns] --> Action[LeadershipInviteRowAction<br/>client island]
|
||||
Action -->|phone inválido| Disabled[Botão disabled<br/>aria-label explica]
|
||||
Action -->|clique| Popover[Popover: Convidar por WhatsApp]
|
||||
Popover -->|gerar| Invite[createCampaignInvite<br/>kind: autopreenchimento]
|
||||
Invite --> Result[Alert com<br/>âncora Enviar pelo WhatsApp]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`LeadershipInviteRowAction`** (novo, `src/components/campaign/invite/LeadershipInviteRowAction.tsx`, client): recebe `leadershipID: number`, `name: string`, `phone: string | null`. Calcula validade com `normalizeBrazilianPhone` (`src/lib/phone.ts`, reusado, sem duplicar `whatsAppHrefForPhone`). Renderiza um `Popover` (`@/components/ui/popover`, já em uso no B9/B24/B26) com trigger `Button variant="ghost" size="icon"` (`MessageCircleIcon`, `disabled` quando telefone inválido, `aria-label` diferenciado). Conteúdo do Popover: replica o fluxo de `LeadershipInviteButtons` só para `kind: 'autopreenchimento'` — `useTransition` + chama `createCampaignInvite({ leadership: leadershipID, kind: 'autopreenchimento' })` (import direto de `@/app/(campaign)/campanha/actions/invite`, sem mudança de assinatura) e, no sucesso, mostra a âncora real `whatsappUrl` (mesmo tratamento de erro de consentimento ausente que `LeadershipInviteButtons` já tem).
|
||||
- **`leadershipColumns`** (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`): novo item ao final do array, `id: 'inviteAction'`, `head: <CampaignTableHead align="right"><span className="sr-only">Ações</span></CampaignTableHead>`, `cell: (row) => <LeadershipInviteRowAction leadershipID={row.id} name={row.name} phone={row.phone} />`, `cellClassName: 'text-right'` (mesmo padrão de `AdvisorsTable`).
|
||||
- **Sem migration, sem collection, sem server action nova, sem endpoint.** `LeadershipRowViewModel.phone` já existe no loader; `createCampaignInvite` e `campaignInvite` ficam intactos.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Soft: B28** (e-mail + celular na lista de lideranças, [plano](email-celular-lista-liderancas.md)) — mesma tabela, mesma área de ação por linha; qualquer um dos dois pode ir primeiro, mas quem chegar depois precisa acomodar o ícone do outro na mesma célula/coluna de ações (ver Decisão "ícone distinto" acima). Não é dependência dura: este item não precisa das colunas E-mail/Celular do B28 para funcionar (já tem `phone` no view model hoje).
|
||||
- Reusa integralmente, sem depender de nenhum plano aberto: `createCampaignInvite` (`src/app/(campaign)/campanha/actions/invite.ts`), `campaignInvite` collection + access (`src/collections/CampaignInvite.ts`, `src/utilities/access/invites.ts`), `buildWhatsAppUrl`/`normalizeBrazilianPhone` (`src/lib/phone.ts`), `LeadershipRowViewModel.phone` (`src/utilities/leadershipData.ts`), `CampaignTable`/`CampaignTableHead` (`src/components/campaign/shared/CampaignTable.tsx`), `Popover` (`src/components/ui`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Convite de acesso ao app (`kind: 'login'`) na lista — só existe no detalhe hoje (condicionado a `supportStatus === 'engajado'`); o pedido do usuário é só o convite de completar cadastro. Se precisar, é extensão trivial do mesmo componente, mas fora deste item.
|
||||
- Disparo em massa / seleção múltipla de lideranças para convite em lote — vedado por Res. TSE 23.610 art. 33 e fora do desenho do produto (ver `docs/roadmap.md` § Fora de escopo); esta ação é sempre 1:1, uma linha por vez.
|
||||
- Editar/validar o telefone da liderança a partir da lista — isso é o formulário de edição existente (`/campanha/liderancas/[id]/editar` ou equivalente); esta ação só lê `contact.phone`.
|
||||
- Programa WhatsApp interno D3–D5 (bridge de sessão, inbox) — este item usa o mesmo `wa.me` manual de sempre, não abre canal novo.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou na linha, deixa eu também mostrar o link/texto copiável direto na célula, sem Popover."** Empilhar `Alert` + duas ações (WhatsApp + copiar link) dentro de uma célula de tabela quebra a densidade das outras 434 linhas quando várias forem abertas ao mesmo tempo. **Mitigação:** manter o resultado dentro do `Popover` (fecha ao clicar fora, como qualquer Popover do B9), nunca inline na célula.
|
||||
- **"Vou generalizar para um `RowActionMenu` genérico já que estou tocando a tabela."** Um único botão não justifica um menu de ações genérico (regra do depth check — menos de 3 usos). **Mitigação:** um componente nomeado (`LeadershipInviteRowAction`), sem abstração de menu.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Convite de acesso ao app (`kind: 'login'`) também na lista.** Revisitar se o coordenador pedir a mesma ação para lideranças `engajado` sem abrir o detalhe — nesse ponto o componente já criado aqui vira a base (um segundo trigger no mesmo Popover).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, item B30 — § "Próximos — Campanha")
|
||||
- `src/app/(campaign)/campanha/(app)/liderancas/page.tsx` — `leadershipColumns`, ponto de inserção da coluna
|
||||
- `src/utilities/leadershipData.ts` — `LeadershipRowViewModel.phone`
|
||||
- `src/components/campaign/invite/LeadershipInviteButtons.tsx` — fluxo de referência (gerar → Alert com âncora)
|
||||
- `src/app/(campaign)/campanha/actions/invite.ts`, `src/utilities/campaignInviteCreation.ts`, `src/utilities/campaignInvite.ts` — action + criação de convite + montagem do link
|
||||
- `src/lib/phone.ts` — `normalizeBrazilianPhone`, `buildWhatsAppUrl`
|
||||
- `src/components/campaign/advisor/AdvisorsTable.tsx` (linhas ~86–93, ~411–437) — precedente de ícone de linha com disable por telefone
|
||||
- `tests/unit/campaignInviteInteractions.unit.spec.ts`, `tests/int/campaignInvite.int.spec.ts` — padrão de teste da action/UI de convite existente
|
||||
- AGENTS.md — Local API `overrideAccess: false` (já respeitado pela action reusada), Campaign auth
|
||||
- `PRODUCT.md` / `.cursor/rules/campanha-edit-where-you-see.mdc`, `.cursor/rules/campanha-action-feedback.mdc` — princípios de UI herdados
|
||||
138
docs/plans/dobradinhas-lista-liderancas.md
Normal file
138
docs/plans/dobradinhas-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Coluna de dobradinhas na lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B31**)
|
||||
Impeccable: B — encaixe em `/campanha/liderancas` (coluna + célula editável no `CampaignTable` existente); sem rota nova
|
||||
Appetite: ~1 dia eng (0,75–1,25); coluna + célula de chips com busca e gravação por delta + action nova + provider de opções; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 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) e o popover de edição rápida da lista de municípios (B9 ✓ / **B27**) · shells `CampaignTable` / `CampaignPageShell` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG / Assessor no onboarding da rede, varrendo `/campanha/liderancas` para **amarrar cada liderança à(s) dobradinha(s)** — hoje o único caminho é abrir 1 ficha por vez, rolar até o `RelationMultiSelect` e clicar "Salvar".
|
||||
- **Job principal:** ver e alterar as dobradinhas de uma liderança sem sair da lista, no mesmo gesto com que se lê a linha.
|
||||
- **Estratégia de cor:** Restrained — chips `secondary` como nas demais relações; sem badge de status, sem cor semântica por partido.
|
||||
- **Edit where you see:** **sim** — o campo é mutável pelo papel que está olhando (`canManageLeadership`: irrestrito ou assessor no escopo) e hoje é readout morto na lista. Gravação automática por chip (sem botão "Salvar"), com otimismo no controle e refresh honesto do resultado.
|
||||
- **Anti-goals:** modo "Editar" de tabela inteira (spreadsheet — anti-goal explícito do princípio 3 e do **B28**); inputs montados em todas as linhas; segundo design system de célula editável; chip colorido por partido.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — relação categórica `leadership.stateDeputies` (já persistida, já no view model como `stateDeputyNames`, hoje **não renderizada**).
|
||||
- **Decisões desbloqueadas:**
|
||||
- Coordenador / Assessor: "esta liderança já está amarrada a alguma dobradinha — e a qual?" (evita prometer a mesma perna duas vezes; é o flanco de fogo amigo intra-campo nomeado em [CUSTOMER.md](../CUSTOMER.md), 2026-07-23).
|
||||
- Coordenador: "ao montar o giro/estrutura deste município, quais lideranças já andam com qual candidato a estadual?" (insumo de **E13** e da leitura de **A6**).
|
||||
- Assessor: "quais das minhas fichas ainda estão sem dobradinha e precisam ser preenchidas agora?" (o seed do **E4R ✓** trouxe dobradinha só no município, não na liderança).
|
||||
- **Forma escolhida:** **tabela/lista** — coluna com chips nomeados (link para `/campanha/dobradinhas/<slug>`), igual ao tratamento de Municípios/Organizações da mesma tabela. **Rejeitado:** contagem ("3 dobradinhas") — esconde justamente o nome que decide; KPI "% de lideranças com dobradinha" (vaidade sem fila associada); chart/mapa (dado é nominal e categórico, a pergunta é "quem", não "onde" nem "quanto").
|
||||
- **Profile:** categórico; granularidade `leadership` × `stateDeputy`; tamanho típico 0–3 chips por linha (teto de schema 20), catálogo de dezenas de dobradinhas; absoluto (o nome em si), sem leitura eleitoral relativa.
|
||||
- **Anti-goals de dado:** sem métrica eleitoral derivada nesta célula; sem ranking de lideranças por dobradinha; sem inferir dobradinha da liderança a partir de `municipality.stateDeputies` (união automática mascararia o que é sabido do que é suposto).
|
||||
|
||||
Self-check dados: 5/5.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/liderancas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>)) renderiza um `CampaignTable` com Nome · Status · Municípios · Organizações · Acesso ao app. O loader [`leadershipData.ts`](../../src/utilities/leadershipData.ts) **já resolve** `stateDeputyNames` em `LeadershipRowViewModel` (mesmo `namesForIds` de municípios e organizações) — o dado chega na lista e é descartado, como aconteceu com `sector`/`updatedAt` (**B29**) e `phone` (**B28**).
|
||||
|
||||
Editar dobradinha de liderança hoje = abrir `/campanha/liderancas/[id]`, usar o `RelationMultiSelect` do [`LeadershipInternalForm`](../../src/components/campaign/leadership/LeadershipInternalForm.tsx) (select nativo + chips) e submeter o formulário inteiro. O **E4R ✓** (2026-07-24) importou DOBRADINHAS para `municipality.stateDeputies` e criou as lideranças **name-only** — ou seja, `leadership.stateDeputies` está praticamente vazio e o preenchimento é trabalho de mesa em lote, exatamente durante o onboarding (Onda 0 §4).
|
||||
|
||||
Em `/campanha/assessores` (**B19 ✓**), a coluna "Municípios" da [`AdvisorsTable`](../../src/components/campaign/advisor/AdvisorsTable.tsx) já resolve esse tipo de trabalho: chips clicáveis no modo leitura, chips removíveis + campo de busca (typeahead com agrupamentos por TI/ZE) no modo edição, gravação por delta (`setAdvisorMunicipalityMembership`) com lock e estado otimista, "Ver mais…" quando a carteira não cabe em 3 linhas.
|
||||
|
||||
Pedido de produto (2026-07-25): **a mesma funcionalidade, na lista de lideranças, para dobradinhas.**
|
||||
|
||||
Duas diferenças estruturais que o plano precisa resolver: (1) a `AdvisorsTable` é uma tabela cliente própria com **toggle "Editar" de tabela inteira** — a lista de lideranças usa o sistema de listas do Pass 2 W1 (`CampaignTable`, colunas como dado, servidor) e o toggle full-row é anti-goal declarado no **B28** e no princípio 3; (2) dobradinha não tem agrupamento análogo a território/ZE, então some a metade "batch" do widget do assessor.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Coluna **Dobradinhas** em `/campanha/liderancas`, entre "Organizações" e "Acesso ao app", com chips nomeados que linkam para `/campanha/dobradinhas/<slug>` e "—" quando vazio.
|
||||
- Adicionar/remover dobradinha **na própria linha**, com busca acento-insensível por nome (e partido) sobre o catálogo, **sem botão "Salvar"**: cada chip grava sozinho, com chip otimista, erro revertido + toast e refresh do resultado.
|
||||
- Gravação **por delta** (`leadershipId` + `stateDeputyId` + `assigned`), transacional, com lock consultivo por liderança e teto de `MAX_LEADERSHIP_STATE_DEPUTIES` (20) respeitado no caminho novo.
|
||||
- `LeadershipRowViewModel` passa a carregar `stateDeputies: StateDeputySummary[]` (id/nome/slug/partido) no lugar de só `stateDeputyNames`, sem query extra por linha (mesmo lote já existente).
|
||||
- Contadores derivados de `/campanha/dobradinhas` (`leadershipCount`) e a ficha `/campanha/dobradinhas/[slug]` não ficam obsoletos após a edição.
|
||||
- Guardrails: **sem migration, sem collection, sem Consent, sem mudança de contrato de URL** da lista; access inalterado (`canManageLeadership` para escrever, `canReadStateDeputy` para o catálogo; `overrideAccess: false` nas leituras com `user`); `leader` continua fora da rota.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Item de trilha B31, não absorvido em B28/B29/B30.** B28 é leitura de contato (`Contact`) com anti-goal explícito de edição in-list; B29 é ordenação/filtro do header; B30 é uma ação de linha (convite) que não escreve na ficha. Este item é escrita de relação própria da `leadership` — outro caminho de código (action + lock + provider), outro appetite. (2026-07-25, classificação roadmap-item.) **Rejeitado:** fase do B28 (contamina um slice de leitura com uma action de escrita e dobra o appetite dele); fill-in (não é rename — tem action, lock e access); esperar B29 (nenhuma dependência dura; B29 mexe no head, este item na célula).
|
||||
- **Edição por célula, nunca por modo de tabela.** A paridade pedida é a **da coluna** (chips + busca + auto-save + link), não o toggle "Editar" da tela de assessores, que existe lá porque **todas** as colunas daquela tabela são editáveis (nome/e-mail/celular/carteira). Trazer o modo para lideranças ligaria inputs em todas as linhas e todas as colunas — anti-goal literal do princípio 3 do `PRODUCT.md` e do **B28**. **Rejeitado:** toggle "Editar" na lista de lideranças (spreadsheet; colide com a reforma de header do B29 e dobraria o appetite); célula sempre em edição (inputs montados em 25 linhas).
|
||||
- **Escrita por delta com lock, não replace do array.** `updateLeadershipInternalRecord` grava o array inteiro via `leadershipInternalUpdateSchema` — sob auto-save por chip, dois atores na mesma ficha se sobrescrevem (mesmo argumento do **B27** para a carteira do assessor). Novo par record/action `setLeadershipStateDeputyMembership` dentro de `withPayloadTransaction`, com `acquireTextAdvisoryLocks(['leadership-state-deputies:{id}'])`, leitura com `user` + `overrideAccess: false` (o row access **é** a checagem de escopo do assessor) e cálculo do próximo array espelhando `nextAdvisorIdsAfterMembership`. **Rejeitado:** reusar o update do formulário interno (replace + campos irrelevantes no mesmo schema); escrever pelo lado do `stateDeputy` (a relação mora na `leadership`).
|
||||
- **Server action + `revalidatePath`, não endpoint JSON.** B24/B27 escolheram endpoint sem revalidar porque a lista de municípios tem 435 linhas; aqui a página tem 25 e há **derivados a jusante** — `leadershipCount` da lista de dobradinhas e a lista de lideranças da ficha `/campanha/dobradinhas/[slug]` — que envelheceriam em silêncio. Revalidar `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e a ficha do deputado tocado. **Rejeitado:** endpoint JSON espelho do `expected-votes/` (barato aqui, mas deixa dois readouts derivados mentindo); só `router.refresh()` sem revalidate (não limpa as outras rotas).
|
||||
- **Catálogo de opções entregue uma vez por tabela, via provider cliente.** Passar o índice de dobradinhas por célula duplicaria o payload RSC em cada uma das 25 linhas. Um provider cliente fino em volta do `CampaignTable` (precedente: o provider de cenário de estimativa da lista de municípios, escopado no Pass 2 W4) entrega o catálogo às ilhas. **Rejeitado:** props por célula (payload × linhas); transformar a lista inteira em ilha cliente (abandona o sistema de listas e o streaming do servidor); buscar no servidor a cada tecla (catálogo é de dezenas, cabe no cliente).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`LeadershipStateDeputiesCell`, `setLeadershipStateDeputyMembership`, `nextStateDeputyIdsAfterMembership`, `StateDeputyOptionsProvider`), strings visíveis em pt-BR ("Dobradinhas", "Adicionar dobradinha…", "Remover …").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Container da edição: Popover ou typeahead inline na célula (paridade literal com assessores)?** **Opções:** A) Popover disparado pela célula, com chips removíveis + combobox `Command` dentro | B) inline, edição ao focar a célula (input aparece ao lado dos chips, como no assessor) | C) toggle de tabela. **Recomendação:** **A** — é o contrato de edição in-list de todo o `/campanha` (B9 ✓, B24, B26, B27), o Radix Popover porta para fora do container que **rola horizontalmente** (a tabela de lideranças vai a 8–10 colunas com B28/B29, contra 5 fixas em assessores) e resolve toque sem gesto ambíguo; a leitura da célula continua sendo os chips, então a "funcionalidade da coluna" é a mesma — muda o container. C rejeitado acima. _(assumido — validar com produto; se a mesa quiser o gesto literal do assessor, B é troca de container no craft, não de contrato de dados.)_
|
||||
- **Chip com partido ("Fulano (PT)") ou só nome?** **Opções:** A) só nome na célula, partido no resultado da busca | B) nome + partido sempre. **Recomendação:** **A** — a coluna divide largura com Municípios e Organizações; a desambiguação necessária acontece na hora de escolher. _(assumido)_
|
||||
- **Adicionar filtro por dobradinha no header (B29)?** **Opções:** A) fora deste item | B) junto. **Recomendação:** **A** — o B29 adiou o facet de dobradinha por falta de decisão nomeada; esta coluna cria a decisão ("quem já está amarrado"), então vira gatilho registrado no B29, não escopo aqui.
|
||||
- **Deve existir "Ver mais…" quando houver muitos chips?** **Opções:** A) não — teto prático é 2–3 por liderança, `whitespace-normal` resolve | B) portar a medição do `AdvisorMunicipalityCell`. **Recomendação:** **A**; a medição por `ResizeObserver` é a parte cara daquele componente e não se justifica sem evidência de linha estourada.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
loader["loadLeadershipListPageData<br/>+ stateDeputies (id/nome/slug)"] --> page["liderancas/page.tsx<br/>coluna Dobradinhas"]
|
||||
options["loadStateDeputyOptions<br/>(catálogo staff)"] --> provider["StateDeputyOptionsProvider<br/>(1× por tabela)"]
|
||||
provider --> cell["LeadershipStateDeputiesCell<br/>chips + Popover + combobox"]
|
||||
page --> cell
|
||||
cell -->|delta assigned| action["setLeadershipStateDeputyMembership<br/>tx + lock + access"]
|
||||
action --> revalidate["revalidatePath<br/>liderancas · dobradinhas"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`leadershipData.ts`**: `LeadershipRowViewModel.stateDeputies: StateDeputySummary[]` substitui `stateDeputyNames` (o `namesForIds` do `stateDeputy` passa a selecionar `slug`/`party`, ou o lote reusa `loadStateDeputySummaries`); consumidores atuais (dossiê **E16 ✓**, painel do município) acompanham o rename — sem query nova por linha.
|
||||
- **`liderancas/page.tsx`**: definição de coluna `stateDeputies` (id estável, `cellClassName` `max-w-56 whitespace-normal` como as relações vizinhas) + `loadStateDeputyOptions(payload, user)` no mesmo `Promise.all` do loader da lista; a tabela passa a ser envolvida pelo provider.
|
||||
- **`components/campaign/leadership/LeadershipStateDeputiesCell.tsx`** (ilha cliente): chips (`Badge` + `Link` para `/campanha/dobradinhas/<slug>`) no repouso; Popover com chips removíveis + `Command` (busca via `matchesAtWordStart`/`normalizeSearchPhrase` de `src/lib/wordStartFilter.ts`) para adicionar; `useTransition` + estado otimista + `toast.error` com reversão; `disabled` quando o ator não pode gerenciar a ficha.
|
||||
- **`components/campaign/stateDeputy/StateDeputyOptionsProvider.tsx`**: contexto cliente com o catálogo (`RelationOption` + `slug`); montado uma vez em volta do `CampaignTable`.
|
||||
- **`actions/leadership.ts`**: `setLeadershipStateDeputyMembershipRecord(payload, actor, input)` + wrapper `setLeadershipStateDeputyMembership` — `getFreshStaffActor`, `withPayloadTransaction`, `acquireTextAdvisoryLocks([\`leadership-state-deputies:${id}\`])`, leitura da ficha com `user`/`overrideAccess: false`, `nextStateDeputyIdsAfterMembership`(teto 20, no-op quando já está no estado desejado), update com`user`/`overrideAccess: false`, `revalidatePath` das 4 rotas (slug do deputado lido na mesma transação).
|
||||
- **`liderancas/formActions.ts`** (novo arquivo na rota da lista): casca sobre `runCampaignFormAction`, no padrão do Pass 2 W4d; zod novo em `src/lib/schemas/leadership.ts` (`leadershipStateDeputyMembershipSchema`) reusando `positiveRelationshipId`.
|
||||
- **Testes:** unit de `nextStateDeputyIdsAfterMembership` (adiciona/remove/no-op/teto) e da busca do catálogo; int do action — assessor fora do escopo é recusado, assessor no escopo grava, `leader` recusado, teto de 20 respeitado, delta concorrente serializado pelo lock.
|
||||
- **Migration:** sem migration, sem collection, sem server action de leitura nova (o catálogo já existe).
|
||||
|
||||
Depth check: reusa `CampaignTable`, `Popover`/`Command`/`Badge` do kit, `wordStartFilter`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`, `runCampaignFormAction`, `loadStateDeputyOptions`, `campaignAccess`. Não extrai primitivo genérico de "célula de relação" agora — ver Adiado com gatilho.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Soft: **B27** (mesma peça — chips + combobox + auto-save por delta em popover de célula; quem entrar depois consome o que o primeiro deixou), **B19 ✓** (padrão de interação e o cálculo `nextAdvisorIdsAfterMembership` como espelho), **B28**/**B29**/**B30** (mesma tabela — ordem/quantidade de colunas, reforma do header e a coluna de ação de linha), **B17** (com esta coluna a tabela chega a 8–10 colunas; o seletor fica mais valioso), **E4R ✓** (criou o catálogo de dobradinhas e as fichas vazias que este item preenche).
|
||||
- Reusa do código existente: `leadershipData.ts`, `campaignRelationOptions.ts`, `actions/leadership.ts`, `utilities/access/leaderships.ts`, `utilities/postgresTransactionLocks.ts`, `lib/schemas/leadership.ts`, `components/campaign/shared/CampaignTable.tsx`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Coluna/edição de dobradinhas no painel de lideranças do município e no dossiê (**E16 ✓**) — só a leitura já existente.
|
||||
- Filtro e ordenação por dobradinha no header — gatilho registrado no **B29**.
|
||||
- Editar **Municípios** e **Organizações** in-list pelo mesmo gesto — item próprio se a mesa pedir (aqui só a relação pedida; municípios têm regra extra de escopo do assessor).
|
||||
- Criar dobradinha nova a partir da célula ("adicionar e cadastrar") — `/campanha/dobradinhas/nova` continua sendo o caminho.
|
||||
- Toggle "Editar" / edição de e-mail·celular na lista — **B28** (adiado lá).
|
||||
- Seletor de colunas — **B17**.
|
||||
- Insight de dobradinha 2026 (TSE) — **A6**.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Igual ao assessores" interpretado como portar a `AdvisorsTable` inteira.** Traria toggle de modo, células de texto com debounce e a medição por `ResizeObserver` — e abandonaria o sistema de listas do Pass 2 W1. **Mitigação:** paridade travada na **coluna** (chips + busca + auto-save + link), container decidido no craft.
|
||||
- **Extrair já um `CampaignRelationChipsCell` genérico.** Com B27 ainda não entregue, o "genérico" seria desenhado a partir de um caso e meio, e as duas metades caras do widget do assessor (agrupamento TI/ZE e medição de linhas) não existem aqui. **Mitigação:** construir a célula do domínio; extração só com o 3º call site (ver Adiado).
|
||||
- **Unir dobradinha do município com a da liderança "para a coluna não ficar vazia".** Apagaria a diferença entre vínculo sabido e suposto e envenenaria E13/A6. **Mitigação:** a célula mostra só `leadership.stateDeputies`; "—" é informação verdadeira.
|
||||
- **Catálogo grande no cliente.** Se o registro de dobradinhas crescer muito (centenas), o provider vira payload. **Mitigação:** medir na entrega; acima de ~200 opções, busca no servidor com debounce (gatilho abaixo).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Extrair a célula de relação (chips + busca + auto-save por delta) para `shared/`.** Revisitar quando existir o **3º call site** — hoje seriam este item e **B27** (a carteira do assessor na lista de municípios); o `AdvisorMunicipalityCell` só entra na conta se o agrupamento TI/ZE couber na abstração sem vazar.
|
||||
- **Busca de dobradinha no servidor.** Revisitar quando o catálogo passar de ~200 registros (pós-15/08, quando o TSE publicar candidaturas e **A6** entrar).
|
||||
- **"Ver mais…" / colapso de chips na célula.** Revisitar quando aparecer liderança com >4 dobradinhas em uso real (o teto de schema é 20).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / Janela 1–2 — B30; grafo e paralelizáveis)
|
||||
- [`AdvisorMunicipalityCell.tsx`](../../src/components/campaign/advisor/AdvisorMunicipalityCell.tsx) / [`AdvisorsTable.tsx`](../../src/components/campaign/advisor/AdvisorsTable.tsx) — o padrão pedido (chips, typeahead, otimista, delta)
|
||||
- [`liderancas/page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>) — colunas atuais e shells da lista
|
||||
- [`leadershipData.ts`](../../src/utilities/leadershipData.ts) — `stateDeputyNames` já resolvido no view model
|
||||
- [`actions/leadership.ts`](<../../src/app/(campaign)/campanha/actions/leadership.ts>) / [`actions/advisor.ts`](<../../src/app/(campaign)/campanha/actions/advisor.ts>) — escopo do assessor; espelho do delta com lock
|
||||
- [`stateDeputyData.ts`](../../src/utilities/stateDeputyData.ts) — `StateDeputySummary`, `leadershipCount` (derivado a revalidar)
|
||||
- [`campaignRelationOptions.ts`](../../src/utilities/campaignRelationOptions.ts) — `loadStateDeputyOptions`
|
||||
- [`combobox-assessores-lista-municipios.md`](combobox-assessores-lista-municipios.md) (B27) · [`email-celular-lista-liderancas.md`](email-celular-lista-liderancas.md) (B28) · [`ordenacao-filtros-lista-liderancas.md`](ordenacao-filtros-lista-liderancas.md) (B29) · [`convite-whatsapp-lista-liderancas.md`](convite-whatsapp-lista-liderancas.md) (B30) · [`gerenciar-assessores.md`](gerenciar-assessores.md) (B19 ✓)
|
||||
- AGENTS.md — Campaign auth, naming, `overrideAccess: false`, escrita transacional, `Contact` join
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 3, 4 e 8; Field Desk · `.cursor/rules/campanha-edit-where-you-see.mdc`
|
||||
133
docs/plans/email-celular-lista-liderancas.md
Normal file
133
docs/plans/email-celular-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# E-mail e celular na lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
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: —
|
||||
|
||||
## 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`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG / Assessor no desk ou no celular, varrendo a rede de lideranças para **ligar, copiar contato ou abrir ZAP** sem abrir a ficha de cada pessoa.
|
||||
- **Job principal:** ver e usar e-mail/celular da liderança na própria lista, com o mesmo gesto da tabela de assessores (clique copia; WhatsApp na linha).
|
||||
- **Estratégia de cor:** Restrained — colunas de leitura; sem badge de “tem e-mail”.
|
||||
- **Edit where you see:** **não neste item** — só leitura + copy + `wa.me`. Correção de contato continua no fluxo de criação / detalhe (e-mail/celular **ainda não** são editáveis na ficha interna; ver Adiado). Anti-goal: modo “Editar” full-row na lista de lideranças (spreadsheet).
|
||||
- **Anti-goals:** clonar `AdvisorsTable` inteira; toggle “Editar” + debounce na lista; segunda coluna de ações genérica; expor PII além do escopo staff já autorizado.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — canais de contato já persistidos em `Contact`, hoje só legíveis abrindo `/campanha/liderancas/[id]` (e o `phone` já chega no view model da lista sem UI).
|
||||
- **Decisões desbloqueadas:**
|
||||
- Coordenador / Assessor: “tenho como falar com esta liderança agora (celular / WhatsApp / e-mail) sem sair da varredura?”
|
||||
- Coordenador / Assessor: “esta ficha seedada pela planilha (E4R ✓) ainda está sem telefone — preciso completar antes de convidar?”
|
||||
- **Forma escolhida:** **tabela/lista** — colunas “E-mail” e “Celular” + botão WhatsApp por linha (quando há celular válido). **Rejeitado:** KPI de “% com telefone” (vaidade); chart; coluna só ícone sem número legível; só link “ver ficha”.
|
||||
- **Profile:** categórico/texto (e-mail, telefone BR 11 dígitos); granularidade = `leadership`×`Contact`; tamanho típico dezenas–centenas no escopo do ator; absoluto (o número em si), não relativo eleitoral.
|
||||
- **Anti-goals de dado:** sem métrica eleitoral; sem ranking de “lideranças contactáveis”.
|
||||
|
||||
Self-check dados: 5/5.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/liderancas` ([`page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>)) usa `CampaignTable` com colunas Nome · Status · Municípios · Organizações · Acesso ao app. O loader [`leadershipData.ts`](../../src/utilities/leadershipData.ts) já monta `LeadershipRowViewModel.phone` a partir do `Contact` (`depth: 1`), mas **a lista não renderiza** telefone nem e-mail. E-mail só entra em `LeadershipDetailViewModel` (detalhe).
|
||||
|
||||
Em `/campanha/assessores` (**B19 ✓**), a tabela cliente [`AdvisorsTable.tsx`](../../src/components/campaign/advisor/AdvisorsTable.tsx) mostra E-mail · Celular com:
|
||||
|
||||
- leitura: clique copia para a área de transferência (toast);
|
||||
- vazio: “—”;
|
||||
- celular formatado (`formatBrazilianPhoneInput`);
|
||||
- ação WhatsApp (`buildWhatsAppUrl` / `MessageCircleIcon`) quando o número normaliza.
|
||||
|
||||
Pedido de produto (2026-07-25): **as mesmas colunas e o mesmo funcionamento de leitura/ação** na lista de lideranças.
|
||||
|
||||
Diferença estrutural importante: assessores editam `campaignUser` na própria tabela (toggle Editar + `AdvisorDebouncedTextCell`); lideranças guardam contato em `Contact` via join, e `LeadershipInternalForm` **não** atualiza e-mail/celular após o create. Paridade de **edição** in-list exigiria action nova + locks de telefone — fora do appetite deste slice (ver Adiado / Rabbit holes).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Em `/campanha/liderancas` (staff): colunas **E-mail** e **Celular** na `CampaignTable`, após Nome (ou imediatamente após — ordem travada abaixo).
|
||||
- Células no modo leitura do B19: clique copia; “—” se vazio; celular com máscara BR; e-mail truncável.
|
||||
- Botão/ícone **WhatsApp** por linha quando há celular válido (`wa.me`, `noopener noreferrer`); desabilitado com `aria-label` honesto quando não há.
|
||||
- `LeadershipRowViewModel` inclui `email: string | null` (hoje só no detalhe); `toLeadershipRows` / `contactNameAndPhone` (ou rename) passam a carregar e-mail.
|
||||
- Extrair o primitivo de copy (+ helper WhatsApp de linha, se couber) para `shared/` no **mesmo PR** — este é o 2º call site do padrão do B19; `AdvisorsTable` consome o compartilhado (knip zero; sem divergência).
|
||||
- Guardrails: sem migration, sem collection, sem Consent novo, sem mudança de URL/filtros; access inalterado (`overrideAccess: false` na lista; PII só no escopo staff já permitido por `canReadContacts` / leadership row access); `leader` continua fora da rota.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Item de trilha B28 (não fill-in de rename).** Colunas + interação (copy / WhatsApp) + view model + extrair primitivo compartilhado — ~½–¾ dia, paralelizável, cortável. (2026-07-25, classificação roadmap-item.) **Rejeitado:** fill-in só-string (subestima a interação e o 2º call site); absorver em R6 (atrasa quick win de campo); clonar `AdvisorsTable` como lista de lideranças (abandona `CampaignTable` / Pass 2 W1).
|
||||
- **Paridade = modo leitura + WhatsApp do B19; não o toggle “Editar”.** O job do pedido é contactar na varredura. Edição de `Contact` pós-create não existe nem no detalhe — resolvê-la na lista seria rabbit hole (locks, action, UI de edição em RSC table). **Rejeitado:** debounce in-list neste item; modo spreadsheet; “funcionamento igual” interpretado como CRUD completo da tabela de assessores.
|
||||
- **Ordem das colunas: Nome → E-mail → Celular → Status → Municípios → Organizações → Acesso (+ ações WhatsApp).** Contato perto do nome (padrão assessores). **Rejeitado:** WhatsApp só como texto do celular sem ícone (piora o gesto de campo); coluna WhatsApp sem número visível.
|
||||
- **Extrair primitivo compartilhado agora (2º call site).** `CampaignCopyableTextCell` (ou nome equivalente) + helper `whatsAppHrefForPhone` em `shared/` (ou `lib/phone` se já couber o href). **Rejeitado:** copiar-colar o JSX do `AdvisorsTable` (diverge no 3º uso); abstrair `EditableContactRow` genérica (classitis — só precisamos do copy).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`email`, `phone`, `LeadershipContactCells`); strings pt-BR (“E-mail”, “Celular”).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **WhatsApp: coluna própria de ações ou ícone colado na célula Celular?** **Opções:** A) coluna de ações à direita (espelha assessores) | B) ícone inline na célula Celular. **Recomendação:** **A** — mesmo layout mental do B19; célula Celular fica só número/copy. _(assumido)_
|
||||
- **Busca da lista (`q`) deve incluir e-mail/telefone?** **Opções:** A) não neste item | B) estender o lookup de Contact. **Recomendação:** **A** — pedido é coluna; busca por nome já resolve o caminho feliz. Gatilho: feedback de uso.
|
||||
- **Painel de lideranças no detalhe do município — mostrar telefone também?** **Opções:** A) não | B) sim neste item. **Recomendação:** **A** — fora do pedido (`/campanha/liderancas`); se incomodar, fill-in.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
load["loadLeadershipListPageData<br/>depth 1"] --> vm["LeadershipRowViewModel<br/>+ email"]
|
||||
vm --> table["CampaignTable<br/>liderancas/page"]
|
||||
table --> emailCell["CopyableTextCell<br/>E-mail"]
|
||||
table --> phoneCell["CopyableTextCell<br/>Celular"]
|
||||
table --> wa["WhatsApp icon<br/>wa.me"]
|
||||
shared["shared/CampaignCopyableTextCell"] --> emailCell
|
||||
shared --> phoneCell
|
||||
shared -.reuse.-> advisors["AdvisorsTable"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`leadershipData.ts`**: estender `contactNameAndPhone` (ou equivalente) para `{ id, name, phone, email }`; `LeadershipRowViewModel.email`; detalhe reusa o mesmo campo (sem duplicar parse).
|
||||
- **`liderancas/page.tsx`**: novas colunas `email` / `phone` (+ head de ações WhatsApp se coluna dedicada); células = ilhas cliente do primitivo compartilhado (a `CampaignTable` já aceita `ReactNode` por célula).
|
||||
- **`shared/CampaignCopyableTextCell.tsx`** (nome final no craft): botão com `READ_CELL_CLASS`-equivalente, `navigator.clipboard`, toast sonner; props `value`, `label` (“E-mail”/“Celular”), `emptyDisplay`, `className`.
|
||||
- **Helper WhatsApp:** reusar `buildWhatsAppUrl` / `normalizeBrazilianPhone` de `src/lib/phone.ts`; botão `Button asChild` + `MessageCircleIcon` como no B19.
|
||||
- **`AdvisorsTable.tsx`**: passar a consumir o primitivo compartilhado (mesmo PR — evita dois copy-toasts divergindo).
|
||||
- **Testes:** unit do helper de formatação/empty se extrair lógica; pin opcional de colunas em teste de componentes se já houver precedente para lideranças; int **não** obrigatório (sem access/schema novo) — só se o loader de e-mail precisar de pin de select.
|
||||
- **Migration:** Sem migration, sem collection, sem server action de escrita.
|
||||
|
||||
Depth check: reusa `CampaignTable`, `phone` helpers, padrão visual do B19; extrai só o que o 2º call site justifica; não inventa lista cliente paralela.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura de item aberto. Soft: **B19 ✓** (padrão visual/interação); Pass 2 W1 (`CampaignTable`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Toggle “Editar” / auto-save de e-mail·celular na lista — Adiado (abaixo).
|
||||
- Editar contato na ficha interna / action `updateLeadershipContact` — Adiado.
|
||||
- Busca por e-mail/telefone; filtros novos; sort por coluna.
|
||||
- Painel de lideranças no município (`MunicipalityLeadershipsPanel`) / dossiê.
|
||||
- Programa WhatsApp interno **D3–D5** (bridge) — aqui é só `wa.me` 1:1 como no B19.
|
||||
- Seletor de colunas (**B17**) — se existir, estas colunas entram como `defaultVisible` quando B17 chegar às lideranças (fora deste item).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **“Igual ao assessores” = clonar tabela cliente com modo edição.** Explode access/`Contact` locks e abandona o sistema de listas. **Mitigação:** paridade travada em leitura + WhatsApp; edição adiada.
|
||||
- **Action de update de telefone “só para completar a célula”.** Locks `contact-phone`, merge de duplicatas, Consent — escopo de outro item. **Mitigação:** sem escrita neste PR.
|
||||
- **Promover head genérico / B22 descriptions nesta lista.** Fora do pedido. **Mitigação:** `CampaignTableHead` simples basta; B22 é soft depois.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **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.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / Janela 1 — B28)
|
||||
- [`AdvisorsTable.tsx`](../../src/components/campaign/advisor/AdvisorsTable.tsx) — copy + WhatsApp + empty
|
||||
- [`liderancas/page.tsx`](<../../src/app/(campaign)/campanha/(app)/liderancas/page.tsx>) — colunas atuais
|
||||
- [`leadershipData.ts`](../../src/utilities/leadershipData.ts) — view model / loader
|
||||
- [`phone.ts`](../../src/lib/phone.ts) — normalize / format / `buildWhatsAppUrl`
|
||||
- [`gerenciar-assessores.md`](gerenciar-assessores.md) — B19 (precedente)
|
||||
- AGENTS.md — Campaign auth, naming, `Contact` join, `overrideAccess: false`
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk; Feel the action no copy/WhatsApp (feedback imediato no clique)
|
||||
119
docs/plans/explicacao-colunas-header-listas.md
Normal file
119
docs/plans/explicacao-colunas-header-listas.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Explicação por coluna no header das listas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B22**)
|
||||
Impeccable: B — encaixe no header já existente da lista de municípios (`MunicipalitySortableHead`) + capacidade promovida para o sistema de listas compartilhado
|
||||
Appetite: ~0,5 dia eng; promover 1 componente de domínio para `shared/`, 1 prop em 2 heads, copy das colunas de `/campanha/municipios`; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; anti-goal spreadsheet/data-grid) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells existentes: `CampaignTable` / `CampaignTableHead` (`src/components/campaign/shared/CampaignTable.tsx`), `MunicipalitySortableHead`, `MunicipalityHoverTooltip`, `CampaignInfoHint`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8 (não há dado novo, escrita nem PII neste item).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e o assessor no desktop, varrendo a tabela de 435 municípios com 9 colunas — várias delas nomes curtos de métrica derivada ("Cobertura da meta", "Último sinal", "Votos estimados", "Tendência") cujo significado hoje só existe na cabeça de quem implementou.
|
||||
- **Job principal:** entender, sem sair da lista, o que uma coluna mede antes de ordenar/filtrar por ela.
|
||||
- **Estratégia de cor:** Restrained — a explicação não introduz cor nova; a affordance é tipográfica (sublinhado pontilhado no label).
|
||||
- **Edit where you see:** não se aplica — superfície é leitura/orientação; nenhum campo é editado a partir do header.
|
||||
- **Anti-goals:** um "?" `CircleHelp` por coluna (9 ícones = ruído num header já denso com sort + funil do B16); tooltip cobrindo a célula inteira ou o link do nome; segunda página de glossário concorrendo com `/campanha/conceitos` (E18); parágrafos longos dentro do tooltip.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não — nenhum número novo, agregado ou série. O item apresenta **copy sobre dados já exibidos**. `Dados: N/A` quanto a novas métricas; a ressalva abaixo existe porque a copy nomeia semântica de métrica.
|
||||
- **Decisões desbloqueadas:** Coordenador/assessor: "ordeno por esta coluna ou por outra?" — hoje `deficit` (default do staff) e `frescor` só são legíveis para quem leu o plano do E9.
|
||||
- **Forma escolhida:** texto curto (1–2 frases) em tooltip de hover/foco no próprio header. **Rejeitado:** legenda fixa abaixo da tabela (rouba altura da lista densa); modal/drawer de ajuda (fora de contexto); repetir o conteúdo do `/campanha/conceitos` inline (duplica fonte).
|
||||
- **Anti-goals de dado:** nenhuma definição inventada aqui — para colunas cobertas pelo E18, a frase vem do `oneLiner` do conceito (fonte única); para as demais, a frase descreve o campo tal como o loader o calcula.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/municipios` é a superfície mais densa do produto: tabela de 435 municípios com até 9 colunas no papel staff (Município, Território, Tipo, 2022, Assessores, Tendência, Votos estimados, Último sinal, Cobertura da meta), header já carregado de sort (**B15**) e funil de filtro (**B16**). Várias colunas são métricas derivadas do E8/E9 cujo nome curto não se explica sozinho.
|
||||
|
||||
A capacidade **existe pela metade**: `MunicipalitySortableHead` (`src/components/campaign/municipality/MunicipalitySortableHead.tsx`) já aceita `tooltip?: ReactNode` e o envolve em `MunicipalityHoverTooltip` — mas só **3** colunas passam a prop (`votos`, `frescor`, `deficit`, com as frases montadas ad hoc em `MunicipalityList.tsx:410–415`). As outras seis ficam mudas, e as demais listas do produto (lideranças, organizações, dobradinhas, demandas, apoiadores e a futura **B21** territórios) usam `CampaignTableHead`, que não tem prop nenhuma de explicação. O primitivo de tooltip (hover + foco + tap em touch, com dismiss por `pointerdown`) também mora no domínio `municipality`, apesar de não ter nada de municipal.
|
||||
|
||||
Pedido de produto (2026-07-25): tooltip no hover de cada header de coluna em `/campanha/municipios`, **implementado no componente de header** para ser reaproveitado em outras páginas. Isso também dá evidência datada para o débito **O3** ("jargão") / glossário inline do **R6** — que estava em _A validar_ por falta de pedido concreto.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Toda coluna do header de `/campanha/municipios` (papel staff e papel assessor) expõe uma explicação curta em hover, foco de teclado e tap (touch), sem `?` por coluna.
|
||||
- A capacidade é do **sistema de listas compartilhado**: `CampaignTableHead` (heads simples) e `MunicipalitySortableHead` (head rico) aceitam a mesma prop e usam o mesmo primitivo, disponível para qualquer superfície que use `CampaignTable`.
|
||||
- O primitivo de tooltip deixa de ser de domínio: `MunicipalityHoverTooltip` → `src/components/campaign/shared/CampaignHoverTooltip.tsx`, com os call sites atuais (cards de baseline e conta da cadeira) atualizados no mesmo commit — knip fica em zero.
|
||||
- Para colunas cobertas por um conceito do **E18**, a frase vem do `oneLiner` de `campaignIntelligenceConcepts` (fonte única), não de string paralela.
|
||||
- Sem migration, sem collection, sem server action, sem mudança de contrato de URL. Nada muda para o papel `leader` (a lista não é acessível a ele).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **A explicação é prop dos componentes de header, não campo de `CampaignTableColumn`.** `CampaignTable` recebe `head` como `ReactNode` opaco (por design — heads ricos são ilhas cliente que possuem `aria-sort` e o Popover de filtro); injetar tooltip a partir da tabela exigiria `cloneElement` sobre um nó arbitrário. **Rejeitado:** `description` em `CampaignTableColumn` (a tabela teria que saber montar heads, quebrando a separação que o Pass 2 W1 estabeleceu); `title=` nativo do HTML (sem touch, sem estilo, inconsistente com o resto do produto).
|
||||
- **Um único primitivo compartilhado (`CampaignHoverTooltip`), movido de `municipality/` para `shared/`.** O componente já resolve os três modos (hover, foco, tap com dismiss por `pointerdown`) e trata o próprio conteúdo como "dentro" — comportamento conquistado num critique do E18 que não deve ser reimplementado. **Rejeitado:** segundo componente de tooltip no `shared/` (duas versões divergindo em touch); usar `CampaignInfoHint` (Popover "?") por coluna — 9 gatilhos de 44px num header que já tem sort + funil.
|
||||
- **Copy curta e sem link dentro do tooltip de header.** Conteúdo de Tooltip do Radix não entra na ordem de tabulação; um "Saiba mais" ali seria inalcançável por teclado (o E18 resolveu isso com Popover, que aqui custaria o "?" por coluna). Quem quiser aprofundar tem `/campanha/conceitos` na sidebar. **Rejeitado:** repetir o padrão `MetricExplanation` + link do card de conta da cadeira no header da lista.
|
||||
- **Fonte única para conceitos do E18.** Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `src/lib/campaignIntelligenceConcepts.ts`; a coluna "Cobertura da meta" passa a ler dele em vez da frase própria de hoje. **Rejeitado:** duplicar o texto no `MunicipalityList` (definição de métrica divergindo entre glossário e lista é exatamente o defeito que o E18 nasceu para fechar).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`CampaignHoverTooltip`, `description`, `campaignConceptOneLiner`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **A prop se chama `description` ou continua `tooltip`?** **Opções:** A) `description` nos dois heads (rename do atual) | B) manter `tooltip` | C) `hint`. **Recomendação:** **A** — `tooltip` nomeia o mecanismo, `description` nomeia o conteúdo e sobrevive se o mecanismo mudar (ex.: Popover no mobile). São 3 call sites; rename barato agora, caro depois que 6 listas adotarem. _(assumido)_
|
||||
- **As outras listas (lideranças, organizações, dobradinhas, demandas, apoiadores) ganham copy neste item?** **Opções:** A) só a capacidade, copy quando cada superfície for tocada | B) copy em todas agora. **Recomendação:** **A** — as colunas delas são substantivos concretos ("Partido", "Telefone", "Aberta em") que não pedem glosa; escrever 30 frases defensivas viola "build less" e estoura o appetite. B21 (territórios) nasce com copy porque suas colunas são derivadas.
|
||||
- **Affordance visual do que tem explicação?** **Opções:** A) sublinhado pontilhado no label | B) nada (descoberta por hover) | C) ícone. **Recomendação:** **A** — barato, sem chrome novo, e evita que a explicação só exista para quem passa o mouse por acaso. Validar no critique do R6.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
concepts["campaignIntelligenceConcepts<br/>oneLiner (E18)"] --> copy["municipalityColumnDescriptions"]
|
||||
copy --> cols["municipalityListColumns()"]
|
||||
cols --> rich["MunicipalitySortableHead<br/>description"]
|
||||
cols --> plain["CampaignTableHead<br/>description"]
|
||||
rich --> tip["CampaignHoverTooltip<br/>(shared: hover + foco + tap)"]
|
||||
plain --> tip
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`CampaignHoverTooltip`** (move de `src/components/campaign/municipality/MunicipalityHoverTooltip.tsx` para `src/components/campaign/shared/CampaignHoverTooltip.tsx`): mesmo comportamento, mesma assinatura; atualizar os imports de `MunicipalityBaselineCard.tsx` e `MunicipalityGoalAccountCard.tsx` e o comentário de cabeçalho (deixa de citar "município").
|
||||
- **`CampaignTableHead`** (`src/components/campaign/shared/CampaignTable.tsx`): nova prop `description?: ReactNode`. Quando presente, envolve o label num `<span tabIndex={0}>` com `focus-visible:ring` e `underline decoration-dotted underline-offset-4` dentro do `CampaignHoverTooltip` — o `<th>` em si continua não interativo. Sem `description`, o render é byte a byte o de hoje.
|
||||
- **`MunicipalitySortableHead`** (`src/components/campaign/municipality/…`): `tooltip` → `description`, apontando para o primitivo compartilhado; o gatilho continua sendo o próprio `CampaignTransitionAnchor` de sort (já focável, então o teclado já funciona) e ganha o mesmo sublinhado pontilhado.
|
||||
- **`municipalityColumnDescriptions`** (novo, em `src/utilities/municipalityLabels.ts` — onde os labels da lista já moram): mapa `columnId → string` em pt-BR com as 9 colunas do staff + a coluna `lastUpdateAt` do papel assessor. `goalCoverage` lê `campaignConceptOneLiner('cobertura-da-meta')` e acrescenta a nota de cenário que hoje está no `deficitHint`; `frescor` reaproveita a frase atual com `MUNICIPALITY_COLD_SIGNAL_DAYS`; `votos` reaproveita `formatMunicipalityConcentrationHint()`.
|
||||
- **`campaignConceptOneLiner`** (`src/lib/campaignIntelligenceConcepts.ts`): lookup por `CampaignConceptId` ao lado do `campaignConceptHref` existente; mantém o teste unitário do glossário como pino.
|
||||
- **`MunicipalityList.tsx`**: as três `const *Hint` locais somem em favor do mapa; `municipalityListColumns` passa `description={municipalityColumnDescriptions[id]}`.
|
||||
- **Teste**: unit pin de que todo `id` de coluna da lista de municípios tem entrada no mapa (mesma família do teste que guarda os conceitos do E18) — impede que **B17** (seletor de colunas) ou **E10** adicionem coluna muda.
|
||||
- **Sem migration, sem collection, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. Suaves, todas já entregues: **B15** (contrato de sort no header), **B16** (funil no mesmo `<th>` — o layout precisa continuar cabendo), **E18** (`campaignIntelligenceConcepts` como fonte da copy de métrica), Pass 2 W1 (colunas como dado em `CampaignTable`).
|
||||
- Consumidores futuros da capacidade: **B21** (colunas derivadas dos TIs), **B17** (o seletor de colunas passa a listar colunas que já sabem se explicar), **E10**/**E14** (cada métrica nova nasce com sua frase).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Copy das colunas das outras 5 listas — cada superfície escreve a sua quando for tocada (ver Questões em aberto); a capacidade já estará lá.
|
||||
- Explicação nos **cards mobile** de `/campanha/municipios` (não há header lá; o card já rotula cada valor por extenso). Se o critique do R6 mostrar necessidade, entra como fill-in.
|
||||
- Novos conceitos em `/campanha/conceitos` — o E18 é o dono; este item só consome `oneLiner`.
|
||||
- Glossário inline em badges/estimativas (FD2 / **R6**) — este item cobre header de tabela apenas.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Transformar a copy num "sistema de glossário".** Se alguém "só completar", vira collection Payload de termos, admin de edição e i18n de glossário para 10 frases. **Mitigação neste item:** copy é conteúdo curado em código, ao lado da definição da coluna — mesma decisão já travada no E18.
|
||||
- **Tornar o tooltip navegável por teclado com links dentro.** Explode em Popover por coluna, foco preso e redesign do header denso do B16. **Mitigação:** copy sem link; caminho de aprofundamento é a página do E18 pela sidebar.
|
||||
- **Refatorar `CampaignTable` para renderizar heads a partir de dados.** Parece "a coisa certa" ao adicionar `description`, mas desfaz a separação head-como-ilha do Pass 2 W1 e briga com o Popover de filtro do B16. **Mitigação:** decisão travada acima; a prop fica nos heads.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Copy de coluna nas listas de lideranças/organizações/dobradinhas/demandas/apoiadores.** Revisitar quando: alguém do time perguntar o que uma coluna dessas significa em sessão/R6, ou quando a superfície ganhar coluna derivada.
|
||||
- **Variante Popover (com link) no mobile.** Revisitar quando: o critique do R6 registrar falha de descoberta em touch — o tap já abre o tooltip hoje.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B — "Demais itens abertos", grafo, Janela 1–2, Cortes seguros)
|
||||
- `src/components/campaign/shared/CampaignTable.tsx` — `CampaignTableHead`, contrato `CampaignTableColumn` (seams do B17)
|
||||
- `src/components/campaign/municipality/MunicipalitySortableHead.tsx` — prop `tooltip` atual + composição com `MunicipalityHeaderFilter`
|
||||
- `src/components/campaign/municipality/MunicipalityHoverTooltip.tsx` — primitivo a mover (comportamento touch conquistado em critique)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` (~200–420) — definições de coluna e os três `*Hint` de hoje
|
||||
- `src/lib/campaignIntelligenceConcepts.ts` — `oneLiner`, `campaignConceptHref`, teste-pino do glossário
|
||||
- `src/utilities/municipalityLabels.ts` — casa dos labels da lista (destino do mapa de descrições)
|
||||
- `docs/plans/documentacao-conceitos-campanha.md` (E18) — por que o Popover foi o caminho de teclado lá e por que aqui não
|
||||
- `docs/plans/filtros-no-header-lista-municipios.md` (B16) — densidade atual do `<th>`
|
||||
- AGENTS.md — naming (identificadores em inglês / copy pt-BR), client boundary (`'use client'` só nas ilhas), knip zero após o move
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, clareza sob pressão, anti-goal data-grid
|
||||
117
docs/plans/link-territorio-lista-municipios.md
Normal file
117
docs/plans/link-territorio-lista-municipios.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# B25 — Link do território na lista de municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B25; entrada da página entregue por B21)
|
||||
Impeccable: B — encaixe na célula "Território" da lista existente (`MunicipalityList`), mais o `id` de âncora nas linhas da tabela do B21
|
||||
Appetite: ~0,25 dia eng; sem migration, sem collection, sem server action, sem loader novo — uma célula vira link, um helper puro e um `id` por linha
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — a tela leva à decisão seguinte; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable` + colunas como dado) · precedente de âncora com destaque em `/campanha/conceitos` (`scroll-mt-6 target:bg-muted/50`).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Sem `harden`/`optimize` (nenhuma escrita, nenhuma query nova, nenhum dado sensível).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Coordenador ou assessor varrendo `/campanha/municipios`; lê "Sertão Produtivo" na coluna Território e quer saber o peso e a cobertura daquele território antes de decidir se remaneja carteira ou giro. Hoje precisa sair pelo sidebar e reencontrar a linha na tabela dos 27.
|
||||
- **Job principal:** do município para o território em um clique, sem perder a comparação regional.
|
||||
- **Estratégia de cor:** Restrained. Link no mesmo padrão do nome do município (sublinhado no hover, `text-primary` só onde já é o padrão da célula); o realce no destino é o `target:` neutro do precedente, não uma cor nova.
|
||||
- **Edit where you see:** **não** — TI não é entidade editável (malha estática em `bahiaTerritories.ts`); a affordance aqui é navegação de leitura.
|
||||
- **Anti-goals:** linha inteira clicável (a lista já tem controles editáveis do coordenador na mesma linha); segundo estilo de link na tabela; badge/ícone extra na célula só para anunciar que é clicável.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** **Não (N/A)** — este item não calcula, agrega nem exibe número novo; é navegação entre duas superfícies existentes. Os dados do destino são os do rollup do E17 ✓ já renderizado pelo B21.
|
||||
- **Decisão que a navegação serve (contexto, não dado novo):** coordenador — "este território está descoberto o bastante para eu remanejar carteira/giro?" — respondida pela página do B21, que este link torna alcançável do lugar onde a pergunta nasce.
|
||||
- **Anti-goals de dado:** não repetir números do TI dentro da célula da lista (tooltip com agregados regionais = superfície de dado nova, fora deste appetite e do escopo do B22/B23).
|
||||
|
||||
## Contexto
|
||||
|
||||
Na lista `/campanha/municipios` a coluna "Território" é texto morto: em `src/components/campaign/municipality/MunicipalityList.tsx` a definição de coluna `region` tem `cell: (municipality) => municipality.region`, e no card mobile o território aparece dentro de `formatMunicipalityGeographyLabel(...)` como parágrafo. A única coisa que se pode fazer com o território ali é usá-lo como **filtro** (Popover do header, B16 ✓).
|
||||
|
||||
A navegação inversa já existe e é assimétrica: no painel do Início, `TerritoryOverviewTable` (E17 ✓) linka cada TI para `/campanha/municipios?region=<TI>`. Ou seja, do território se chega aos municípios, mas do município não se chega ao território.
|
||||
|
||||
O pedido (2026-07-25) fecha essa assimetria: clicar no território da lista deve levar à página daquele território. Hoje **não existe** superfície de TI fora do painel do Início — a página própria é o **B21** (`/campanha/territorios`, planejado no mesmo dia), que entrega os 27 TIs no sistema de listas e **exclui explicitamente** uma rota de detalhe por TI ("o drill continua sendo a lista de municípios filtrada"). Este item é, portanto, a entrada de navegação para a página do B21 — não uma tela nova.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- **Célula "Território" vira link** na tabela desktop de `/campanha/municipios`, apontando para a linha daquele TI em `/campanha/territorios`.
|
||||
- **Card mobile** ganha o mesmo destino no trecho de território da linha de geografia (sem quebrar o sufixo "· ZE N" das zonas de Salvador).
|
||||
- **Linhas do B21 ganham `id` de âncora** e realce de chegada, no precedente de `/campanha/conceitos` (`scroll-mt-*` + `target:bg-muted/50`) — quem chega pelo link sabe onde caiu.
|
||||
- **Um único cálculo de âncora** compartilhado pelas duas pontas (helper puro), para o link e o `id` nunca divergirem.
|
||||
- Alvo de toque `min-h-11`, sem novo tab stop além do próprio link, sem linha clicável.
|
||||
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action, sem query nova; nenhuma mudança no contrato de URL da lista de municípios (B15/B16/B18 congelados).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Destino = página dos TIs (B21) ancorada na linha do território, não rota de detalhe `/campanha/territorios/<slug>`.** O B21 rejeitou a rota de detalhe em 2026-07-25 e nada mudou desde então: um "detalhe de TI" seria a linha do rollup + a lista de municípios daquele TI — conteúdo que já existe em duas telas (a linha do B21 e `/campanha/municipios?region=<TI>`). Uma terceira superfície duplicaria a leitura regional que o E12 vai enriquecer. **Rejeitado:** rota de detalhe (duplica superfície e reabre um Não escopo recém-decidido, sem evidência nova); link para `/campanha/municipios?region=<TI>` (é a lista onde o usuário já está — auto-loop; e é justamente a direção que o E17 ✓ já cobre no sentido contrário).
|
||||
- **Âncora (`#`) em vez de filtro (`?region=`) no destino.** A página do B21 existe para **comparar** os 27 territórios; chegar nela filtrada a uma linha destrói o job da tela e ainda deixa o usuário com um chip para limpar. A âncora entrega a linha pedida **dentro** da comparação. **Rejeitado:** `?region=<TI>` (tabela de uma linha; parece tela quebrada e exige um clique de "Limpar" para virar útil); scroll programático em client component (JS para o que `#` + `scroll-mt` resolvem, e sem URL compartilhável).
|
||||
- **Dependência dura do B21; nada de destino provisório.** Enquanto a página não existir, o link não entra — não vale apontar para o painel do Início (âncora dentro do dashboard) nem deixar o link levar a 404. **Rejeitado:** enviar para `/campanha#territorios` antes do B21 (cria um destino que o B21 vai ter de desfazer no mesmo mês).
|
||||
- **Âncora derivada do nome do TI por helper puro, não por slug persistido.** Os TIs não têm slug — `bahiaTerritories.ts` os identifica por `name` (é o que o `?region=` já canonicaliza, acento-insensível). Um `territoryAnchorId(region)` sobre o `slugify` existente (`src/lib/slug.ts`) resolve as duas pontas sem tocar em schema. **Rejeitado:** adicionar `slug` aos registros de TI (mudança de dado estático + migração de contrato de URL para ganho zero); usar o nome cru como `id` (espaços e acentos em fragmento de URL).
|
||||
- **i18n e naming:** identificadores em inglês (`territoryAnchorId`, `buildTerritoryPageHref`, `TerritoryLink`); copy pt-BR (o texto do link é o próprio nome do território; `title`/`aria-label` do tipo "Ver o território Sertão Produtivo").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O link também entra no cabeçalho do detalhe do município (e no dossiê E16)?** **Opções:** (A) só a lista nesta entrega; (B) lista + cabeçalho do detalhe; (C) lista + detalhe + dossiê. **Recomendação: A** — a lista é onde o pedido nasceu e onde a varredura acontece; o dossiê é uma peça de impressão (link não imprime) e o cabeçalho do detalhe entra de graça depois, já que o `TerritoryLink` é compartilhado. Registrado em "Adiado com gatilho". _(assumido — validar na primeira demo.)_
|
||||
- **A célula linkada atrapalha quem usa a coluna como filtro?** **Recomendação:** não — filtrar continua sendo o funil no header (B16 ✓), que é onde o padrão da lista já colocou a ação de filtro; clicar no valor da célula é navegação. Se em uso real a mesa clicar no território esperando filtrar, o sinal é do B16, não deste item.
|
||||
- **Territórios sem linha correspondente no destino?** **Recomendação:** impossível por construção (o rollup cobre os 27 TIs do catálogo e todo município tem `region` validado contra `bahiaTerritories.ts`); se o `id` não existir, a página abre no topo — degradação aceitável, sem tratamento especial.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Lib["territoryAnchorId(region)<br/>src/lib/territoryAnchor.ts (puro)"]
|
||||
Cell["Coluna 'region' + card mobile<br/>MunicipalityList.tsx"]
|
||||
LinkC["TerritoryLink<br/>components/campaign/municipality/"]
|
||||
Page["/campanha/territorios (B21)<br/>linha com id + target:"]
|
||||
Lib --> LinkC --> Cell
|
||||
Lib --> Page
|
||||
Cell -->|"/campanha/territorios#ti-<slug>"| Page
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/lib/territoryAnchor.ts`** (novo, puro/client-safe): `territoryAnchorId(region: BahiaIdentityTerritory): string` (`ti-${slugify(region)}`) e `buildTerritoryPageHref(region)` (`/campanha/territorios#${territoryAnchorId(region)}`). Reusa `slugify` de `src/lib/slug.ts`. Unit test curto garantindo estabilidade e unicidade sobre `bahiaIdentityTerritories` (27 âncoras distintas) — é o pino que impede link e `id` de divergirem.
|
||||
- **`TerritoryLink`** (novo, em `src/components/campaign/municipality/`): `<Link>` com o nome do TI, `min-h-11`, estilo alinhado ao link do nome do município (`underline-offset-4 hover:underline`), porém em `text-muted-foreground` para manter a hierarquia atual da coluna. **Depth check:** é um wrapper fino de `Link` **com** duas responsabilidades reais (href canônico + rótulo acessível) e 2–3 call sites imediatos (célula desktop, card mobile, futuro cabeçalho do detalhe); se ficar em 1 call site na implementação, inline o `Link` e mantenha só o helper de href.
|
||||
- **`src/components/campaign/municipality/MunicipalityList.tsx`** (alterado): coluna `region` passa a renderizar `<TerritoryLink region={municipality.region} />`; no card mobile, o parágrafo de geografia usa o link no trecho do território, preservando o sufixo `· ZE N` das zonas (hoje montado por `formatMunicipalityGeographyLabel` em `src/utilities/municipalityLabels.ts` — dividir a formatação em rótulo + sufixo em vez de duplicar a regra).
|
||||
- **Página/tabela do B21** (alterada): cada `<TableRow>` de território recebe `id={territoryAnchorId(row.region)}` e as classes de chegada (`scroll-mt-*` para folgar o header sticky + `target:bg-muted/50`), no precedente de `src/app/(campaign)/campanha/(app)/conceitos/page.tsx`.
|
||||
- **Sem migration, sem collection, sem `Consent`, sem server action, sem alteração de loader.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Duras:** **B21** (`/campanha/territorios`) — o destino. Sem ele não há link.
|
||||
- **Suaves:** **B17** (se a coluna "Território" for ocultável, o link some junto — comportamento correto, nada a fazer). **E12** não é dependência: é o gatilho registrado para revisitar a decisão "âncora vs rota de detalhe" ("Adiado com gatilho").
|
||||
- Reusa: `src/lib/slug.ts`, `src/lib/bahiaTerritories.ts`, `src/utilities/municipalityLabels.ts`, `src/components/campaign/shared/CampaignTable.tsx`, e o padrão de âncora de `/campanha/conceitos`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Criar a página dos territórios: **B21**.
|
||||
- Rota de detalhe por TI (`/campanha/territorios/<slug>`): permanece Não escopo do **B21**; reabre só com **E12**.
|
||||
- Métricas regionais na célula ou em tooltip do território: **E12** (dado) / **B23** (mecanismo de tooltip de célula) — este item não introduz dado novo na lista.
|
||||
- Link do território no cabeçalho do detalhe do município e no dossiê (E16 ✓): "Adiado com gatilho" abaixo.
|
||||
- Qualquer mudança no filtro de território do header (B16 ✓) ou no contrato de URL da lista.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que a célula é clicável, deixa a linha inteira clicável."** A linha da lista carrega controles editáveis do coordenador (assessores, tendência, votos estimados) — linha-link engole tap e teclado. **Mitigação:** o alvo clicável é só o texto do território, como já é o nome do município.
|
||||
- **"Aproveita e mostra os números do TI no hover."** Vira superfície de dado nova (denominador, salvaguarda MAUP, quem vê) dentro de um item de 0,25 dia. **Mitigação:** tooltip de célula é capacidade do **B23** e conteúdo é **E12**; aqui, nada além do rótulo.
|
||||
- **Slug persistido para TI.** "Se vai ter âncora, o TI devia ter slug" abre migração de dado estático + contrato de URL (`?region=` hoje casa por nome normalizado). **Mitigação:** âncora derivada em runtime por helper puro, pinada por teste.
|
||||
- **Refatorar `formatMunicipalityGeographyLabel` para "componentizar geografia".** **Mitigação:** expor o rótulo em partes (território + sufixo de zona) e parar aí; nada de componente genérico de geografia.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Link do território no cabeçalho do detalhe do município.** Revisitar quando o `TerritoryLink` existir e alguém pedir a mesma navegação a partir do detalhe (ou na primeira demo do B21) — é encaixe de minutos, não vale ampliar o appetite agora.
|
||||
- **Voltar a considerar rota de detalhe por TI.** Gatilho: **E12** entregar métricas regionais que não caibam em uma linha da tabela (captura, amplitude, município crítico) **ou** a mesa pedir uma "página do território" em sessão real depois de usar o B21.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / "Demais itens abertos", B25; Janela 1–2; cortes seguros)
|
||||
- [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21 — o destino, incl. o Não escopo da rota de detalhe) · [tabela-ti-inicio.md](tabela-ti-inicio.md) (E17 ✓ — rollup e o link inverso) · [camada-territorios-identidade.md](camada-territorios-identidade.md) (E12) · [tooltip-celulas-listas.md](tooltip-celulas-listas.md) (B23 — por que o hover de dado não é deste item)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `region` e card mobile a alterar
|
||||
- `src/components/campaign/municipality/TerritoryOverviewTable.tsx` — linhas que recebem o `id` de âncora (e o link inverso já existente)
|
||||
- `src/utilities/municipalityLabels.ts` (`formatMunicipalityGeographyLabel`), `src/lib/slug.ts`, `src/lib/bahiaTerritories.ts` — o que o helper reusa
|
||||
- `src/app/(campaign)/campanha/(app)/conceitos/page.tsx` — precedente de âncora com `scroll-mt` + `target:`
|
||||
- AGENTS.md — Campaign auth e acesso por papel (a página destino é staff-only; `leader` nunca vê a coluna), naming (identificadores em inglês, copy pt-BR), sem migration neste item
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, anti-goal de dashboard/spreadsheet, hierarquia de links na lista
|
||||
130
docs/plans/municipios-prioritarios-inicio.md
Normal file
130
docs/plans/municipios-prioritarios-inicio.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Municípios prioritários no Início
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B20)
|
||||
Impeccable: B — encaixe na seção existente do dashboard staff (`CampaignDashboard` em `/campanha`); sem rota nova
|
||||
Appetite: ~0,5 dia eng; semântica da seção + ordenação + link “Ver todas” + markup sem bullets; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 clareza sob pressão + 6 inteligência serve organização) / `DESIGN.md` (register `product`; Field Desk; chips de escopo) · tema `data-theme='campaign'` · shell `CampaignPageShell` + padrão de chip-strip já usado em `assessores/[id]` e `RecentlyVisitedCard`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só a seção prioritários + loader; sem redesign do Início).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG/candidato (e assessor no próprio escopo) abrem `/campanha` sob pressão; precisam de atalho para **onde atacar agora** entre as prioritárias da mesa — não do inventário completo.
|
||||
- **Job principal:** ver um recorte acionável das prioritárias, entender que o conjunto total é maior, e cair na lista filtrada com um toque.
|
||||
- **Estratégia de cor:** Restrained; chips outline/secondary existentes, sem segundo sistema de badges.
|
||||
- **Edit where you see:** não — seção só-leitura / navegação; mutação de `priority` continua no form de estratégia.
|
||||
- **Anti-goals:** inventário de ~50 chips no Início; “top 6” sem critérios; hero-metric SaaS de “prioritários”; lista com bullets visíveis.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — lista curta de municípios (nome + link) + contagem do conjunto `priority=alta` no escopo.
|
||||
- **Decisões desbloqueadas:** Staff: “dentre as prioritárias da mesa, quais abro agora (maior buraco de cobertura)?”; “quero o inventário completo → lista filtrada”.
|
||||
- **Forma escolhida:** lista/chip-strip ranqueada (≤8) + contagem + CTA “Ver todas (N)” — **por quê:** o Início é atalho (E9 já é a fila completa). **Rejeitado:** mostrar os ~50 chips (polui o Início; E4R seedou ~50 `alta`); só KPI numérico sem nomes (perde o atalho de um toque); chart/mapa de prioritárias (vaidade; mapa já está acima).
|
||||
- **Profile:** categórico (município) + ordenação por déficit numérico derivado (E8/E9, cenário `central`); granularidade município; tamanho típico amostra 6–8 de um conjunto ~50 (coordenador) ou ≤N do escopo (advisor); relativo (déficit de cobertura), não % estadual.
|
||||
- **Anti-goals de dado:** sem % estadual; sem reorder inventado paralelo a E9; sem inventar segundo campo de “prioridade do dashboard”.
|
||||
|
||||
## Contexto
|
||||
|
||||
No dashboard staff (`/campanha`), `CampaignDashboard` renderiza a seção **“Municípios prioritários”** a partir de `StaffDashboardView.priorityMunicipalities` (`src/utilities/campaignDashboardData.ts`):
|
||||
|
||||
```ts
|
||||
priorityMunicipalities: municipalities.docs
|
||||
.filter((m) => m.priority === 'alta')
|
||||
.slice(0, 6)
|
||||
.map((m) => ({ name: m.name, slug: m.slug }))
|
||||
```
|
||||
|
||||
Problemas observados (pedido de produto 2026-07-25):
|
||||
|
||||
1. **Semântica opaca.** Após **E4R**, ~50 municípios têm `priority=alta` (aba PRIORITÁRIAS da planilha). A seção mostra no máximo 6, na ordem do escopo (não ranqueada), sem dizer que há mais. O usuário lê como “estes são os prioritários” — mentira por omissão. `highPriorityCount` já é calculado no loader e **não é usado** na UI (campo morto no view model).
|
||||
2. **Markup com bullets.** A strip usa `<ul className="flex flex-wrap gap-2">` **sem** `list-none` / reset de margem — ao contrário de `RecentlyVisitedCard` e da strip de municípios em `/campanha/assessores/[id]`. Resultado: marcadores de lista visíveis nos chips.
|
||||
|
||||
A fila canônica de alocação já é `/campanha/municipios` (**E9**, default `?sort=deficit`); o filtro `?priority=alta` e o link da “coluna da vergonha” (`?priority=alta&coverage=sem_assessor`) já existem. O Início deve ser **atalho**, não segundo inventário.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Redefinir a seção como **atalho acionável** sobre o conjunto `priority === 'alta'` do escopo do ator (não como inventário truncado).
|
||||
- Ordenar a amostra pelo mesmo critério E9: déficit de cobertura da meta no cenário `central` (maior buraco primeiro); municípios sem meta/`goal=0` no fim.
|
||||
- Limite da amostra: **8** (ou todos se o escopo tiver menos); header com contagem total `highPriorityCount` e link **“Ver todas”** → `buildMunicipalityListHref({ priority: 'alta', …defaults })`.
|
||||
- Corrigir markup: `list-none m-0 p-0` (e `[&>li]:mt-0` se necessário), espelhando o padrão de assessores/recentes — zero bullets.
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem server action; `leader` nunca vê o dashboard; advisor continua no próprio escopo (`loadMunicipalityScope`).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Semântica = atalho ranqueado + inventário na lista, não “os 6 prioritários”.** A seção responde “quais prioritárias atacar agora?”; o conjunto completo vive em `/campanha/municipios?priority=alta`. **Rejeitado:** mostrar ~50 chips no Início (polui; contradiz Field Desk); manter `.slice(0, 6)` sem sort nem contagem (status quo mentiroso); trocar a seção só por KPI sem nomes (perde o job de um toque); restringir a “sem assessor” só (já coberto pela coluna da vergonha no overview da lista — aqui o critério é déficit entre as `alta`).
|
||||
- **Ordenação = déficit E9 (`central`), reuso do bundle de cobertura já carregado.** `getCampaignDashboardData` já chama `loadMunicipalityGoalCoverageBundle`; ordenar as `alta` por `coverage.deficit` desc antes do slice. **Rejeitado:** ordem alfabética / ordem do escopo; sort por votos 2022 (A11) nesta seção (a lente de alocação pós-E9 é cobertura); segundo loader só para prioritários.
|
||||
- **Limite amostral = 8; CTA sempre que `highPriorityCount > amostra`.** Número alinhado a caps de dossiê/filas curtas; não expor constante mágica sem nome (`DASHBOARD_PRIORITY_SAMPLE_LIMIT`). **Rejeitado:** 6 sem copy de “há mais”; paginação no Início; “mostrar tudo” abaixo de um fold.
|
||||
- **Markup: `ul` com `list-none` (não trocar por `div`).** Preserva lista acessível; precedent `assessores/[id]` + `RecentlyVisitedCard`. **Rejeitado:** deixar bullets; reinventar componente de chip-strip genérico (<3 call sites além destes).
|
||||
- **i18n e naming** (AGENTS.md): identificadores ingleses (`priorityMunicipalities`, `highPriorityCount`, `DASHBOARD_PRIORITY_SAMPLE_LIMIT`); strings pt-BR (“Municípios prioritários”, “Ver todas”).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Copy do subtítulo / CTA?** **Opções:** A) só título + Badge com N + link “Ver todas”; B) linha “N prioritários · mostrando as 8 com maior déficit de cobertura”. **Recomendação:** A (clareza sob pressão; o sort é implícito pelo contexto pós-E9; B é didático demais para o Início). _(assumido — validar com produto na primeira demo)_
|
||||
- **Incluir chip “sem responsável” na amostra?** **Opções:** A) não neste item (E9 já sinaliza na lista); B) priorizar `alta` sem assessor antes do sort por déficit. **Recomendação:** A — rabbit hole de segunda fila; se a mesa pedir, fill-in.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scope["loadMunicipalityScope"]
|
||||
Bundle["loadMunicipalityGoalCoverageBundle<br/>(já no dashboard)"]
|
||||
Filter["filter priority=alta"]
|
||||
Sort["sort by deficit central desc"]
|
||||
Sample["slice(0, SAMPLE_LIMIT)"]
|
||||
UI["CampaignDashboard section<br/>chips list-none + Ver todas"]
|
||||
List["/campanha/municipios?priority=alta"]
|
||||
Scope --> Filter
|
||||
Bundle --> Sort
|
||||
Filter --> Sort --> Sample --> UI
|
||||
UI -->|"Ver todas"| List
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`getCampaignDashboardData`** (`src/utilities/campaignDashboardData.ts`): após o bundle E8, filtrar `priority === 'alta'`, ordenar por `goalCoverageBundle.byMunicipalityId` (ou equivalente já exposto) no cenário `central` — maior `deficit` primeiro; `null`/sem meta no fim; `slice(0, DASHBOARD_PRIORITY_SAMPLE_LIMIT)`; continuar populando `highPriorityCount` (agora consumido na UI). Extrair helper puro testável se o sort não couber inline (<15 linhas → pode ficar no próprio arquivo; ≥ complexidade → `pickDashboardPriorityMunicipalities` no mesmo módulo ou ao lado de `goalCoverage`).
|
||||
- **`CampaignDashboard`** (`src/components/campaign/dashboard/CampaignDashboard.tsx`): header da seção com título + `Badge`/`span` com `highPriorityCount`; `ul` com `m-0 flex list-none flex-wrap gap-2 p-0 [&>li]:mt-0`; chips `Button outline` + `Link` como hoje; CTA `Link` “Ver todas” via `buildMunicipalityListHref` com `priority: 'alta'` (defaults de sort da lista — `deficit` desc — alinhados a E9). Esconder CTA se `highPriorityCount <= amostra`.
|
||||
- **Unit test** do helper de pick/sort (fixture 10 municipíos, assert ordem e limite).
|
||||
- **Migration:** Sem migration, sem collection, sem server action.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Duras: nenhuma de item aberto — **E4R ✓** (seed de `priority`), **E8 ✓** / **E9 ✓** (cobertura + sort `deficit`), filtro URL `priority` em `municipalityListUrl.ts`.
|
||||
- Reuso: `loadMunicipalityScope`, `loadMunicipalityGoalCoverageBundle`, `buildMunicipalityListHref`, padrão `list-none` de assessores/recentes.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Ícone Flag na lista de municípios → fill-in [icone-prioridade-lista-municipios.md](icone-prioridade-lista-municipios.md).
|
||||
- Substituir `priority` por níveis N0–N4 → **E14**.
|
||||
- Segunda fila “só sem assessor” no Início → já coberto por E9 (“coluna da vergonha”); não duplicar.
|
||||
- Redesign do restante do dashboard / mapa / tabela TI (**E17**).
|
||||
- `highPriorityCount` no `CampaignMetricStrip` (KPI vaidade; a seção + lista bastam).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **“Mostrar todos” com scroll interno.** Vira inventário no Início e compete com a lista. **Mitigação:** amostra fixa + CTA.
|
||||
- **Sort paralelo inventado (votos, frescor, alfabetic).** Diverge de E9 e confunde. **Mitigação:** só déficit `central`.
|
||||
- **Componente genérico `CampaignChipStrip`.** 2–3 call sites com markup trivial. **Mitigação:** copiar classes do precedente; abstrair só no 3º copy-paste futuro (gatilho).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Priorizar “alta sem assessor” na amostra.** Revisitar quando: CG pedir explicitamente no Início **ou** a coluna da vergonha deixar de ser descoberta na lista (evidência de sessão/R6).
|
||||
- **Expor `highPriorityCount` no metric strip.** Revisitar quando: houver 2º consumidor além do header da seção (hoje seria vaidade).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (B20; E9 fila; E4R seed ~50 alta)
|
||||
- `src/utilities/campaignDashboardData.ts` — loader + `slice(0, 6)` atual + `highPriorityCount` morto
|
||||
- `src/components/campaign/dashboard/CampaignDashboard.tsx` — seção + `ul` sem `list-none`
|
||||
- `src/utilities/municipalityListUrl.ts` — `buildMunicipalityListHref` / filtro `priority`
|
||||
- `src/utilities/municipalityPageData.ts` — `applyDerivedMunicipalitySort` (precedente déficit)
|
||||
- `src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx` — strip `list-none` de referência
|
||||
- `src/components/campaign/dashboard/RecentlyVisitedCard.tsx` — `list-none` de referência
|
||||
- [fila-de-alocacao.md](fila-de-alocacao.md) (E9) · [import-planilha-projecao.md](import-planilha-projecao.md) (E4R, ~50 alta)
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk; anti dashboard SaaS
|
||||
- AGENTS.md — naming, access por escopo, staff-only dashboard
|
||||
124
docs/plans/ordenacao-filtros-lista-dobradinhas.md
Normal file
124
docs/plans/ordenacao-filtros-lista-dobradinhas.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# B33 — Ordenação e filtro no header da lista de dobradinhas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B33)
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — ver Dependências)
|
||||
Appetite: ~0,5–0,75 dia eng se `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existirem (B29 ou B21 já entregues); ~1–1,5 dia eng se este item chegar primeiro e precisar fazer a extração
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`) e o par compartilhado que **B29** promove para `shared/`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8 — leitura paginada com filtro no banco, sem escrita nova.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Coordenador Geral (ou assessor) abrindo `/campanha/dobradinhas` para achar rapidamente "quais dobradinhas do PT ainda não têm nenhuma liderança vinculada?" ou "ordenar por quem cobre mais municípios". Hoje a lista só busca por nome e ordena fixo por nome (`sort: 'name'` no loader).
|
||||
- **Job principal:** recortar e ordenar as ~dezenas de fichas de dobradinha pela coluna que importa no momento, sem abrir cada ficha.
|
||||
- **Estratégia de cor:** Restrained. Filtro ativo é o funil preenchido do padrão B16 ✓, sem cor nova.
|
||||
- **Edit where you see:** não neste item — a lista continua leitura + navegação para a ficha; vincular município/liderança já é ação da ficha (`stateDeputy/[slug]`), fora de escopo aqui.
|
||||
- **Anti-goals:** overview/KPI strip de dobradinhas; segunda gramática de lista (estado fora da URL, sort client-side); coluna nova derivada de contagem sem decisão nomeada.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — sem métrica nova: ordenação/filtro sobre as colunas já existentes (Nome, Partido, Municípios, Lideranças).
|
||||
- **Decisões desbloqueadas:**
|
||||
- Coordenador Geral: "quais deputados dobrados ainda não têm nenhum município ou liderança vinculada?" — ordenar por `municipalityCount`/`leadershipCount` ascendente para achar fichas órfãs antes do onboarding.
|
||||
- Qualquer staff: "quais dobradinhas são do meu partido de interesse?" — filtrar por Partido quando a lista crescer além de uma tela.
|
||||
- **Forma escolhida:** tabela/lista ranqueada (degrau 2) — a forma já em uso; muda a interação, não a representação. **Rejeitado:** KPI strip de contagem (sem decisão nomeada — a leitura que decide alocação é a de município, E8/E9); chart por partido (uma coluna, sem decisão nova).
|
||||
- **Profile:** categórico (Partido, dezenas de valores possíveis, muitos "sem partido") × contagens derivadas (`municipalityCount`, `leadershipCount`); dezenas de linhas hoje (uma por deputado estadual parceiro), sem paginação pesada.
|
||||
- **Anti-goals de dado:** nenhuma métrica de "força" ou "prioridade" sintética da dobradinha — a alavancagem político-eleitoral fica para **A6**/E13, não para esta lista.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/dobradinhas` (`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`) já está no sistema de listas do Pass 2 W1: `CampaignTable` com colunas como dado, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, pending compartilhado. O estado da URL é só `q` + `page` (`parseStateDeputyListParams` em `src/utilities/stateDeputyData.ts`) e a ordenação é fixa (`sort: 'name'` na query `payload.find`, sem `dir`). As quatro colunas hoje renderizadas são Nome (link), Partido, Municípios (contagem) e Lideranças (contagem) — nenhuma tem header rico.
|
||||
|
||||
O pedido (2026-07-25) é trazer para `/campanha/dobradinhas` a mesma capacidade de ordenação/filtro no header que `/campanha/municipios` tem desde **B15 ✓**/**B16 ✓**, **sem duplicar código** — reusando a lógica e os componentes já construídos para essa família de listas. O caminho de reuso correto **não** é copiar `MunicipalitySortableHead`/`MunicipalityHeaderFilter` (que importam módulos do domínio `municipality`), e sim consumir a extração que **B29** (lista de lideranças) já registrou como seu objetivo explícito: `shared/CampaignSortableHead` + `shared/CampaignHeaderFilterPopover` (chrome puro — a11y, busca, "Limpar") com um wrapper de domínio fino (`StateDeputySortableHead`/`StateDeputyHeaderFilter`) por trás, no mesmo molde de `LeadershipSortableHead`/`LeadershipHeaderFilter`. O Não escopo do B29 já nomeia essa entrada: "Migrar `demandas`, `dobradinhas`, `organizacoes` e `apoiadores` para o header rico: cada uma entra por pedido próprio — a peça compartilhada fica pronta." Este item é esse pedido.
|
||||
|
||||
Duas colunas hoje são contagens **derivadas** (`municipalityCount`/`leadershipCount`, calculadas em memória a partir de duas queries auxiliares em `loadStateDeputyListPageData`, não colunas no banco) — não dá para ordenar por elas no `payload.find` sem reescrever o agregado. Isso limita o v1 realista a ordenar por `name` (já é o default) e `party` (campo indexado real), e a filtrar por `party` (facet dos valores presentes).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- **Ordenar pelo header** em `/campanha/dobradinhas`, com `?sort=`/`?dir=` no contrato canônico do `campaignListUrl` (`name` default asc, `party`) — recarregável e compartilhável, no mesmo padrão do B15 ✓.
|
||||
- **Filtrar pelo header** (Popover, padrão B16 ✓) por **Partido** — multi-seleção com facet dos partidos presentes no conjunto atual + opção "Sem partido" — com chip de filtro ativo e "Limpar" pelos shells compartilhados.
|
||||
- **Header e filtros permanecem montados com zero resultados** — usar a prop `empty` do `CampaignTable` (precedente B16 ✓), em vez de trocar a tabela inteira pelo empty state.
|
||||
- **Ordenação e filtro executados no banco** (`payload.find` com `sort`/`where`), nunca em memória sobre a página carregada.
|
||||
- **Zero segunda cópia do chrome de head/filtro.** Este item consome `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover`; se nenhum dos dois existir ainda quando este item for implementado (B29 e B21 ainda abertos), a extração acontece aqui, seguindo exatamente o contrato já desenhado no plano do B29 (ver Dependências).
|
||||
- Guardrails: **sem migration, sem collection, sem `Consent`, sem server action**; access inalterado (`isCampaignStaff` na rota, `overrideAccess: false` na leitura já existente).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Filtro v1 = só Partido; contagens (`municipalityCount`/`leadershipCount`) ficam sem sort/filtro nesta entrega.** São calculadas em memória por duas queries auxiliares (`payload.find` em `municipality`/`leadership` com `where: { stateDeputies: { in } }`), não campos armazenados em `stateDeputy` — ordenar por elas no banco exigiria reescrever o loader para um agregado SQL (`payload.db.drizzle`, molde do `supporterListOverviewAggregate.ts`). Fonte: exploração de `loadStateDeputyListPageData` (2026-07-25). **Rejeitado:** desnormalizar as contagens em campos `stateDeputy.municipalityCount`/`leadershipCount` mantidos por hook (segunda fonte de verdade sobre uma relação, e hook teria de disparar em `afterChange` de `municipality`/`leadership` sempre que `stateDeputies` mudar — acoplamento caro para duas colunas hoje com dezenas de linhas); ordenar em memória sobre a página carregada (ordenação que mente, mesmo anti-goal nomeado no plano do B29).
|
||||
- **Consome `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` — nunca uma terceira cópia do chrome de head/filtro.** O plano do B29 já desenhou a extração (chrome puro: `aria-sort`, `min-h-11`, busca acima do limiar, linhas exclusivas + multi com checkbox, "Limpar"; política de URL/estado fica no wrapper de domínio). Fonte: [ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md) § Decisões travadas. **Rejeitado:** copiar `MunicipalitySortableHead`/`MunicipalityHeaderFilter` inteiros para `dobradinhas` (é exatamente o "terceira/quarta cópia" que o B29 nomeou como razão para extrair); esperar os dois (B21 e B29) estarem entregues antes de sequer planejar este item (empurra a decisão sem necessidade — ver Dependências para o caso de este item chegar primeiro).
|
||||
- **Contrato de URL próprio para esta lista** (`q`, `party`, `sort`, `dir`, `page`) em `src/utilities/stateDeputyListUrl.ts`, espelhando `municipalityListUrl.ts`/`leadershipListUrl.ts` sobre `campaignListUrl.ts`, sem importar os módulos dos outros domínios. **Rejeitado:** estender `parseStateDeputyListParams` in-place sem extrair para módulo próprio (mistura parsing de URL com o loader, quebrando o padrão que as outras duas listas já fixaram); reaproveitar `party` como nome de param de outra lista (não há colisão hoje, mas o precedente do B29 já rejeitou compartilhar nomes de param entre domínios).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`stateDeputyListUrl.ts`, `StateDeputyListState`, `StateDeputyListSortKey`, `buildStateDeputySortHref`, `stateDeputyFilterDefinitions`, `StateDeputySortableHead`, `StateDeputyHeaderFilter`), strings visíveis em pt-BR ("Partido", "Sem partido", "Limpar").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O que fazer se este item for implementado antes de B29 e B21 (nenhum dos dois extraiu o chrome ainda)?** **Opções:** (a) este item faz a extração para `shared/`, migrando `MunicipalitySortableHead`/`MunicipalityHeaderFilter` para compô-la, exatamente como o plano do B29 já desenhou | (b) este item duplica o head localmente e um dos outros (B21/B29) absorve depois | (c) bloquear este item até B29 entregar. **Recomendação:** (a) — o contrato de extração já está desenhado e revisado no plano do B29; refazê-lo aqui evita um quarto lugar de decisão sobre a mesma peça, e como este item é o menor dos três consumidores (só uma coluna filtrável), é o candidato mais barato para pagar a extração se for o primeiro a chegar.
|
||||
- **Ordenar por `party` deve tratar "sem partido" (`null`) antes ou depois dos partidos preenchidos?** **Opções:** nulls primeiro | nulls por último | nulls tratados como string vazia. **Recomendação:** nulls por último em ambas as direções (comportamento padrão do adapter Postgres do Payload para `sort` em campo de texto opcional) — evita que uma tela vazia de partido "vença" a ordenação alfabética por acidente. _(assumido — validar com produto se surgir confusão.)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Url["stateDeputyListUrl.ts<br/>(state · parse · where · sort · hrefs)"]
|
||||
Filt["stateDeputyListFilters.ts<br/>(facet de partido · toggles · chips)"]
|
||||
Loader["stateDeputyData.ts<br/>(find paginado + facet)"]
|
||||
Page["/campanha/dobradinhas/page.tsx (RSC)"]
|
||||
Dom["StateDeputySortableHead / StateDeputyHeaderFilter<br/>(estado + optimistic + hrefs)"]
|
||||
Shared["shared/CampaignSortableHead<br/>shared/CampaignHeaderFilterPopover<br/>(de B29, ou extraído aqui)"]
|
||||
Table["CampaignTable (colunas como dado)"]
|
||||
Url --> Loader --> Page --> Table
|
||||
Url --> Dom
|
||||
Filt --> Dom
|
||||
Dom --> Shared
|
||||
Dom --> Table
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/utilities/stateDeputyListUrl.ts`** (novo): `StateDeputyListState` (`q`, `party`, `sort`, `dir`, `page`), `parseStateDeputyListParams` (movido e estendido a partir de `stateDeputyData.ts`), `buildStateDeputyListWhere`, `resolveStateDeputyListSort`, `buildStateDeputyListHref` / `buildStateDeputySortHref` / `buildStateDeputyFilterOptionHref`, tudo sobre os helpers de `campaignListUrl.ts` (`firstValue`, `normalizedText`, `strictDecimalInteger`, `buildListHref`). Espelha `leadershipListUrl.ts` sem importá-lo.
|
||||
- **`src/utilities/stateDeputyListFilters.ts`** (novo): definição do filtro de Partido (multi-seleção, opção "Sem partido" para `party: null`), helpers de toggle/clear, resumo para os chips. Facet computado no loader a partir dos `party` distintos do conjunto atual, molde de `loadMunicipalityListFilterFacets`.
|
||||
- **`src/utilities/stateDeputyData.ts`** (alterado, `server-only`): `loadStateDeputyListPageData` passa a montar `where` via `buildStateDeputyListWhere`, aplicar `sort` resolvido (`name` ou `party`, com `dir`) e devolver `filterFacets.party`; `parseStateDeputyListParams` sai deste arquivo para `stateDeputyListUrl.ts`. Queries de contagem (`municipalityCounts`/`leadershipCounts`) continuam iguais — não entram no `sort`.
|
||||
- **`src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx`** (alterado): `resolveStateDeputyListUrl` + `redirect` canônico (molde de `municipios/page.tsx`), coluna "Nome" ganha `StateDeputySortableHead` (sort, sem filtro) e "Partido" ganha `StateDeputySortableHead` com `filterParam="party"`; `CampaignFilterChips` + `CampaignTable empty={…}` no lugar da troca de árvore.
|
||||
- **`src/components/campaign/stateDeputy/StateDeputySortableHead.tsx` / `StateDeputyHeaderFilter.tsx`** (novos, client): wrappers finos — `useOptimistic(state)`, hrefs pelo módulo de URL, render delegado a `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover`.
|
||||
- **`src/components/campaign/shared/CampaignSortableHead.tsx` / `CampaignHeaderFilterPopover.tsx`**: reusados tal como o plano do B29 os desenha. **Se ainda não existirem** quando este item for implementado, a extração a partir de `MunicipalitySortableHead`/`MunicipalityHeaderFilter` acontece aqui, seguindo o mesmo contrato (ver Questões em aberto).
|
||||
- **Testes:** unit de `stateDeputyListUrl` (parse → serialize idempotente, defaults omitidos — molde de `municipalityListUrl.unit.spec.ts`); int de `loadStateDeputyListPageData` (sort por `party` com nulls, filtro "Sem partido", facet restrito ao conjunto filtrado).
|
||||
- **Sem migration, sem collection, sem `Consent`, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Duras:** nenhuma.
|
||||
- **Suaves:** **B29** (lista de lideranças) — quem entre os dois chegar primeiro extrai `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover`; o outro apenas consome (mesma lógica de ordem de chegada já usada entre B21 e B29). **B21** (página dos Territórios de Identidade) — mesmo raciocínio, terceira ponta possível da mesma corrida. **B15 ✓**/**B16 ✓** deram o contrato de URL/popover a espelhar.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Ordenar/filtrar por `municipalityCount`/`leadershipCount` (contagens derivadas, não campos de banco): exigiria reescrever o loader para um agregado SQL — adiado com gatilho abaixo.
|
||||
- Overview/KPI de dobradinhas no topo da página: sem decisão nomeada (a leitura que decide alocação é a de município, E8/E9).
|
||||
- Vincular município/liderança a partir da lista (edição in-context): a ficha (`/campanha/dobradinhas/[slug]`) já faz isso; fora do job desta lista (leitura + navegação).
|
||||
- **A6** (dobradinha 2026 automática pós-TSE): camada de insight separada sobre o mesmo registro operacional, não recorte de lista.
|
||||
- Filtros salvos e atalho no sidebar para esta lista: **B18** ([plano](filtros-salvos-municipios.md)), que passa a valer para mais de uma lista quando for implementado.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou aqui, reescrevo o loader para agregado SQL e ordeno por contagem."** Explode o appetite de um item de ~meio dia para um agregado novo com `payload.db.drizzle`, sem pedido nomeado para essa ordenação específica. **Mitigação neste item:** Não escopo explícito; adiado com gatilho abaixo.
|
||||
- **Extrair o chrome compartilhado "melhor" do que o B29 já desenhou.** Redesenhar `shared/CampaignSortableHead` na hora de consumi-lo (props extras, generalização especulativa) sem um quarto call site na mão. **Mitigação:** seguir o contrato do plano do B29 ao pé da letra; qualquer generalização nova espera um call site que a peça de fato precise.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Sort/filtro por `municipalityCount`/`leadershipCount`.** Revisitar quando: houver pedido nomeado de produto para "dobradinhas sem nenhuma liderança/município" como recorte de onboarding, ou quando outro item já precisar do mesmo agregado SQL sobre `stateDeputy` (ex.: um "C8-like" de escala).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / "Demais itens abertos", B33; grafo; cortes seguros)
|
||||
- [ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md) (B29 — contrato de extração do chrome compartilhado, molde de módulo de URL/filtros) · [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15 — contrato `sort`/`dir`) · [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) (B16 — popover, facets, chips) · [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21 — outra ponta da mesma corrida de extração) · [sistema-listas-campanha.md](sistema-listas-campanha.md) (Pass 2 W1)
|
||||
- `src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx` — superfície a estender (colunas, empty state, footer)
|
||||
- `src/utilities/stateDeputyData.ts` — loader atual, contagens derivadas em memória, `parseStateDeputyListParams` a mover
|
||||
- `src/utilities/municipalityListUrl.ts`, `src/utilities/campaignListUrl.ts` — contrato a espelhar e helpers a reusar
|
||||
- `src/components/campaign/municipality/MunicipalitySortableHead.tsx`, `MunicipalityHeaderFilter.tsx`, `src/components/campaign/shared/CampaignTable.tsx` — origem da extração (via B29) e destino das colunas
|
||||
- `src/collections/StateDeputy.ts` — campos indexados (`name`, `party`) e access (`canReadStateDeputy`)
|
||||
- AGENTS.md — Campaign auth e RBAC, `overrideAccess: false` com `user`, naming (identificadores em inglês, copy pt-BR), sem migration neste item
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, anti-goals de dashboard, "Edit where you see" (não se aplica na v1 desta tela)
|
||||
147
docs/plans/ordenacao-filtros-lista-liderancas.md
Normal file
147
docs/plans/ordenacao-filtros-lista-liderancas.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# B29 — Ordenação e filtro no header da lista de lideranças
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B29)
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico de `/campanha/municipios` (B15 ✓ + B16 ✓)
|
||||
Appetite: ~1,5–2 dias eng; sem migration — módulo de URL + filtros no loader + duas colunas + extração do par head/filtro para `shared/` (com municípios migrado junto)
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8 — a superfície é leitura paginada com filtros no banco; a única escrita continua sendo o cadastro, que não é deste item.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Assessor com carteira de municípios (e o CG na mesa) abrindo a lista de lideranças para trabalhar uma fatia — "quem ainda está `a abordar` em Feira de Santana?", "quais lideranças religiosas de Irecê ainda não têm acesso ao app?". Hoje a única ferramenta é a busca por nome, e a ordem é sempre a última escrita.
|
||||
- **Job principal:** recortar a lista de pessoas pelo critério do dia e ordená-la pela coluna que importa, sem sair da tela nem decorar quem é quem.
|
||||
- **Estratégia de cor:** Restrained. O único acento continua sendo o `SupportStatusBadge` existente; filtro ativo é o funil preenchido do B16, não cor nova.
|
||||
- **Edit where you see:** **não neste item.** A lista de lideranças ainda é leitura + navegação para a ficha; edição rápida de `supportStatus` na célula é candidata natural (mesmo padrão do B9/B24), mas é outra decisão, com outro appetite — registrada em "Adiado com gatilho", não semeada aqui.
|
||||
- **Anti-goals:** score/nível sintético de liderança na tela (é **E14**); KPI strip / overview de lideranças; filtro por telefone ou qualquer recorte de PII sensível; segunda gramática de lista (chips próprios, sort client-side, estado fora da URL).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — mas **sem métrica nova**: a entrega é recorte e ordenação sobre a tabela de entidades que já existe.
|
||||
- **Decisões desbloqueadas:**
|
||||
- Assessor: "de quem eu falo esta semana?" — lideranças `a abordar` / `em disputa` nos meus municípios, ordenadas por nome ou pelo sinal mais antigo.
|
||||
- Coordenador Geral: "qual município da carteira tem rede cadastrada e qual está oco?" — filtro por município sobre o conjunto que ele enxerga inteiro.
|
||||
- Staff no onboarding (Onda 0 §4): "quais lideranças ainda estão sem acesso ao app?" — filtro `sem acesso` antes de disparar convites.
|
||||
- **Forma escolhida:** **tabela/lista ranqueada** (degrau 2 da escada) — a forma já em uso; o que muda é a interação (ordenar/filtrar), não a representação. **Rejeitado:** KPI strip com contagem por status (contagem bruta de cadastros é anti-goal do research quando não amarra a cobertura/meta — a leitura de rede que decide alocação é a de município, no E8/E9); chart de barras por setor (uma coluna por vez, sem decisão nova); mapa de lideranças (a geografia decisória é município, e o mapa já existe lá).
|
||||
- **Profile:** categórico (status de apoio 4 valores, setor 10 valores, acesso ao app booleano) × relacional (municípios, organizações) × temporal (`updatedAt`); centenas a poucos milhares de linhas com a base nominal real (C2), paginadas de 25 em 25; leitura absoluta por linha, sem razões.
|
||||
- **Anti-goals de dado:** ranking sintético de lideranças; % de "engajamento" agregado; qualquer coluna que exponha `estimatedVotes` (assimetria travada) ou dado consentido do apoiador.
|
||||
|
||||
## Contexto
|
||||
|
||||
`/campanha/liderancas` (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`) já foi migrada para o sistema de listas do Pass 2 W1: usa `CampaignTable` com colunas como dado, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState` e o pending compartilhado. O que ela **não** tem é o que a lista de municípios ganhou em B15 ✓ (ordenar clicando no header, com `?sort=`/`?dir=` na URL) e B16 ✓ (filtro multi-seleção em Popover no próprio header, com busca dentro do popover, chips de filtro ativo e "Limpar"): o estado da URL é só `q` + `page` (`parseLeadershipListParams` em `src/utilities/leadershipData.ts`) e a ordem é fixa em `-updatedAt`.
|
||||
|
||||
Com o seed do E4R ✓ a base já entrou com lideranças nominais vindas da planilha (nome, sem telefone), e o onboarding do time (Onda 0 §4) vai trabalhar essa lista município a município. Sem recorte, o assessor pagina uma lista ordenada por "última escrita" — que é exatamente a ordem em que o seed as gravou.
|
||||
|
||||
Há ainda um defeito adjacente na busca atual: como o nome vive em `Contact`, `loadLeadershipListPageData` pré-resolve os contatos com `contains` e **`limit: 200`**, e depois filtra as lideranças por esses ids. Uma busca por sobrenome comum trunca silenciosamente. Este item toca exatamente esse caminho de query, então a correção entra junto (ver Objetivos).
|
||||
|
||||
Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: `MunicipalitySortableHead` importa `municipalityListUrl`, e `MunicipalityHeaderFilter` importa `municipalityListFilters`. As outras cinco listas (`demandas`, `dobradinhas`, `organizacoes`, `liderancas`, `apoiadores`) usam o `CampaignTableHead` simples. O B21 (página dos Territórios de Identidade) registrou "head/URL genéricos em `shared/`" como **adiado até o 3º call site**; com este item o terceiro consumidor deixa de ser hipótese.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- **Ordenar pelo header** em `/campanha/liderancas`, com `?sort=`/`?dir=` no contrato canônico do `campaignListUrl` — recarregável e compartilhável, no mesmo padrão do B15 ✓.
|
||||
- **Filtrar pelo header** (Popover, padrão B16 ✓) em Status de apoio (multi), Setor (multi), Município (multi, com facet) e Acesso ao app (com/sem) — mais chips de filtro ativo e "Limpar" pelos shells compartilhados.
|
||||
- **Toda chave de ordenação e todo filtro pousam numa coluna visível**: entram as colunas "Setor" e "Última atualização" (ambas já no `LeadershipRowViewModel`, hoje não renderizadas), para não existir ordenação invisível.
|
||||
- **Ordenação e filtro executados no banco** (`payload.find` com `sort`/`where`), nunca em memória sobre a página de 25 linhas.
|
||||
- **Header e filtros permanecem montados com zero resultados** — a lista passa a usar a prop `empty` do `CampaignTable` (precedente B16 ✓), em vez de trocar a tabela inteira pelo empty state.
|
||||
- **Par head/filtro extraído para `src/components/campaign/shared/`** e `/campanha/municipios` migrado junto, sem mudança de comportamento observável lá (mesmos hrefs, mesmo `aria-sort`, mesmo optimistic).
|
||||
- **Teto de 200 na busca por nome eliminado** — a busca passa pela mesma query da lista se o caminho relacional funcionar no adapter; caso contrário, permanece a pré-resolução e o teto vira defeito nomeado, não silencioso (ver Decisões travadas).
|
||||
- Guardrails: **sem migration, sem collection, sem `Consent`, sem server action**; access inalterado (`isCampaignStaff` na rota, `overrideAccess: false` na leitura, escopo do assessor pelo access da collection); liderança segue sem acesso à área.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Contrato de URL próprio e congelado para esta lista** (`q`, `status`, `sector`, `municipality`, `access`, `sort`, `dir`, `page`), em `src/utilities/leadershipListUrl.ts` sobre os helpers de `campaignListUrl.ts`, com o par default omitido da querystring. É caro de reverter: vira link colado no grupo, entra em Visitados e é o que o **B18** salva. **Rejeitado:** estado em `useState`/`localStorage` (não compartilha, não recarrega, quebra B18); reaproveitar os nomes de param de municípios (`slug`, `region`, `coverage` significam outra coisa aqui — colisão semântica em `RecentVisitTracker` e nos filtros salvos); um único módulo de URL multi-lista parametrizado (acopla três contratos independentes num só ponto de mudança).
|
||||
- **Ordenação por nome via join na query, não por desnormalização.** O adapter Postgres resolve `sort: 'contact.name'` por caminho pontilhado (`buildOrderBy` → `getTableColumnFromPath`, que adiciona o join), então o nome — que vive em `Contact`, a fonte única de pessoa — pode ordenar sem duplicar dado. **Rejeitado:** copiar `contact.name` para um campo `leadership.name` mantido por hook (migration + segunda fonte de verdade de nome, exatamente o cadastro paralelo que o AGENTS proíbe, e por um sort); carregar todas as lideranças do escopo e ordenar em memória (não escala com a base nominal real da C2 e duplica a paginação); ordenar só a página carregada (ordenação que mente). **Fallback nomeado:** se na implementação o caminho relacional não sair no adapter, a v1 entrega apenas as chaves locais (`supportStatus`, `sector`, `updatedAt`) e o sort por nome vira Adiado com gatilho — a desnormalização continua rejeitada.
|
||||
- **Filtros v1 = as colunas visíveis** (Status, Setor, Município, Acesso ao app), todos sobre campos indexados da própria `leadership` (`supportStatus`, `sector`, `municipalities`, `user`). **Rejeitado:** filtro por organização e por dobradinha (não têm decisão nomeada hoje e cada popover custa uma query de facet — adiados com gatilho); filtro por telefone/e-mail preenchido (recorte de PII sem decisão, e o telefone é justamente o que o seed do E4R não trouxe); filtro por "criada por mim".
|
||||
- **Opções dos filtros vêm de facet no escopo do ator**, unindo sempre o valor já selecionado (precedente `loadMunicipalityListFilterFacets`, `src/utilities/municipalityPageData.ts`). O assessor só vê no popover os municípios em que existem lideranças suas. **Rejeitado:** listar os 435 municípios do catálogo (o assessor veria opções que zeram a lista dele — e o payload RSC carregaria 435 pares por render); options fixas sem cross-filtragem (o B16 já pagou o preço de aprender que filtro que zera a lista é filtro quebrado).
|
||||
- **O par head/filtro é extraído para `shared/` neste item, com municípios migrado junto** — `CampaignSortableHead` (âncora de sort + `aria-sort` + slot de filtro + seam de `description` para o B22) e `CampaignHeaderFilterPopover` (chrome do popover: busca, linhas com checkbox, "Limpar", `min-h-11`, a11y). A política — estado, optimistic, hrefs canônicos — **fica no wrapper de domínio** (`LeadershipHeaderFilter`, `MunicipalityHeaderFilter`), não no componente compartilhado. Este é o 3º call site que o B21 nomeou como gatilho. **Rejeitado:** terceira cópia do head (a próxima métrica/tooltip entraria em uma cópia só — foi assim que o `tooltip` do B22 nasceu meio implementado); item de refactor isolado (abstração sem consumidor, zero valor de usuário, e desenhada sem o terceiro caso na mão); esperar o B21 (empurra a decisão e garante três cópias em vez de duas).
|
||||
- **Sem métrica, score ou nível de liderança nesta tela.** Níveis N0–N4 são **E14**, com histerese e vocabulário duplo. **Rejeitado:** coluna "engajamento" ad hoc derivada de `supportStatus` (criaria um segundo vocabulário de nível que o E14 teria de reconciliar).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`leadershipListUrl.ts`, `LeadershipListState`, `LeadershipListSortKey`, `buildLeadershipSortHref`, `leadershipFilterDefinitions`, `LeadershipSortableHead`, `CampaignSortableHead`, `CampaignHeaderFilterPopover`), strings visíveis em pt-BR ("Status", "Setor", "Acesso ao app", "Última atualização", "Limpar").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Qual passa a ser a ordenação default?** **Opções:** manter `-updatedAt` | nome A–Z | status. **Recomendação:** manter `-updatedAt` — trocar o default muda o que a mesa vê ao abrir a tela e ninguém pediu isso; a diferença é que agora ele fica **visível** (coluna "Última atualização" com o header ativo) e reversível em um clique. Revisitar com feedback de uso. _(assumido — validar na primeira demo.)_
|
||||
- **Mobile: só os popovers do header ou também controles empilhados?** **Opções:** header em todos os breakpoints (a tabela já rola na horizontal) | barra de selects empilhados como em `MunicipalityFilters` | select compacto só de ordenação. **Recomendação:** header em todos os breakpoints na v1 — a lista de municípios só precisou da barra empilhada porque no mobile ela **troca a tabela por cards**, o que não acontece aqui. O select compacto fica adiado com gatilho de uso real em celular.
|
||||
- **A busca por nome migra para a query única?** **Opções:** `where: { 'contact.name': { contains } }` na mesma query | manter a pré-resolução com teto maior | manter como está. **Recomendação:** tentar a query única no tracer bullet (resolve o teto de 200 e barateia a paginação); se o caminho relacional não servir para `where` com `overrideAccess: false`, manter a pré-resolução, **paginar** a resolução de contatos e registrar o defeito no ledger em vez de deixar o `limit: 200` mudo.
|
||||
- **A coluna "Municípios" deve mostrar contagem em vez da lista de nomes quando houver muitos?** **Recomendação:** fora deste item — é polish de célula (candidato ao `cellTooltip` do **B23**), não recorte.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Url["leadershipListUrl.ts<br/>(state · parse · where · sort · hrefs)"]
|
||||
Filt["leadershipListFilters.ts<br/>(definições · toggles · chips)"]
|
||||
Loader["leadershipData.ts<br/>(find paginado + facets)"]
|
||||
Page["/campanha/liderancas/page.tsx (RSC)"]
|
||||
Dom["LeadershipSortableHead / LeadershipHeaderFilter<br/>(estado + optimistic + hrefs)"]
|
||||
MunDom["MunicipalitySortableHead / MunicipalityHeaderFilter<br/>(migrados)"]
|
||||
Shared["shared/CampaignSortableHead<br/>shared/CampaignHeaderFilterPopover"]
|
||||
Table["CampaignTable (colunas como dado)"]
|
||||
Url --> Loader --> Page --> Table
|
||||
Url --> Dom
|
||||
Filt --> Dom
|
||||
Dom --> Shared
|
||||
MunDom --> Shared
|
||||
Dom --> Table
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/utilities/leadershipListUrl.ts`** (novo): `LeadershipListState`, `parseLeadershipListParams` (movido de `leadershipData.ts` e estendido), `buildLeadershipListWhere`, `resolveLeadershipListSort`, serialização canônica, `buildLeadershipListHref` / `buildLeadershipSortHref` / `buildLeadershipFilterOptionHref` e `resolveLeadershipListUrl` (redirect canônico), tudo sobre `campaignListUrl.ts` (`firstValue`, `allParamValues`, `normalizedText`, `strictDecimalInteger`, `buildListHref`, `resolveListUrl`). Espelha `municipalityListUrl.ts` sem importá-lo.
|
||||
- **`src/utilities/leadershipListFilters.ts`** (novo): `leadershipFilterDefinitions` (label, seleção multi/toggle, options estáticas para status/setor), helpers de toggle/clear, `formatLeadershipActiveFiltersSummary` para os chips e o empty state. Espelha `municipalityListFilters.ts`.
|
||||
- **`src/utilities/leadershipData.ts`** (alterado, `server-only`): `loadLeadershipListPageData` passa a receber o estado completo, montar `where` a partir de `buildLeadershipListWhere`, aplicar `sort` resolvido e devolver também `filterFacets` (municípios presentes no escopo filtrado). Queries seguem com `user` + `overrideAccess: false`; a resolução de nomes de exibição continua no `namesForIds` (bypass já documentado). `parseLeadershipListParams` sai daqui para o módulo de URL.
|
||||
- **`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`** (alterado): `resolveLeadershipListUrl` + `redirect` canônico (molde de `municipios/page.tsx`), colunas ganham `LeadershipSortableHead` com `filterParam`, entram as colunas `sector` e `updatedAt`, `CampaignFilterChips` + `CampaignTable empty={…}` no lugar da troca de árvore, footer inalterado.
|
||||
- **`src/components/campaign/leadership/LeadershipSortableHead.tsx` / `LeadershipHeaderFilter.tsx`** (novos, client): wrappers finos — `useOptimistic(state)`, hrefs pelo módulo de URL, e render delegado aos compartilhados.
|
||||
- **`src/components/campaign/shared/CampaignSortableHead.tsx` / `CampaignHeaderFilterPopover.tsx`** (novos, extraídos de `MunicipalitySortableHead`/`MunicipalityHeaderFilter`): chrome, a11y (`aria-sort`, `aria-label`, `min-h-11`), busca acima do limiar de opções, linhas exclusivas + multi com checkbox, "Limpar". **Depth check:** só chrome; nenhuma regra de URL, nenhum `state` de domínio, nenhum `useOptimistic` dentro. O tooltip do header (**B22**) e o `cellTooltip` (**B23**) passam a ter um único lugar onde pousar.
|
||||
- **`MunicipalitySortableHead` / `MunicipalityHeaderFilter`** (alterados): mantêm nome, props e comportamento; passam a compor os compartilhados. O "selecionados no topo" (fill-in) continua sendo helper puro em `src/lib/`, consumido pelo popover compartilhado.
|
||||
- **Testes:** unit de `leadershipListUrl` (parse → serialize idempotente, defaults omitidos, valores inválidos descartados — molde de `municipalityListUrl.unit.spec.ts`); int de `loadLeadershipListPageData` (cada filtro, ordenação por nome, e o assessor não vendo liderança fora da carteira mesmo com `?municipality=` de outro município).
|
||||
- **Sem migration, sem collection, sem `Consent`, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Duras:** nenhuma. Consome o sistema de listas do Pass 2 W1 ✓, os padrões B15 ✓ (sort na URL) e B16 ✓ (popover de filtro) e o access já existente da `leadership`.
|
||||
- **Suaves:** **B21** (ordem de chegada define quem duplica: se B21 entrar primeiro, ele duplica o head uma vez, como o plano dele já prevê, e **B29 migra os três**; se B29 entrar primeiro, B21 consome o compartilhado e fecha seu "Adiado com gatilho"); **B28** (mesma tabela: adiciona as colunas E-mail e Celular e declara "sort por coluna" fora do escopo — quem entrar depois só encaixa as colunas do outro; as colunas de contato **não** ganham head rico, ver Não escopo); **B22** (a `description` do header nasce com um só ponto de implementação); **B23** (`cellTooltip` na célula de Municípios); **B17** (as colunas desta lista já são dado — o seletor passa a valer aqui de graça); **B18** (o contrato de URL nasce salvável); **E14** (níveis N0–N4 entram como coluna + filtro nesta tela, sem tela nova); **C2 dados reais** (é quando a lista fica grande o bastante para o recorte ser obrigatório).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Edição rápida de `supportStatus` na célula (padrão B9/B24): outro appetite, outra decisão — ver Adiado com gatilho.
|
||||
- Sort/filtro nas colunas de contato do **B28** (E-mail, Celular): ninguém decide nada ordenando por e-mail, e "com telefone" é recorte de PII sem decisão nomeada — as colunas convivem com `CampaignTableHead` simples ([plano](email-celular-lista-liderancas.md)).
|
||||
- Níveis de envolvimento N0–N4 como coluna/filtro: **E14** ([plano](niveis-de-envolvimento.md)).
|
||||
- Overview/KPIs de lideranças no topo da página: anti-goal declarado; a leitura que decide alocação é a de município (**E8**/**E9**).
|
||||
- Migrar `demandas`, `dobradinhas`, `organizacoes` e `apoiadores` para o header rico: cada uma entra por pedido próprio — a peça compartilhada fica pronta.
|
||||
- Filtros salvos e atalho no sidebar para esta lista: **B18** ([plano](filtros-salvos-municipios.md), que passa a valer para mais de uma lista).
|
||||
- Busca acento-insensível no banco (`unaccent`/`pg_trgm`): **C8** ([plano](escala-dry-pos-c6.md)).
|
||||
- Export CSV da lista filtrada: sem pedido; o admin Payload já exporta contatos.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que vamos extrair, vamos fazer o framework de listas de verdade."** Um motor genérico de estado de URL + facets + colunas com 3 consumidores é o clássico DRY prematuro, e travaria os três contratos num ponto de mudança. **Mitigação neste item:** extrair **apenas chrome** (head + popover); módulos de URL e de filtros continuam um por domínio, duplicados de propósito.
|
||||
- **Desnormalizar o nome na `leadership` "de passagem".** Um campo + hook + migration + backfill, e uma segunda fonte de verdade do nome de uma pessoa. **Mitigação:** decisão travada acima; se o join não servir, corta-se o sort por nome, não a normalização.
|
||||
- **Migrar as outras quatro listas junto.** Cada uma tem seu contrato de URL e seus facets; vira uma semana. **Mitigação:** Não escopo explícito; o ganho é a peça pronta, não a varredura.
|
||||
- **Facet por popover sem teto.** Um facet por filtro em cima de uma tabela que vai crescer com a base nominal real é `SELECT` sem paginação por render. **Mitigação:** só o filtro de Município tem facet (os outros são enums estáticos), com `select` mínimo e reuso da mesma consulta quando o `where` coincide — como já faz `loadMunicipalityListFilterFacets`.
|
||||
- **Filtrar por município via nome/slug.** A relação é por id; aceitar nome na URL convida a matching frouxo e a divergência com o catálogo. **Mitigação:** param `municipality` só com ids inteiros, validados no parser.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Edição rápida de `supportStatus` na célula** (Popover + auto-save, padrão B9/B24). Revisitar quando: a mesa reportar que abre a ficha só para mudar o status, ou ≥1 pedido em sessão/R6.
|
||||
- **Filtro por organização e por dobradinha.** Revisitar quando: houver decisão nomeada em sessão (ex.: "quero as lideranças do sindicato X") ou quando a cobertura de `organizations` na base real passar a ser a regra, não a exceção.
|
||||
- **Select compacto de ordenação no mobile.** Revisitar quando: uso real em celular mostrar que rolar o header na horizontal atrapalha (mesmo gatilho que produziu o select compacto do B15).
|
||||
- **Sort por nome (se o join não sair no adapter).** Revisitar quando: houver evidência de que a ordem alfabética é bloqueante — e ainda assim sem desnormalizar (a alternativa seria uma view/consulta dedicada).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / "Demais itens abertos", B29; Janela 1–2; grafo; cortes seguros)
|
||||
- [email-celular-lista-liderancas.md](email-celular-lista-liderancas.md) (B28 — colunas vizinhas na mesma tabela) · [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15 — contrato `sort`/`dir`) · [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) (B16 — popover, facets, chips) · [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21 — registrou o gatilho do head compartilhado) · [explicacao-colunas-header-listas.md](explicacao-colunas-header-listas.md) (B22) · [tooltip-celulas-listas.md](tooltip-celulas-listas.md) (B23) · [seletor-colunas-lista-municipios.md](seletor-colunas-lista-municipios.md) (B17) · [filtros-salvos-municipios.md](filtros-salvos-municipios.md) (B18) · [sistema-listas-campanha.md](sistema-listas-campanha.md) (Pass 2 W1) · [niveis-de-envolvimento.md](niveis-de-envolvimento.md) (E14)
|
||||
- `src/app/(campaign)/campanha/(app)/liderancas/page.tsx` — superfície a estender (colunas, empty state, footer)
|
||||
- `src/utilities/leadershipData.ts` — loader, view model (`sector`, `updatedAt` já disponíveis) e a pré-resolução de contatos com `limit: 200`
|
||||
- `src/utilities/municipalityListUrl.ts`, `src/utilities/municipalityListFilters.ts`, `src/utilities/campaignListUrl.ts` — contrato a espelhar e helpers a reusar
|
||||
- `src/utilities/municipalityPageData.ts` (`loadMunicipalityListFilterFacets`) — padrão de facet cross-filtrado
|
||||
- `src/components/campaign/municipality/MunicipalitySortableHead.tsx`, `MunicipalityHeaderFilter.tsx`, `src/components/campaign/shared/CampaignTable.tsx` — origem da extração e destino das colunas
|
||||
- `src/collections/Leadership.ts`, `src/utilities/access/` — campos indexados (`supportStatus`, `sector`, `municipalities`, `user`) e escopo do assessor
|
||||
- AGENTS.md — Campaign auth e RBAC, `overrideAccess: false` com `user`, pessoa é sempre `Contact` + join, naming (identificadores em inglês, copy pt-BR), sem migration neste item
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, anti-goals de dashboard, "Edit where you see" (não se aplica na v1 desta tela) · `.cursor/rules/campanha-action-feedback.mdc` (pending honesto no resultado)
|
||||
141
docs/plans/pagina-territorios-identidade.md
Normal file
141
docs/plans/pagina-territorios-identidade.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# B21 — Página própria dos Territórios de Identidade (overview + tabela)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B21; conteúdo herdado de E17 ✓ e estendido por E12)
|
||||
Impeccable: C — rota nova `/campanha/territorios`, mas com encaixe direto no sistema de listas existente (`CampaignPageShell` + `CampaignTable` + header sort/filtro do B15/B16)
|
||||
Appetite: ~1–1,5 dia eng; sem migration, sem collection, sem server action — rota + módulo de URL + definições de coluna + entrada no sidebar
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 e 6; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`src/components/campaign/shared/CampaignTable.tsx`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListPendingBoundary`) · painel entregue no Início (`TerritoryOverviewTable`).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): shape compacto → craft → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8 (a superfície é leitura agregada de 27 linhas; não há escrita nem hot path).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Coordenador Geral (e candidato) na mesa, dividindo carteiras de assessor e escolhendo a região do próximo giro; a tabela dos 27 TIs no Início ficou pequena demais para essa sessão (é um painel entre outros, com 7 colunas fixas e sem filtro).
|
||||
- **Job principal:** comparar os 27 Territórios de Identidade numa tela dedicada — ordenar pela coluna que importa hoje, esconder o ruído, e cair na lista de municípios do TI escolhido.
|
||||
- **Estratégia de cor:** Restrained. Tabela densa e sóbria; badge âmbar só para "sem assessor" (mesma linguagem do E9/E17). Sem heatmap, sem cor por ranking.
|
||||
- **Edit where you see:** **não** neste item — TI não é entidade editável (a malha é estática em `bahiaTerritories.ts`, carteiras continuam por município na lista/`/campanha/assessores`). A affordance de ação é o link para a lista filtrada.
|
||||
- **Anti-goals:** dashboard regional com cards de KPI; segundo mapa; ranking gamificado entre TIs; média regional apresentada como se decidisse município (MAUP); tabela divergente da do Início (duas implementações da mesma leitura).
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim, superfície neste item — os mesmos agregados por TI que o E17 já calcula (`computeTerritoryRollup`), numa tela dedicada com ordenação e filtro por coluna.
|
||||
- **Decisões desbloqueadas:**
|
||||
- Coordenador: "qual TI está descoberto de assessor e concentra votação minha o bastante para eu remanejar carteira nesta semana?"
|
||||
- Coordenador/candidato: "qual região recebe o próximo giro?" (peso eleitoral × estimativa 2026 × cobertura).
|
||||
- Staff: "deste TI, quais municípios abrem a fila?" — o clique leva a `/campanha/municipios?region=<TI>` já ordenado pelo déficit (default do E9).
|
||||
- **Forma escolhida:** **tabela/lista ranqueada** (degrau 2 da escada) — 27 entidades comparáveis em 6–7 dimensões; ordenar e filtrar é a interação, não visualizar. **Rejeitado:** mapa por TI (a geometria existe em B2, mas trocar a malha do mapa é decisão de E12/B13, e a pergunta aqui é comparativa, não espacial); chart de barras (só responderia uma coluna por vez); dashboard com cards por TI (27 cards = anti-goal de PRODUCT §5); KPI strip no topo (a soma estadual já vive no Início e no overview da lista de municípios).
|
||||
- **Profile:** categórico (27 TIs, +2 sub-linhas do Metropolitano) × numérico (votos 2014/2018/2022, válidos 2022, estimativa 2026, municípios, cobertura n/n); tamanho fixo e pequeno; leitura **relativa** (% da própria votação é a lente da mesa — `docs/CUSTOMER.md` 2026-07-23).
|
||||
- **Anti-goals de dado:** % do eleitorado estadual como coluna principal; captura/LQ/mediana regional aqui (só com as salvaguardas MAUP do **E12**); qualquer média de razões.
|
||||
|
||||
## Contexto
|
||||
|
||||
O E17 (entregue 2026-07-24) deu ao produto a primeira superfície regional: um painel no Início staff (`src/app/(campaign)/campanha/(app)/page.tsx` → `TerritoryOverviewSection`) com `TerritoryOverviewTable`, alimentado por `loadTerritoryOverview.ts` sobre o rollup puro `territoryOverview.ts`. Aquela tabela é deliberadamente mínima: 7 colunas fixas, ordenação client-side em `useState`, sem busca, sem filtro, sem estado na URL — ela divide espaço com o mapa e os KPIs do dashboard.
|
||||
|
||||
Enquanto isso, a lista de municípios amadureceu num sistema completo (Pass 2 W1 + B15 + B16): colunas como dado em `CampaignTable`, estado na URL canonicalizado em `municipalityListUrl.ts`, header com sort (`MunicipalitySortableHead`) e filtro multi-seleção em Popover (`MunicipalityHeaderFilter`), pending honesto (`CampaignListPendingBoundary`), chips e footer compartilhados.
|
||||
|
||||
O pedido (2026-07-25) é fechar essa distância: uma **página própria** para os Territórios de Identidade, com overview + tabela no mesmo padrão da de municípios. Este item entrega a superfície; as **métricas** regionais mais ricas (captura, mediana, amplitude, município crítico, cobertura de meta por TI) continuam sendo **E12**, que passa a pousar aqui em vez de inventar tela nova.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- **Rota nova `/campanha/territorios`** dentro de `(app)`: staff-only (`isCampaignStaff`), `leader` redirecionado para `/campanha` — mesmo gate de `/campanha/municipios` e `/campanha/conceitos`.
|
||||
- **Tabela sobre `CampaignTable`** com as colunas do E17 como definições de dado (`id`/`head`/`cell`), herdando os seams do B17 (`mandatory` em "Território", `defaultVisible`).
|
||||
- **Sort no header via URL** (`?sort=&dir=`), no contrato do `campaignListUrl` — recarregável, compartilhável e consistente com a lista de municípios; substitui o `useState` local do E17 nesta superfície.
|
||||
- **Filtro no header** (Popover, padrão B16) no conjunto mínimo que decide algo com 27 linhas: Território (multi-seleção) e Cobertura (`com_assessor` / `sem_assessor`), mais busca por nome; chips de filtro ativo + "Limpar" pelos shells compartilhados.
|
||||
- **Metropolitano decomposto** preservado (Salvador 19 zonas × Demais RMS) — salvaguarda permanente do relatório; sub-linhas nunca são reordenadas independentemente.
|
||||
- **Linha → lista filtrada** `/campanha/municipios?region=<TI>` (comportamento já entregue no E17).
|
||||
- **Uma única implementação da leitura por TI:** o painel do Início passa a consumir as mesmas definições de coluna (subconjunto compacto) e ganha um link "Ver todos os territórios" para a página. Sem segunda tabela divergente.
|
||||
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; leitura agregada com `overrideAccess: true` (precedente E17), nunca PII por município.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Rota própria (`/campanha/territorios`) em vez de absorver em E12.** O E12 está escrito como "rollup nas superfícies existentes, sem rota nova" e nomeia "rollup virar segunda página de analytics" como rabbit hole; o pedido de 2026-07-25 é justamente a página. Separar mantém o E12 no que ele é bom (métricas com salvaguardas MAUP) e põe a superfície na trilha B, que já é dona do sistema de listas. **Rejeitado:** absorver como fase do E12 (mistura appetite de métrica com appetite de tela e reabre o rabbit hole sem nomear dono); estender o painel do Início até virar tela (o Início já carrega mapa + KPIs + tabela; crescer ali é o anti-goal de dashboard); rota `/campanha/municipios?group=territorio` (agrupamento colapsável — segue sendo opção do E12 na lista, mas não responde "quero comparar os 27 lado a lado").
|
||||
- **URL como fonte de verdade do sort/filtro** (`campaignListUrl` + módulo de domínio `territoryListUrl.ts`), não `useState`. É o contrato congelado do produto (B15/B16/B18) — link compartilhável para a mesa, `RecentVisitTracker` e filtros salvos futuros funcionam de graça. **Rejeitado:** manter ordenação client-side (27 linhas cabem no cliente, mas produzem uma segunda gramática de lista e quebram B18); `localStorage` como persistência (não compartilha).
|
||||
- **Sem paginação e sem `where` no banco.** São 27 linhas derivadas de um rollup que já lê os 435 municípios de uma vez; busca e filtro se aplicam **em memória** sobre as linhas do rollup. **Rejeitado:** replicar `buildMunicipalityListWhere`/paginação (cerimônia sem carga); `CampaignListPagination` (nada a paginar — o footer entra só com a contagem).
|
||||
- **Colunas v1 = as do E17** (Território · Municípios · Votos 2022 com série · % da própria votação · Válidos 2022 · Estimativa 2026 · Com assessor), ordenação default **% da própria votação desc**. Toda métrica derivada nova (captura, LQ, mediana, amplitude, município crítico, cobertura de meta por TI) entra **com o E12**, junto das salvaguardas. **Rejeitado:** aproveitar a página nova para já somar cobertura de meta (E8 existe por município) — sem a regra "razão dos agregados + decomposição obrigatória" do E12 é exatamente a média que mente.
|
||||
- **"Colunas reordenadas" = ordenação de linhas pelo header (B15) + visibilidade (B17), não drag-and-drop de colunas.** Reordenar colunas por DnD está em "Fora de escopo (por enquanto)" no roadmap desde 2026-07-24, com gatilho próprio. **Rejeitado:** abrir exceção nesta tela (o gatilho registrado é evidência de campo, não superfície nova).
|
||||
- **Advisor vê a tabela completa** (`overrideAccess: true` no loader, agregados TI-level), como no E17: leitura regional é contexto, não gestão. **Rejeitado:** escopar por `municipality.advisors` (produziria "% da própria votação" calculada sobre um denominador parcial — número errado, não número reduzido).
|
||||
- **i18n e naming:** `territoryListUrl.ts`, `TerritoryListState`, `TerritoryListSortKey`, `buildTerritoryListHref`, `territoryListColumns`, `TerritorySortableHead`; rota em português (`/campanha/territorios`), labels pt-BR ("Territórios de Identidade", "Com assessor").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O painel do Início continua?** **Opções:** (A) continua completo + link; (B) vira versão compacta (Território · % da votação · Com assessor) com "Ver todos"; (C) sai do Início. **Recomendação: B** — o Início volta a ser resumo e a página vira o lugar de trabalhar, sem perder o pedido do candidato (a leitura regional segue visível na home). Reaproveita as mesmas definições de coluna via subconjunto de `id`s. _(assumido — validar na primeira demo.)_
|
||||
- **Entrada no sidebar: item próprio ou 2º nível sob Municípios?** **Opções:** item em `staffNav` logo abaixo de Municípios | submenu de Municípios (o padrão que o B18 planeja para filtros salvos). **Recomendação:** item próprio em `staffNav` (`MapIcon`), **fora** do `getCampaignBottomNav` (o bottom bar corta em 5 e Territórios não desloca Planos). O submenu do B18 é para estados salvos da mesma lista, não para outra entidade.
|
||||
- **Filtro de "peso" (ex.: só TIs acima de X% da votação)?** **Recomendação:** não na v1 — com 27 linhas, ordenar por % resolve; um filtro numérico é controle a mais sem decisão nova. Reavaliar se a mesa pedir corte fixo.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Rollup["loadTerritoryOverview.ts<br/>(E17 ✓ — cache, overrideAccess)"]
|
||||
Url["territoryListUrl.ts<br/>(parse/canonicalize/href · campaignListUrl)"]
|
||||
Filter["filterTerritoryRows + sortTerritoryRows<br/>(territoryOverview.ts, puro)"]
|
||||
Cols["territoryListColumns.tsx<br/>(CampaignTableColumn[])"]
|
||||
Page["/campanha/territorios/page.tsx<br/>(RSC, staff gate)"]
|
||||
Table["CampaignTable + TerritorySortableHead"]
|
||||
Home["Início: painel compacto + 'Ver todos'"]
|
||||
Rollup --> Page
|
||||
Url --> Page
|
||||
Page --> Filter --> Table
|
||||
Cols --> Table
|
||||
Cols --> Home
|
||||
Table -->|linha| Municipios["/campanha/municipios?region=TI"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/app/(campaign)/campanha/(app)/territorios/page.tsx`** (novo): RSC no molde de `municipios/page.tsx` — `getCampaignUser()`, `isCampaignLeader → redirect('/campanha')`, `resolveTerritoryListUrl` (redirect canônico), `loadTerritoryOverview(payload)`, `CampaignPageShell` + header + `CampaignListPendingBoundary`/`CampaignListResults`. Sem server action.
|
||||
- **`src/utilities/territoryListUrl.ts`** (novo): estado, parse/canonicalização, serialização e `resolveListUrl` sobre os helpers de `campaignListUrl.ts` (`firstValue`, `allParamValues`, `normalizedText`, `buildListHref`). Params: `q`, `region` (multi), `coverage`, `sort`, `dir`. Sem `page`.
|
||||
- **`src/utilities/territoryOverview.ts`** (existente, client-safe): ganha `filterTerritoryRows(rows, state)` puro ao lado de `sortTerritoryRows`; sub-linhas do Metropolitano acompanham a linha-mãe (nunca filtradas isoladamente). Unit tests estendem `territoryOverview.unit.spec.ts`.
|
||||
- **`src/components/campaign/municipality/territoryListColumns.tsx`** (novo): as 7 colunas como `CampaignTableColumn<TerritoryOverviewRow>[]`, com `mandatory` em `region` e `defaultVisible` nas demais (seam B17). Células reaproveitam `YearSeriesCell`/`CoverageCell` extraídas do `TerritoryOverviewTable` atual.
|
||||
- **`TerritorySortableHead`** (novo, client, em `components/campaign/municipality/`): irmão de `MunicipalitySortableHead` — `CampaignTransitionAnchor` + `aria-sort` + Popover de filtro quando a coluna tem `filterParam`. **Depth check:** se ao implementar as duas cabeças divergirem só no módulo de URL, extrair um head genérico em `shared/` (3º call site chega com E12) — não criar a abstração antes disso.
|
||||
- **`TerritoryOverviewTable.tsx`** (existente): passa a receber as definições de coluna e o modo (`compact` no Início | `full` na página), perdendo o `useState` de sort na variante da página. É a mesma leitura em dois tamanhos, não duas tabelas.
|
||||
- **`src/components/campaign/shell/nav.ts`**: entrada "Territórios" em `staffNav` após "Municípios"; excluída de `getCampaignBottomNav` (mesmo tratamento de Apoiadores/Assessores).
|
||||
- **Sem migration, sem collection, sem `Consent`, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Duras:** nenhuma pendente. Consome E17 ✓ (`territoryOverview.ts` + `loadTerritoryOverview.ts`), o sistema de listas do Pass 2 W1 ✓ (`CampaignTable`, `campaignListUrl`, shells) e os padrões B15 ✓/B16 ✓ (sort e filtro no header).
|
||||
- **Suaves:** **E12** (adiciona colunas com salvaguardas MAUP nesta página em vez de criar superfície nova); **B17** (o seletor de colunas passa a valer para esta tabela sem trabalho extra — as colunas já são dado); **B13** (se o mapa ganhar modo TI, a página vira o par tabular dele); **E4R** em produção (estimativa 2026 viva na coluna).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Métricas regionais derivadas — captura, LQ, mediana, amplitude, município crítico, gap regional, cobertura de meta por TI: **E12**.
|
||||
- Mapa por TI: **E12 / B13** (a geometria de B2 existe; a decisão de malha do mapa não é deste item).
|
||||
- Rota de detalhe por TI (`/campanha/territorios/<slug>`): o drill continua sendo a lista de municípios filtrada.
|
||||
- Link de entrada a partir da coluna "Território" da lista de municípios (e o `id` de âncora por linha que ele exige): **B25** ([plano](link-territorio-lista-municipios.md)), que depende desta página.
|
||||
- Agrupamento "por Território" **dentro** da lista de municípios: continua sendo entrega do **E12**.
|
||||
- Reordenar colunas por drag-and-drop: "Fora de escopo (por enquanto)" no roadmap, com gatilho próprio ([plano](reordenar-colunas-lista-municipios.md)).
|
||||
- Filtros salvos / atalho no sidebar para estados desta tabela: **B18** (o contrato de URL nasce compatível).
|
||||
- Qualquer edição em nível de TI (malha, carteira regional, meta regional).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que é página, coloca os KPIs também."** Vira dashboard regional — anti-goal declarado. **Mitigação:** o topo da página é header + busca/chips; nenhum card de métrica. Somas estaduais continuam no Início.
|
||||
- **Generalizar o sistema de listas "de verdade" nesta entrega.** Um head genérico + hook de URL genérico com 2 call sites é abstração prematura. **Mitigação:** duplicar conscientemente o head (delta pequeno) e extrair só quando E12/B17 trouxerem o 3º consumidor; registrar como Adiado com gatilho.
|
||||
- **Paginação/facets copiados da lista de municípios.** 27 linhas não pedem `where`, `count`, nem paginação; copiar o pipeline inteiro custa meio dia e adiciona superfície de bug. **Mitigação:** filtro e ordenação em memória sobre o rollup, footer só com contagem.
|
||||
- **Sub-linhas do Metropolitano viram drill-down.** TI → município → zona dentro da tabela é caminho para uma árvore. **Mitigação:** para nas duas sub-linhas; o drill é o link para a lista filtrada.
|
||||
- **Duas tabelas de TI divergindo.** Se o Início mantiver a implementação atual e a página ganhar outra, a próxima métrica entra em uma só. **Mitigação:** definições de coluna compartilhadas desde o primeiro commit; o Início consome um subconjunto de `id`s.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Head/URL genéricos em `shared/`.** Gatilho **disparado em 2026-07-25**: o 3º call site é o **B29** (sort + filtro no header de `/campanha/liderancas` — [plano](ordenacao-filtros-lista-liderancas.md)), que **assume** a extração do par head/filtro para `shared/` e a migração de municípios. Se B21 entrar primeiro, duplica o head uma vez como descrito acima e o B29 migra os três; se B29 entrar primeiro, esta página já nasce consumindo o compartilhado. Os **módulos de URL** seguem um por domínio em qualquer ordem.
|
||||
- **Filtros adicionais no header** (tendência agregada, faixa de peso). Gatilho: a mesa pedir um corte que a ordenação não resolve em sessão real.
|
||||
- **Coluna de cobertura de meta por TI.** Gatilho: **E12** entregue com a regra "razão dos agregados + decomposição obrigatória".
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B / "Demais itens abertos", B21; Janela 1–2; cortes seguros)
|
||||
- [tabela-ti-inicio.md](tabela-ti-inicio.md) (E17 ✓ — rollup, salvaguardas, decisões de coluna) · [camada-territorios-identidade.md](camada-territorios-identidade.md) (E12 — métricas que pousam aqui) · [sistema-listas-campanha.md](sistema-listas-campanha.md) (Pass 2 W1) · [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15) · [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) (B16) · [seletor-colunas-lista-municipios.md](seletor-colunas-lista-municipios.md) (B17)
|
||||
- `src/utilities/territoryOverview.ts`, `src/utilities/loadTerritoryOverview.ts` — rollup e loader a reusar
|
||||
- `src/utilities/campaignListUrl.ts`, `src/utilities/municipalityListUrl.ts` — contrato de URL a espelhar
|
||||
- `src/components/campaign/shared/CampaignTable.tsx`, `CampaignSearchForm.tsx`, `CampaignFilterChips.tsx`, `CampaignListFooter.tsx`, `CampaignListPending.tsx` — shells
|
||||
- `src/components/campaign/municipality/MunicipalitySortableHead.tsx`, `MunicipalityHeaderFilter.tsx`, `TerritoryOverviewTable.tsx` — padrões de header e células
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/page.tsx`, `src/app/(campaign)/campanha/(app)/page.tsx`, `src/components/campaign/shell/nav.ts` — molde de rota, painel do Início, sidebar
|
||||
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.5 (TI como camada de coordenação, salvaguardas MAUP) · `docs/CUSTOMER.md` (% da própria votação como lente da mesa)
|
||||
- AGENTS.md — Campaign auth e access por papel, naming (identificadores em inglês, copy pt-BR), sem migration neste item
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, anti-goals de dashboard SaaS, "Edit where you see" (não se aplica: leitura)
|
||||
132
docs/plans/registrar-sinal-lista-municipios.md
Normal file
132
docs/plans/registrar-sinal-lista-municipios.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Registrar sinal a partir da célula "Último sinal" da lista de municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B26)
|
||||
Impeccable: B — encaixe na célula `lastSignal` de `/campanha/municipios` (tabela desktop + card mobile); sem rota nova de UI
|
||||
Appetite: ~0,75 dia eng; 1 controle novo + 1 form action + fields compartilhados; sem migration, sem collection, sem endpoint JSON
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 **Clarity under pressure**, princípio 3 **Edit where you see**, princípio 4 **Auto-save, no Save button** — e a **exceção** nomeada nele para escrita atômica multi-campo, princípio 8 **Feel the action**) / `DESIGN.md` (register `product` — Field Desk) · tema `data-theme='campaign'` · regras `.cursor/rules/campanha-edit-where-you-see.mdc` (item 4: submit explícito continua válido para fluxo multi-campo) e `.cursor/rules/campanha-action-feedback.mdc` · precedentes vivos: [`MunicipalityListTrendControl.tsx`](../../src/components/campaign/municipality/MunicipalityListTrendControl.tsx) (Popover na célula) e [`MunicipalityUpdateForm.tsx`](../../src/components/campaign/municipality/MunicipalityUpdateForm.tsx) (campos do sinal tipado do C12).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Sem shape completo: a célula, o Popover de lista e o formulário de sinal já existem no produto; o que é novo é a **combinação** e a apresentação mobile (primeira vez que um controle de célula abre em bottom sheet).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e Casey (Assessor) varrendo a fila de alocação — a lista está ordenada por déficit (default do E9) ou por frescor, e a coluna "Último sinal" é justamente onde o município grita "ninguém encosta aqui há 34 dias". No celular, em campo, logo depois de uma ligação ou de uma conversa no ZAP.
|
||||
- **Job principal:** transformar a leitura "está frio" em registro no mesmo gesto — anotar o sinal ali, sem abrir o município, sem trocar de tela, sem perder o lugar na varredura.
|
||||
- **Estratégia de cor:** Restrained (inalterada) — a única cor da célula continua sendo o âmbar `text-estimate-pending-foreground` do estado frio (E9). O gatilho não ganha cor própria.
|
||||
- **Edit where you see:** sim — hoje a célula é o único readout puramente morto da faixa staff da linha (assessores, tendência e votos estimados já são editáveis desde B9/B19). O dado por trás dela, porém, não é um campo do município: é uma **criação** de `municipalityUpdate`, o que muda o contrato (ver Decisões travadas).
|
||||
- **Anti-goals:** virar o formulário longo de atualizações dentro de um Popover (as 3 textareas do relatório semanal); spreadsheet/data-grid mode; segundo sistema genérico de célula editável; Popover ancorado espremido sob o teclado do celular; criar registro vazio "rascunho" só para ter o que auto-salvar.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não. `Dados: N/A` — o item é affordance de **escrita**. A leitura da célula (frescor E9: `max(lastUpdateAt, lastPledgeAt)`, rótulo "há N dias", limiar frio de 21 dias) continua idêntica, e nenhuma métrica, série, ranking ou mapa novo entra.
|
||||
- **Efeito de leitura a vigiar:** depois de gravar, o próprio número muda (o município passa a "hoje"). Isso é o ponto — e é o motivo de a gravação **revalidar** em vez de fingir otimismo (ver Decisões travadas): o rótulo é resultado derivado, não valor de controle.
|
||||
|
||||
## Contexto
|
||||
|
||||
Em `/campanha/municipios`, a coluna staff **"Último sinal"** nasceu no **E9** ([fila-de-alocacao.md](fila-de-alocacao.md)): [`SignalAgeReadout`](../../src/components/campaign/municipality/MunicipalityList.tsx) renderiza `municipality.lastSignalAt` como "hoje" / "há N dias" / "Sem sinal", em âmbar com ícone quando passa de `MUNICIPALITY_COLD_SIGNAL_DAYS` (21). A coluna também é a chave de ordenação `frescor`. É um readout: para registrar qualquer coisa, o usuário precisa abrir `/campanha/municipios/[slug]?tab=updates` (ou `?newUpdate=1`) e usar o [`MunicipalityUpdateForm`](../../src/components/campaign/municipality/MunicipalityUpdateForm.tsx), depois voltar e reencontrar o lugar na lista.
|
||||
|
||||
Os sinais tipados vieram do **C12** ([registro-fundacao.md](registro-fundacao.md)): `municipalityUpdate` com `kind: 'sinal'` exige `body`, `signalType` (invasão / esfriamento / visita adversária / proposta a broker / outro) e `signalSource`, mais o checkbox staff-only `triangulated`. O registro é **imutável** para o staff (`canMutateMunicipalityUpdate` = só admin Payload), e o `afterChange` recomputa `municipality.lastUpdateAt`, que alimenta o frescor.
|
||||
|
||||
Pedido de produto em **2026-07-25** (mesma leva de B22/B23/B24): clicar na célula de sinal deve abrir um popover para criar um sinal novo; no celular, um bottom sheet ("dialog daqueles que vem de baixo"). O item fecha o laço da fila de alocação: hoje o E9 diz **onde** está frio e o produto obriga a sair da fila para fazer algo a respeito.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Clicar na célula "Último sinal" (tabela desktop) abre um **Popover** com o formulário curto de sinal; no card mobile, um **bottom sheet** (`Drawer`) com os mesmos campos.
|
||||
- O formulário grava um `municipalityUpdate` com `kind: 'sinal'` reusando `createMunicipalityUpdate` — mesma validação (zod + hook da collection) e o mesmo access do formulário longo.
|
||||
- Depois de gravar: célula atualizada pelo servidor (o município vira "hoje"), popover/sheet fechado, confirmação visível, e pendente honesto durante a revalidação (princípio 8).
|
||||
- Erro (inclusive assessor fora da carteira) aparece **dentro** do popover/sheet, com o rascunho preservado — nada de fechar engolindo o texto.
|
||||
- Toque confortável: gatilho e campos com `min-h-11`; o sheet mobile respeita o teclado virtual.
|
||||
- Access inalterado (`canCreateMunicipalityUpdate`: unrestricted vê tudo, assessor só a carteira, `leader` não tem a coluna nem a lista). Sem migration, sem collection, sem `Consent` novo, sem endpoint JSON novo.
|
||||
- Ordenação/`aria` do header `frescor` (B15) e o tooltip de coluna intactos.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **O submit continua explícito ("Registrar sinal") — este popover é a exceção do princípio 4, não uma regressão do B24.** É uma **criação multi-campo atômica**: `body` + `signalType` + `signalSource` são obrigatórios pelo `municipalityUpdateCreateSchema` e pelo `validateMunicipalityUpdateKind`, e o registro é imutável para o staff depois de criado. **Rejeitado:** auto-save por campo (cada debounce tentaria criar um registro inválido e, se algum passasse, viraria linha permanente no feed — o staff não pode editar nem apagar); criar registro vazio ao abrir e ir preenchendo (lixo no feed do município, no dossiê E16 e no frescor, já que qualquer update carimba `lastUpdateAt`). O `PRODUCT.md` princípio 4 e a regra `campanha-edit-where-you-see.mdc` item 4 preveem exatamente isso ("write must be atomic across several fields"); o B24 ([autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md)) tira o "Salvar" de uma edição de **campo único** na mesma linha — os dois contratos convivem porque a natureza da escrita é diferente, e a diferença precisa estar visível na copy do botão ("Registrar sinal", não "Salvar").
|
||||
- **Só `kind: 'sinal'` a partir da lista.** A coluna se chama "Último sinal" e o C12 já dá ao sinal um vocabulário fechado (tipo + fonte + triangulado). **Rejeitado:** seletor dos 4 `kind` no popover — `semanal` sozinho traz três textareas de 3000 caracteres mais dois números e transformaria o popover no formulário de detalhe (anti-goal do brief); `nota`/`urgente` continuam a um clique no `?tab=updates`, que segue sendo o lugar do registro completo.
|
||||
- **Gravação por server action + `revalidateMunicipalityListPaths({ slug })`, não por endpoint JSON com otimismo local.** O que a célula mostra é **resultado derivado** (`max(lastUpdateAt, lastPledgeAt)`), e inventar "hoje" no cliente é o anti-goal literal do princípio 3 ("optimistic writes of list/aggregate results that skip server refresh"); além disso o novo sinal precisa aparecer no feed do detalhe e no dossiê E16 sem depender de sorte de cache. O custo é aceitável porque a escrita é **um submit deliberado**, não um debounce de digitação — diferente do B24 e do controle de votos estimados, que gravam várias vezes por minuto. **Rejeitado:** `POST /campanha/municipios/signal` espelhando `expected-votes/route.ts` (mais código, dado derivado mentiroso e detalhe/dossiê defasados); `router.refresh()` no cliente (mesmo custo de RSC, sem a revalidação do detalhe).
|
||||
- **Popover no desktop, `Drawer` (bottom sheet) no mobile, escolhidos pela divisão de breakpoint que a lista já tem — não por `useIsMobile()`.** `MunicipalityList` já renderiza dois ramos: cards `md:hidden` e tabela `hidden md:block`; cada ramo monta a sua variante e ambos compartilham um único componente de campos. **Rejeitado:** `useIsMobile()` (o hook existe em `src/hooks/use-mobile.ts`, mas hoje só o `Sidebar` usa; num controle por linha ele adiciona hidratação em duas fases e um segundo critério de breakpoint concorrendo com o CSS que já decide o layout); `Sheet side="bottom"` (é Radix Dialog sem swipe; o `Drawer` do `@base-ui/react` já é o idioma de "vem de baixo" do produto, usado no `InstallPwaToast`); manter Popover ancorado no mobile (o pedido de produto nasceu disso — popover ancorado num card estreito fica espremido e o teclado virtual cobre os campos).
|
||||
- **O gatilho é a própria leitura de frescor, não um ícone extra.** A célula vira um `button` cujo rótulo acessível é "Registrar sinal em {município} — {rótulo de frescor}", preservando cor/ícone do estado frio. **Rejeitado:** botão "+" separado ao lado do texto (dois alvos numa célula densa, e o alvo útil vira o menor dos dois); coluna nova de ação (densidade morta — o fill-in de remover a coluna "Tipo" está indo na direção contrária).
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês — `MunicipalityListSignalControl`, `MunicipalitySignalFields`, `createMunicipalityListSignalFormAction`; strings visíveis em pt-BR ("Registrar sinal", "Tipo do sinal", "Fonte", "Triangulado", "Sinal registrado.").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Ordenado por `frescor`, a linha salta ao gravar. Revalidar mesmo assim?** **Opções:** A) sempre revalidar (a linha se move); B) nunca revalidar (célula mente); C) revalidar sempre, mas nomear o município na confirmação para reancorar quem estava varrendo. **Recomendação:** C — mover a linha é o comportamento **honesto** (o município deixou de estar frio, que é o objetivo da fila), e o custo real é perder o lugar visual; a confirmação "Sinal registrado em Vitória da Conquista." resolve isso por ~zero. Sob o default `deficit` nada se move.
|
||||
- **As cinco descrições dos tipos de sinal (que o formulário longo lista embaixo do select) cabem no popover?** **Opções:** A) não, só o select; B) lista completa como no detalhe; C) descrição do tipo **selecionado**, em uma linha, abaixo do select. **Recomendação:** C — mantém o popover curto e ainda ensina o vocabulário do C12 a quem está aprendendo (mesmo problema do débito O3/glossário); B ocuparia mais altura que o resto do formulário inteiro.
|
||||
- **`triangulated` entra no popover?** **Opções:** A) sim, checkbox; B) não, só no detalhe. **Recomendação:** A — é um checkbox, é campo staff-only com access próprio já resolvido, e "confirmado por mais de uma fonte" é justamente o que se sabe na hora de registrar.
|
||||
- **Confirmação: `Alert` dentro do popover antes de fechar, ou toast depois de fechar?** **Opções:** A) toast (padrão do `MunicipalityListTrendControl`); B) `Alert` de sucesso e o usuário fecha. **Recomendação:** A, para não deixar um popover aberto sobre uma lista que está revalidando — com a mensagem nomeando o município (ver primeira questão).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
cell["Célula Último sinal<br/>(SignalAgeReadout)"] --> trig["button (min-h-11)"]
|
||||
trig -->|"md+ (tabela)"| pop["Popover"]
|
||||
trig -->|"< md (card)"| drw["Drawer bottom sheet"]
|
||||
pop --> fields["MunicipalitySignalFields<br/>(texto, tipo, fonte, triangulado)"]
|
||||
drw --> fields
|
||||
fields -->|"submit"| fa["createMunicipalityListSignalFormAction"]
|
||||
fa --> act["createMunicipalityUpdate<br/>(zod + transação + overrideAccess: false)"]
|
||||
act --> hook["afterChange → municipality.lastUpdateAt"]
|
||||
fa --> rev["revalidateMunicipalityListPaths({ slug })"]
|
||||
rev --> cell
|
||||
fa -.erro.-> err["Alert no popover/sheet<br/>+ rascunho preservado"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`MunicipalitySignalFields`** (novo, `src/components/campaign/municipality/MunicipalitySignalFields.tsx`): só os campos do sinal (`body` textarea 2 linhas, `signalType` `NativeSelect` + descrição do tipo escolhido, `signalSource` `Input`, `triangulated` checkbox) mais os `fieldErrors` do estado da action. Reusa `municipalitySignalTypes` / `municipalitySignalTypeLabels` / `municipalitySignalTypeDescriptions` de `src/lib/schemas/municipalityUpdate.ts` e os shells `Field`/`FieldLabel`/`FieldError`. Consumido pelas duas variantes (Popover e Drawer) — e é a peça que o `MunicipalityUpdateForm` do detalhe pode passar a reusar depois (ver Adiado com gatilho).
|
||||
- **`MunicipalityListSignalControl`** (novo, `src/components/campaign/municipality/MunicipalityListSignalControl.tsx`): client component com prop `variant: 'popover' | 'sheet'`, `useActionState` sobre a form action, `Spinner` + `disabled` no submit, `Alert` de erro, live region `sr-only`, `setOpen(false)` + toast só no sucesso. O gatilho recebe o `SignalAgeReadout` como children — a leitura E9 não é reimplementada.
|
||||
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): a célula `lastSignal` (coluna staff) passa a envolver `SignalAgeReadout` no controle com `variant="popover"`; o card mobile usa `variant="sheet"`. Nova prop `signalFormAction` no mesmo formato das existentes (`MunicipalityStaffFormAction`).
|
||||
- **`createMunicipalityListSignalFormAction`** (`src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`): casca sobre `runCampaignFormAction` — lê `municipalityId`, `body`, `signalType`, `signalSource`, `triangulated` e o `municipalitySlug` do `FormData` (helpers `requiredRelationshipFormValue` / `nullableFormText` / `optionalMunicipalitySlugFromForm` já usados no arquivo), chama `createMunicipalityUpdate({ kind: 'sinal', … })` e `revalidateMunicipalityListPaths({ slug })`. **Depth check:** ao contrário do `createMunicipalityUpdateFormAction` do detalhe (try/catch à mão), esta nasce dentro de `runCampaignFormAction` — o padrão fechado no Pass 2 W4d; a do detalhe fica como está (troca é fill-in de higiene, não deste item).
|
||||
- **`municipios/page.tsx`**: passa a nova form action para `MunicipalityList`, ao lado de `trendFormAction`/`advisorsFormAction`.
|
||||
- **Migration:** nenhuma. `municipalityUpdate` e todos os campos do sinal existem desde o C12; `lastUpdateAt` já é derivado por hook.
|
||||
- **Testes:** int cobrindo o access da nova form action (assessor fora da carteira recebe erro e nada é criado; assessor da carteira cria e `lastUpdateAt` muda) em `tests/int/campaignMunicipalityUpdate.int.spec.ts`; e2e em `tests/e2e/campaignMunicipalities.e2e.spec.ts` — abrir a célula na tabela, preencher, submeter, ver a célula virar "hoje".
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura em aberto.** Precisa de **E9 ✓** (a coluna e `lastSignalAt`) e **C12 ✓** (sinal tipado + imutabilidade), ambos entregues.
|
||||
- Reusa: `createMunicipalityUpdate` (`src/app/(campaign)/campanha/actions/municipalityUpdate.ts`), `municipalityUpdateCreateSchema` (`src/lib/schemas/municipalityUpdate.ts`), `canCreateMunicipalityUpdate` (`src/utilities/access/municipalityUpdates.ts`), `revalidateMunicipalityListPaths` (`src/utilities/municipalityRevalidation.ts`), `runCampaignFormAction`, `Drawer`/`Popover` de `src/components/ui`.
|
||||
- **Suave:** **B24** (o popover vizinho na mesma linha; a diferença de contrato precisa ser deliberada, não acidental — quem chegar depois cita o outro na copy do botão), **B23** (se o `cellTooltip` for estendido à coluna "Último sinal", vale o mesmo cuidado registrado lá: `openOnTouch={false}` + `disabled={open}` para o tooltip não roubar o tap nem sobreviver atrás do Popover), **E9 ✓** (a fila que este item torna acionável).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Registrar `semanal` / `nota` / `urgente` pela lista** → continua no detalhe (`?tab=updates`, deep link `?newUpdate=1`).
|
||||
- **Ler o texto do último sinal na célula** → hover de conteúdo é **B23** ([tooltip-celulas-listas.md](tooltip-celulas-listas.md)); o feed completo é o detalhe / o dossiê **E16 ✓**.
|
||||
- **Tirar o "Salvar" do popover de Assessores** → fill-in próprio já registrado no B24.
|
||||
- **Editar/apagar um sinal registrado** → fora por decisão do C12 (registro imutável; correção é novo sinal).
|
||||
- **Migrar `createMunicipalityUpdateFormAction` (detalhe) para `runCampaignFormAction`** → higiene, fill-in.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que abriu um formulário na célula, deixa registrar qualquer atualização."** Entram o seletor de `kind`, três textareas de 3000 caracteres e dois campos numéricos; o popover vira o formulário do detalhe e a lista vira tela de edição. **Mitigação neste item:** só `kind: 'sinal'`, campos fixos, nada de seletor de tipo de registro.
|
||||
- **"São dois controles com Popover/Drawer, extrai um `ResponsiveQuickEdit` genérico."** Com B9/B19/B24 na mesma linha, a tentação é um `EditableCell` que acomode select, número por cenário, textarea, criação e edição — design system paralelo (anti-goal do princípio 3). **Mitigação:** controle nomeado; shell responsivo compartilhado só no **3º** call site (ver Adiado com gatilho).
|
||||
- **"Mostra o último sinal dentro do popover para dar contexto."** Exige carregar o último `municipalityUpdate` de cada linha da página (query por linha ou novo agregado), justo na lista que o Pass 2 otimizou para uma passada. **Mitigação:** adiado com gatilho abaixo.
|
||||
- **"Aproveita e faz a linha não se mexer depois de gravar."** Leva a congelar ordenação no cliente, a keys estáveis artificiais e a uma lista que mente sobre o próprio critério de ordem. **Mitigação:** revalidar e reancorar por copy (questão em aberto, opção C).
|
||||
- **Bottom sheet e teclado virtual.** Sheet com altura fixa + teclado aberto esconde o submit; a correção "rápida" costuma virar cálculo de `visualViewport` à mão. **Mitigação:** usar o `Drawer` como ele já está configurado (`max-height` por `dvh`, conteúdo rolável) e testar no iOS antes de inventar layout.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Shell responsivo compartilhado (Popover ↔ Drawer) para controles de célula.** Revisitar quando: existir o **3º** controle de lista que precise das duas variantes.
|
||||
- **Reuso de `MunicipalitySignalFields` dentro do `MunicipalityUpdateForm` do detalhe.** Revisitar quando: este item estiver em produção e os dois blocos de campos tiverem divergido em algum detalhe visível (hoje seriam dois consumidores no mesmo dia — extrair é barato, unificar copy/erros no mesmo passe é que estoura o appetite).
|
||||
- **Contexto do último sinal dentro do popover.** Revisitar quando: alguém pedir em uso real "registrei duplicado porque não vi o que já tinha".
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B, B26; grafo; Janela 1–2; cortes seguros)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `lastSignal`, `SignalAgeReadout`, card mobile, `signalHint`
|
||||
- `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` — precedente de Popover na célula com `useActionState`
|
||||
- `src/components/campaign/municipality/MunicipalityUpdateForm.tsx` — campos do sinal tipado a fatiar
|
||||
- `src/app/(campaign)/campanha/actions/municipalityUpdate.ts` — `createMunicipalityUpdate` (transação + `overrideAccess: false`)
|
||||
- `src/collections/MunicipalityUpdate.ts` — `validateMunicipalityUpdateKind`, imutabilidade, recomputo de `lastUpdateAt`
|
||||
- `src/utilities/access/municipalityUpdates.ts` — `canCreateMunicipalityUpdate`
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts` — onde a nova form action entra
|
||||
- `src/components/ui/Drawer.tsx` / `src/components/ui/Popover.tsx` — primitivos
|
||||
- `docs/plans/fila-de-alocacao.md` (E9) e `docs/plans/registro-fundacao.md` (C12) — origem da coluna e do sinal tipado
|
||||
- `docs/plans/autosave-tendencia-lista-municipios.md` (B24) — contrato vizinho, deliberadamente diferente
|
||||
- AGENTS.md — Campaign auth, naming pt-BR/inglês, `overrideAccess: false`, transações
|
||||
- `PRODUCT.md` / `DESIGN.md` — princípios 2, 3, 4 e 8; Field Desk · regras `campanha-edit-where-you-see.mdc`, `campanha-action-feedback.mdc`
|
||||
118
docs/plans/remover-coluna-tipo-municipios.md
Normal file
118
docs/plans/remover-coluna-tipo-municipios.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Remover coluna Tipo da lista de Municípios
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins)
|
||||
Impeccable: B — encaixe em `MunicipalityList` / filtros da lista (`/campanha/municipios`); sem rota nova
|
||||
Appetite: ~0,25–0,5 dia eng; remove coluna + filtro/sort `kind` da URL da lista; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; anti spreadsheet) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `MunicipalityList` + `CampaignTable` + header B15/B16.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só remoção de densidade morta; sem redesign da tabela).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG / Assessor / Candidato varre a fila de alocação (E9); a coluna "Tipo" (Município × Zona eleitoral) ocupa largura sem mudar a decisão — as 19 ZE de Salvador já se leem pelo nome e pelo filtro de município/TI.
|
||||
- **Job principal:** uma coluna a menos na varredura; quem precisa das zonas de Salvador filtra pelo município (ou busca "Salvador"), não por um enum paralelo.
|
||||
- **Estratégia de cor:** Restrained — nenhuma superfície nova.
|
||||
- **Edit where you see:** não — só remove leitura/filtro; `kind` no schema continua read-only da geografia.
|
||||
- **Anti-goals:** redesign do header; seletor de colunas neste item (B17); apagar `municipality.kind` do modelo/catálogo; segunda forma de filtrar Salvador (chip "só zonas").
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — este item **não** introduz nem altera métricas; só remove uma dimensão categórica redundante da lista. O campo `kind` no domínio (catálogo / detalhe / mapa / dossiê) permanece.
|
||||
|
||||
## Contexto
|
||||
|
||||
Em `/campanha/municipios`, a tabela staff (`MunicipalityList` → `CampaignTable`) inclui a coluna `id: 'kind'` com header "Tipo", sort (`sortKey="kind"`) e filtro single-select no header (`filterParam="kind"`, B16). Célula: `municipalityKindLabels[municipality.kind]` → "Município" | "Zona eleitoral" (`src/utilities/municipalityLabels.ts`). O mesmo `kind` vive na URL (`MunicipalityListState.kind`, `?kind=municipio|zona`) e em `buildMunicipalityListWhere`.
|
||||
|
||||
Pedido de produto (2026-07-25): **remover a coluna**. Municípios e Zonas Eleitorais são tratados da mesma forma na coordenação; para isolar as ZE de Salvador basta filtrar por Salvador (multi-select de slugs / busca no popover de Município — as 19 entradas `salvador-ze-*`). O filtro `?kind=zona` não acrescenta um recorte que a operação use.
|
||||
|
||||
O campo Payload `municipality.kind` e o catálogo (`municipalityCatalog`) **não** saem: geografia seedada, card de bairros (B8), mapa multi-zona, dossiê e labels de detalhe ainda dependem dele.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Em `/campanha/municipios`: sumir a coluna "Tipo" (desktop `CampaignTable` e qualquer echo no card mobile se existir).
|
||||
- Remover o filtro UI "Tipo" (header Popover B16 + select mobile em `MunicipalityFilters`) e a sort key `kind` dos labels/selects de ordenação.
|
||||
- Encolher o contrato URL da lista: deixar de parsear/serializar `kind` (bookmarks `?kind=` viram no-op após canonicalize).
|
||||
- Atualizar testes unitários da lista (`municipalityList.unit.spec.ts`) e int que montam bundle/mapa com `{ kind: … }` **só quando** o teste estiver exercitando o filtro da lista — mapa/loader podem continuar aceitando `kind` no state até o state perder o campo.
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem server action; campo `kind` no schema/admin/catálogo intacto; detalhe/dossiê/mapa/B8 intactos.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Fill-in com plano próprio (sem ID B novo).** Polish de densidade da lista, ~¼–½ dia, paralelizável, cortável. (2026-07-25, classificação roadmap-item.) **Rejeitado:** B24 de trilha (infla grafo para remoção de coluna); absorver só em B17 (hide/show não substitui “não deveria existir”); só R6 (atrasa quick win).
|
||||
- **Remover coluna + filtro + sort `kind` da lista — não só a célula.** Sem coluna, filtro órfão no header é ruído; sort sem coluna é inconsistente com B15. Pedido de produto: ZE ≡ município operacionalmente; Salvador cobre o recorte. **Rejeitado:** manter `?kind=` na UI “por se”; deixar sort `kind` no select mobile sem coluna.
|
||||
- **Encolher URL: drop de `kind` no parse/serialize/`MunicipalityListState`.** Contrato congelado do Pass 2 W1 / B18: remoção deliberada de um param morto, não adição. Bookmarks e filtros salvos futuros que carreguem `kind` perdem o recorte (aceitável — path canônico = slugs de Salvador). **Rejeitado:** parse silencioso sem UI (param zumbi); manter param “por compat” sem consumidor.
|
||||
- **Schema / catálogo `kind` permanece.** Geografia read-only; consumidores fora da lista (mapa, B8 bairros, dossiê, labels de detalhe). **Rejeitado:** migration drop `kind` (caro, quebra remodelagem); fundir as 19 ZE num único município Salvador (fora de escopo — decisão M1).
|
||||
- **i18n e naming** (AGENTS.md): identificadores (`kind`, `municipalityKindLabels`) podem permanecer onde o domínio ainda usa; strings “Tipo” / “Zona eleitoral” saem só das superfícies de lista.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Testes int de mapa que passam `{ kind: 'zona' }` no state da lista — refatorar para filtro por slugs de Salvador, ou manter um path interno de where por `kind` só no loader?** **Opções:** A) testes passam a filtrar por `slugs` das 19 ZE | B) helper interno `whereKind` só para testes | C) manter `kind` no state sem UI. **Recomendação:** **A** — alinha teste ao path de produto; C contradiz a decisão URL. _(assumido)_
|
||||
- **Badge “Tipo” no dossiê / detalhe (`municipalityKindLabels` na capa) — cortar também?** **Opções:** A) manter no detalhe (contexto geográfico pontual) | B) remover em toda UI. **Recomendação:** **A** — o pedido é a lista; no detalhe de uma ZE o tipo ainda orienta (B8 bairros). _(assumido — validar com produto se o critique da lista pedir paridade)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
list["MunicipalityList"] --> cols["CampaignTable columns"]
|
||||
cols -.->|remove| kindCol["id: kind"]
|
||||
url["municipalityListUrl"] -->|drop kind| state["MunicipalityListState"]
|
||||
filters["municipalityListFilters"] -->|drop definition| ui["Header + mobile"]
|
||||
domain["municipality.kind schema"] --> detail["Detalhe / mapa / B8"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`MunicipalityList`** (`src/components/campaign/municipality/MunicipalityList.tsx`): remover o bloco de coluna `id: 'kind'`; dropar import de `municipalityKindLabels` se ficar órfão neste arquivo.
|
||||
- **`municipalityListUrl.ts`**: remover `'kind'` de `MunicipalityListSortKey` / labels / `MunicipalityListState` / `municipalityListParamNames` / parse / serialize / `buildMunicipalityListWhere`.
|
||||
- **`municipalityListFilters.ts`**: remover definition `param: 'kind'`, `applyMunicipalityKindFilter`, ramos em summary/`hasActive`; ajustar tipos `MunicipalityFilterParam` e `getMunicipalitySingleFilterValue` (fica só `coverage` exclusivos se ainda precisar).
|
||||
- **`MunicipalityHeaderFilter` / `MunicipalityFilters`**: remover ramos single-select de Tipo (comentários “Tipo is the only single-select” → coverage/toggle ou o que sobrar).
|
||||
- **`municipalityPageData.ts`**: map de sort Payload `kind: 'kind'` — dropar a entrada.
|
||||
- **Testes**: `tests/unit/municipalityList.unit.spec.ts` (hrefs/`applyMunicipalityKindFilter`); int `municipalityPageData` / `municipalityMapData` que usam `{ kind }` no state da lista → slugs Salvador ou where direto.
|
||||
- **B17 / B22 / planos vizinhos:** não editar planos entregues em massa; na implementação, se B17 listar `kind` como coluna toggleável, o id some naturalmente ao sumir da definição de colunas.
|
||||
- **Migration**: Sem migration, sem collection, sem server action.
|
||||
|
||||
Depth check: só delete/estreitamento nos módulos profundos já donos do contrato da lista — sem wrapper novo.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura de outro plano. Soft: B15 ✓ / B16 ✓ (header); B17 (uma coluna a menos no seletor); B18 (param a menos no subset URL).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Remover ou alterar `municipality.kind` no Payload / seed / catálogo — modelo geográfico da remodelagem.
|
||||
- Unificar as 19 ZE de Salvador numa linha — decisão M1 / B8.
|
||||
- Seletor mostrar/ocultar colunas — **B17**.
|
||||
- Explicação no header / tooltip de célula — **B22** / **B23**.
|
||||
- Filtro “cidade Salvador” como atalho dedicado (chip) — desnecessário se o popover de Município + busca “Salvador” bastar; ver Adiado.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Drop schema `kind`.** Explode migration + mapa + B8 + dossiê. **Mitigação:** UI da lista só; schema intocado.
|
||||
- **Atalho “Salvador (19 zonas)” como filtro de primeira classe.** Segunda taxonomia paralela a `kind`. **Mitigação:** reusar multi-slug; defer se campo pedir atalho após uso.
|
||||
- **Reescrever B17/B18/planos históricos.** Diff cosmético sem valor. **Mitigação:** nota aqui; implementação atualiza só código vivo.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Chip / preset “Salvador (zonas)”** no popover de Município. Revisitar quando: ≥1 ator de campo pedir o atalho depois de perder `?kind=zona`, ou evidência de que marcar 19 slugs à mão atrita.
|
||||
- **Remover `municipalityKindLabels` da capa do dossiê / detalhe.** Revisitar quando: produto pedir a mesma regra “tipo não importa” fora da lista.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `kind` (~L262–270)
|
||||
- `src/utilities/municipalityListUrl.ts` — contrato URL / sort / where
|
||||
- `src/utilities/municipalityListFilters.ts` — definition + `applyMunicipalityKindFilter`
|
||||
- `src/components/campaign/municipality/MunicipalityHeaderFilter.tsx` — single-select Tipo
|
||||
- `src/components/campaign/municipality/MunicipalityFilters.tsx` — mobile
|
||||
- `src/utilities/municipalityLabels.ts` — `municipalityKindLabels` (permanece para detalhe)
|
||||
- `tests/unit/municipalityList.unit.spec.ts` — pins de `kind` na URL
|
||||
- [icone-prioridade-lista-municipios.md](icone-prioridade-lista-municipios.md) — precedente fill-in Impeccable B na mesma lista
|
||||
- [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) — B16 (Tipo como coluna filtrável)
|
||||
- [seletor-colunas-lista-municipios.md](seletor-colunas-lista-municipios.md) — B17 (Tipo como candidata a hide; este item a remove)
|
||||
- AGENTS.md — Município remodel; naming
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, clareza sob pressão
|
||||
116
docs/plans/renomear-coluna-cobertura-meta.md
Normal file
116
docs/plans/renomear-coluna-cobertura-meta.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Renomear coluna "Cobertura da meta" → "Cobertura"
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins)
|
||||
Impeccable: B — encaixe no header/card de `/campanha/municipios`; sem rota nova
|
||||
Appetite: ~0,25 dia eng (ou menos); só strings + pins de teste; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (clareza sob pressão; anti spreadsheet) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedente A11 (header curto `2022`, definição no hover).
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (copy only; sem redesign).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** CG / Assessor varre a fila (E9); o header "Cobertura da meta" compete em largura com células curtas (`80%` / déficit) e com sort+funil no mesmo `<th>`.
|
||||
- **Job principal:** ler a coluna de cobertura de meta com um rótulo curto; o que a métrica significa fica no tooltip do header (**B22**) / glossário (**E18 ✓**).
|
||||
- **Estratégia de cor:** Restrained — nenhuma superfície nova.
|
||||
- **Edit where you see:** não — só rename de leitura.
|
||||
- **Anti-goals:** renomear o conceito do glossário; fundir com o param URL `coverage` (assessores); inventar segundo rótulo "Cobertura %" / "Meta %".
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — este item **não** altera fórmula, denominador nem apresentação numérica. Só o rótulo da coluna (e o label de sort `deficit` que a acompanha). A métrica continua `comprometido ÷ meta` (E8).
|
||||
|
||||
## Contexto
|
||||
|
||||
No E8 a coluna antiga "Cobertura" (= tem assessor) foi renomeada para "Assessoria" para abrir espaço ao rótulo longo "Cobertura da meta". No B16 a coluna "Assessoria" **saiu** (ordenação/filtro de assessores moram em "Assessores"); o comentário em `municipalityListUrl.ts` já antecipa: _"`Cobertura` alone now reads as the goal one (`deficit`)"_ — mas o header ainda diz "Cobertura da meta".
|
||||
|
||||
Pedido de produto (2026-07-25): **coluna = "Cobertura"**. Mesmo padrão do A11 (`2022` em vez de "Concentração 2022"): título curto na tabela; definição no hover (**B22** consome `oneLiner` de `cobertura-da-meta` no E18).
|
||||
|
||||
Onde a string aparece hoje na lista:
|
||||
|
||||
- Header desktop: `MunicipalityList.tsx` coluna `id: 'goalCoverage'` → `MunicipalitySortableHead` children.
|
||||
- Card mobile: `<dt>Cobertura da meta</dt>` no mesmo arquivo.
|
||||
- Sort select / summary: `municipalityListSortLabels.deficit = 'Cobertura da meta'`.
|
||||
|
||||
Fora da lista (não escopo deste fill-in): strip do dashboard/overview, card "Conta da cadeira", dossiê, título do conceito em `/campanha/conceitos` — o nome canônico da métrica continua "Cobertura da meta".
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Em `/campanha/municipios` (staff): header da coluna `goalCoverage` e `<dt>` do card mobile mostram **"Cobertura"**.
|
||||
- `municipalityListSortLabels.deficit` (e qualquer summary que o cite) alinhado a **"Cobertura"**.
|
||||
- Pins de teste da lista atualizados (`campaignComponents.unit.spec.ts` espera a string nova; o teste de `coverage` → "Assessores" pode reforçar que `deficit` → "Cobertura").
|
||||
- Guardrails: sem migration, sem collection, sem Consent, sem server action, sem mudança de sort key / URL (`deficit` permanece); glossário E18 e KPIs do strip intactos.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Fill-in com plano próprio (sem ID B novo).** Rename cosmético de coluna, ~¼ dia, paralelizável, cortável. (2026-07-25, classificação roadmap-item.) **Rejeitado:** B26 de trilha (infla grafo); absorver só em B22 (atrasa quick win atrás de meio dia de tooltip); só R6.
|
||||
- **Só a superfície da lista (header + card + label de sort `deficit`).** Pedido = "coluna". KPI strips / card de detalhe / glossário mantêm o nome longo da métrica. **Rejeitado:** varredura global de "Cobertura da meta" (desfaz o título do conceito E18 e o heading e2e); renomear o conceito para "Cobertura" (ambíguo fora do contexto da tabela).
|
||||
- **Ambiguidade com assessores já resolvida pelo B16.** Sem coluna "Assessoria", "Cobertura" na lista = cobertura da meta. Param URL `coverage` e sort key `coverage` continuam = com/sem assessor (identificadores internos; labels já são "Assessores"). **Rejeitado:** renomear sort key `deficit` → `coverage` (quebra bookmarks/`?sort=deficit`); segundo rótulo "Cobertura (meta)".
|
||||
- **i18n e naming** (AGENTS.md): identificadores (`goalCoverage`, `deficit`, `cobertura-da-meta`) intactos; só strings visíveis da lista.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Strips do dashboard / overview ("Cobertura da meta") — encurtar também?** **Opções:** A) manter longo (mais espaço, KPI nomeado) | B) encurtar para "Cobertura" por paridade. **Recomendação:** **A** — o pedido é a coluna; strips não competem com sort+funil no `<th>`. _(assumido — validar no critique se a mesa achar inconsistência)_
|
||||
- **Ordenação mobile "Frescor do sinal" vs header "Último sinal" — tocar neste item?** **Opções:** A) não | B) alinhar num fill-in separado. **Recomendação:** **A** — fora do pedido; se incomodar, fill-in próprio.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
list["MunicipalityList"] -->|header + dt| label["Cobertura"]
|
||||
url["municipalityListSortLabels.deficit"] --> label
|
||||
tip["B22 tooltip / E18 oneLiner"] -.->|definição| header["th deficit"]
|
||||
gloss["/campanha/conceitos"] -->|title intacto| long["Cobertura da meta"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`MunicipalityList.tsx`**: children do `MunicipalitySortableHead` `sortKey="deficit"` → `"Cobertura"`; `<dt>` do card mobile idem.
|
||||
- **`municipalityListUrl.ts`**: `municipalityListSortLabels.deficit = 'Cobertura'`; comentário do sort `coverage` já documenta a intenção — confirmar que continua verdadeiro.
|
||||
- **Testes:** `tests/unit/campaignComponents.unit.spec.ts` (`toContain('Cobertura')` / deixar de exigir o sufixo "da meta" na lista); opcional pin em `municipalityList.unit.spec.ts` (`deficit` → `"Cobertura"`).
|
||||
- **B22:** ao implementar, o mapa de `description` da coluna `goalCoverage` já aponta para `campaignConceptOneLiner('cobertura-da-meta')` — o header curto + tooltip longo é o desenho desejado; **não** editar o plano B22 em massa neste fill-in.
|
||||
- **Migration:** Sem migration, sem collection, sem server action.
|
||||
|
||||
Depth check: só strings nos módulos que já donam a coluna — sem helper novo.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura. Soft: **B16 ✓** (liberou o nome "Cobertura"); **A11 ✓** (precedente de header curto); **B22** (tooltip carrega a definição — soft, não bloqueia este rename).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Título do conceito / heading em `/campanha/conceitos` — **E18 ✓** (e2e `campaignConcepts.e2e.spec.ts` permanece).
|
||||
- Labels do `CampaignMetricStrip` (dashboard + overview) e do `MunicipalityGoalAccountCard` / dossiê.
|
||||
- Seletor de colunas / explicação no header / tooltip de célula — **B17** / **B22** / **B23**.
|
||||
- Semântica ou sort key `deficit` / fórmula E8.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Varredura "renomear tudo que diga Cobertura da meta".** Quebra glossário + e2e + card. **Mitigação:** boundary = lista + sort label.
|
||||
- **Renomear `?sort=deficit` ou o id `goalCoverage`.** Quebra URL canônica / B18. **Mitigação:** só string visível.
|
||||
- **Reabrir a coluna "Assessoria".** Fora do pedido. **Mitigação:** B16 já decidiu.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Encurtar "Cobertura da meta" nos strips / card de detalhe.** Revisitar quando: critique/R6 ou CG pedir paridade explícita com a lista.
|
||||
- Nenhum outro neste item.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos)
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `goalCoverage` + `<dt>` mobile
|
||||
- `src/utilities/municipalityListUrl.ts` — `municipalityListSortLabels.deficit` (+ comentário em `coverage`)
|
||||
- `tests/unit/campaignComponents.unit.spec.ts` — pin da string na lista
|
||||
- `tests/unit/municipalityList.unit.spec.ts` — labels de sort
|
||||
- [conta-da-cadeira.md](conta-da-cadeira.md) — E8 (origem do rótulo longo)
|
||||
- [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) — B16 (saiu "Assessoria")
|
||||
- [ranking-votos-municipio.md](ranking-votos-municipio.md) — A11 (precedente header curto)
|
||||
- [explicacao-colunas-header-listas.md](explicacao-colunas-header-listas.md) — B22 (definição no hover)
|
||||
- [remover-coluna-tipo-municipios.md](remover-coluna-tipo-municipios.md) — precedente fill-in Impeccable B na mesma lista
|
||||
- AGENTS.md — naming; E8
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk
|
||||
128
docs/plans/renomear-plano-acao-para-atividade.md
Normal file
128
docs/plans/renomear-plano-acao-para-atividade.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Renomear "Plano de Ação" para "Atividade" (entidade, código e rotas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C13)
|
||||
Impeccable: B — encaixe em telas existentes (`/campanha/planos` → `/campanha/atividades`, sidebar, cards do Início/dossiê/organização/demanda); nenhuma tela nova
|
||||
Appetite: ~1–1,5 dia eng; 1 migration de rename (sem perda de dados) + varredura de 61 arquivos + guard no linter de convenções
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 "Clarity under pressure"; §Users e §Product Purpose já chamam a superfície de **agenda**, nunca de "planos de ação") / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Não há layout novo — o craft é conferir densidade dos rótulos ("Atividades" é 1 caractere mais curto que "Planos de ação" no `h1`, e 3 mais longo que "Planos" no rail do sidebar) e a coerência do vocabulário entre título, breadcrumb, botão e mensagens de erro.
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Coordenador Geral e assessores no onboarding (Janela 1), aprendendo a ferramenta pela primeira vez; e o próprio candidato lendo o dossiê antes de uma visita.
|
||||
- **Job principal:** reconhecer a entidade pelo nome que a mesa já usa, sem tradução mental nem explicação de terceiros.
|
||||
- **Estratégia de cor:** Restrained — nenhuma mudança de cor, ícone ou hierarquia; só vocabulário.
|
||||
- **Edit where you see:** N/A — este item não muda affordance de edição (a agenda continua com `/editar` para o formulário multi-campo, como hoje).
|
||||
- **Anti-goals:** redesenhar a vertical no mesmo PR (o critique/polish visual dela é **R6**); introduzir um segundo sinônimo ("Agenda" em um lugar, "Atividade" em outro); manter nomes de banco divergentes do código "porque é só cosmético".
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
**Dados: N/A** — o item renomeia entidade, identificadores, rotas e copy. Nenhuma métrica, série, ranking ou mapa é criado, alterado ou removido; os contadores existentes (tarefas concluídas, próximos eventos) continuam idênticos, só mudam de rótulo.
|
||||
|
||||
## Contexto
|
||||
|
||||
A vertical de agenda nasceu no **C3** ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) com o nome "Plano de Ação". Em 2026-07-25 o produto registrou que a palavra que o time usa em campo é **"atividade"** (ou "agenda") e que "plano de ação" **sempre precisa ser explicado** — custo recorrente de tradução justamente na janela de onboarding (Onda 0 §4). O próprio `PRODUCT.md` já descreve a superfície como "agenda" nas seções Users e Product Purpose: a divergência é entre o produto escrito e o produto implementado.
|
||||
|
||||
Estado atual (varredura de 2026-07-25):
|
||||
|
||||
- **Collection** `actionPlan` (`src/collections/ActionPlan.ts`, 631 linhas), labels "Plano de ação"/"Planos de ação", grupo admin `Campanha`.
|
||||
- **Banco:** tabelas `action_plan`, `action_plan_tasks`, `action_plan_updates`, `action_plan_rels`, `action_plan_texts`; enums `enum_action_plan_kind|status|origin`; colunas FK `campaign_demand.action_plan_id` e `payload_locked_documents_rels.action_plan_id`; ~30 índices/constraints com o prefixo.
|
||||
- **Código:** 61 arquivos (+ `src/payload-types.ts`, regenerado) e ~820 ocorrências de `actionPlan|ActionPlan|ACTION_PLAN` — 13 componentes em `src/components/campaign/actionPlan/`, 7 utilities (`actionPlanUi`, `actionPlanPageData`, `actionPlanDetailPageData`, `actionPlanViewModels`, `actionPlanFormData`, `actionPlanDetailTabUi`, `actionPlanLeadershipOptions`), `src/utilities/access/actionPlans.ts`, `src/lib/schemas/actionPlan.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, 11 arquivos de rota em `.../(app)/planos/`, e o campo `campaignDemand.actionPlan`.
|
||||
- **Copy pt-BR** fora da vertical: `Organization.ts` (`admin.description`), `/campanha/organizacoes` (lista e detalhe), `CampaignDemand.ts` (label + mensagem de erro), `DemandForm.tsx`, sidebar (`nav.ts` → "Planos").
|
||||
- **Guard existente:** o describe "banned campaign terminology" em `tests/unit/codebaseConventions.unit.spec.ts` já mantém Praça/Núcleo fora de `src/`, com allowlist para dados legítimos (`src/lib/cities.ts`). É esse guard que o pedido manda estender.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Uma única palavra para a entidade em todo o produto: **Atividade** (pt-BR) / `activity` (código, banco, rota).
|
||||
- Zero divergência código↔banco↔URL: `activity` na collection, `activity*` nas tabelas/enums/colunas, `/campanha/atividades` na rota.
|
||||
- **Nenhum dado perdido:** a migration renomeia (não recria) — qualquer atividade já registrada pela mesa sobrevive com o mesmo `id` e `slug`.
|
||||
- Access control, RBAC e regras de negócio **inalterados** (só nomes mudam); sem novo `Consent`; sem mudança de papel ou de visibilidade.
|
||||
- Guard programático que falha o CI na primeira reintrodução do termo antigo, na mesma peça que já guarda Praça/Núcleo.
|
||||
- Gate completo verde (`tsc`, `lint`, `format:check`, `knip`, `check:cycles`, `test`, `build`) e `pnpm generate:types` / `generate:importmap` refeitos.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Identificador de código = `activity` (collection slug `activity`).** É a tradução direta do termo aprovado, uma palavra só, sem colisão no repositório (a única ocorrência atual de "activity/atividade" em `src/` é prosa dentro da própria vertical). Fonte: pedido de produto 2026-07-25. **Rejeitado:** `agenda` porque nomeia a _superfície_ (a lista/calendário), não o _registro_ — e o roadmap/PRODUCT.md já usam "agenda" nesse sentido, o que criaria ambiguidade nova; `event` porque a collection também abriga ações sem hora marcada (kind `demanda`/`reunião`) e conflitaria com o vocabulário de eventos de UI.
|
||||
- **Migration data-preserving por `ALTER … RENAME` (tabelas, colunas, enums, índices e constraints), escrita à mão.** O diff do Payload gera `DROP TABLE` + `CREATE TABLE` para troca de slug: apagaria a agenda já cadastrada. A janela de "sem dados reais" que justificou o drop na remodelagem ([remodelagem-municipios.md](remodelagem-municipios.md), decisão de 2026-07-23) **fechou**: a atividade não tem trava jurídica (não é PII de terceiros como liderança/apoiador), então a mesa pode registrá-la assim que o smoke passar — e o deploy deste item é posterior. **Rejeitado:** aceitar o SQL destrutivo gerado (perda irreversível em produção, sem backup de campanha); `dbName: 'action_plan'` mantendo o nome antigo no banco (é exatamente a "dúvida para futuros desenvolvedores" que este item existe para eliminar, e obrigaria a allowlistar a própria config no linter).
|
||||
- **Snapshot gerado, SQL substituído.** Rodar `pnpm migrate:create rename_action_plan_to_activity`, **manter** o `.json` (é o estado-alvo que o próximo diff vai comparar) e **substituir** o corpo do `.ts` pelos renames idempotentes (`IF EXISTS` / `DO $$`), com `RAISE NOTICE` do que foi renomeado — mesmo padrão de `20260715_215834_rename_tag_visible_to_hidden.ts` e das reconciliações da remodelagem. **Rejeitado:** migration só de dados sem tocar índices/constraints (os nomes ficariam órfãos no snapshot do drizzle e o próximo `migrate:create` tentaria recriá-los).
|
||||
- **Rota renomeada para `/campanha/atividades`, sem redirect legado.** `/campanha` é interno, autenticado e sem SEO; os links vêm da navegação do próprio app, e o histórico do navegador de ~10 pessoas não justifica manter uma rota fantasma que o linter teria de allowlistar. **Rejeitado:** manter `/campanha/planos` (divergência URL↔entidade, o problema em miniatura); redirect permanente em `next.config` (peça viva para sempre por um mês de bookmark).
|
||||
- **Guard = extensão table-driven do describe existente**, com os roots ampliados para `src/`, `tests/` e `scripts/`, `src/migrations/` fora (história congelada) e o próprio spec na allowlist (ele cita os termos banidos por definição). **Rejeitado:** regra ESLint nova (o precedente do repositório para vocabulário é o spec de convenções, e ESLint não varre `.mjs` de script com a mesma facilidade); módulo separado só para hospedar os literais (indireção por um único arquivo).
|
||||
- **Escopo do banimento: compostos + copy + rota** — `actionPlan|ActionPlan|ACTION_PLAN|action_plan|action-plan`, `plano de ação|planos de ação` (case/acento tolerantes) e `/campanha/planos`. **Rejeitado:** banir `plan`/`plano` isolado — falso-positivo garantido na Fase 2 white-label (planos de assinatura por mandato) e em nomes próprios; os `plan` locais desta vertical caem na varredura manual, dentro dos mesmos arquivos que já estão sendo renomeados.
|
||||
- **História congelada não é reescrita:** `src/migrations/*` (incl. `20260718_222832_add_action_plan`), `docs/plans/*` já entregues e os artefatos `docs/design-refs/latest/Planos-de-Acao.*` / `Novo-Plano-de-Acao.*` mantêm os nomes originais. **Rejeitado:** renomear design-refs (quebra os links dos planos C3/E13 sem ganhar nada — são fotografias datadas).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`Activity`, `activity`, `activityKinds`, `ActivityCard`, `activityUi`, `loadActivityPageData`, `canReadActivity`, `campaignDemand.activity`), strings visíveis em pt-BR ("Atividade", "Atividades", "Nova atividade"); o segmento de rota `atividades` é valor de URL em pt-BR, como `municipios`/`liderancas`/`dobradinhas`.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Rótulo do item no sidebar: "Atividades" ou "Agenda"?** **Opções:** A) "Atividades" (nome da entidade, plural) | B) "Agenda" (nome do destino/calendário) | C) "Agenda" no rail e "Atividade" no registro. **Recomendação: A** — o item existe para acabar com dois vocabulários; usar duas palavras reintroduz, em escala menor, o custo de tradução que motivou o pedido. "Agenda" continua válido em prosa (docs, PRODUCT.md), não como rótulo de navegação. _(proposta — validar com produto; o pedido citou as duas palavras.)_
|
||||
- **Renomear também os valores de enum `kind` (`caminhada`, `comício`, …)?** **Opções:** A) manter | B) revisar junto. **Recomendação: A, manter** — são valores de dado em pt-BR já compreendidos, e mexer neles transforma um rename de vocabulário em migração de conteúdo. Revisitar só se o R6 trouxer evidência de confusão nos rótulos de tipo.
|
||||
- **Renomear a pasta de teste e2e e as fixtures no mesmo PR?** **Opções:** A) sim, no mesmo PR | B) depois. **Recomendação: A** — deixar `campaignActionPlan.e2e.spec.ts` para depois cria exatamente a ocorrência que o linter vai barrar no PR seguinte.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F1["F1 Schema<br/>Activity.ts + campaignDemand.activity"] --> F2["F2 Migration<br/>ALTER RENAME idempotente"]
|
||||
F2 --> F3["F3 Código<br/>utilities / components / actions / schemas"]
|
||||
F3 --> F4["F4 Rotas + copy<br/>planos → atividades, nav, labels"]
|
||||
F4 --> F5["F5 Guard<br/>codebaseConventions (table-driven)"]
|
||||
F5 --> F6["F6 Docs + gate<br/>AGENTS / notebook / roadmap"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/collections/Activity.ts`** (ex-`ActionPlan.ts`): `slug: 'activity'`, `labels: { singular: 'Atividade', plural: 'Atividades' }`, hooks e access idênticos com os nomes internos renomeados (`setCanonicalActivitySlug`, `validateActivitySchedule`, `deriveActivityFields`, `activityStaffFieldSnapshot`). Mensagens de erro passam a dizer "atividade" ("O título da atividade não pode ser alterado após a criação."). Registro em `src/payload.config.ts`.
|
||||
- **`src/collections/CampaignDemand.ts`**: campo `actionPlan` → `activity` (label "Atividade"), hook de coerência de município e a mensagem "A demanda e a atividade devem pertencer ao mesmo município." O `cacheKey` do `req.context` acompanha (`campaignDemand:activityMunicipality:<id>`).
|
||||
- **Migration `pnpm migrate:create rename_action_plan_to_activity`** — snapshot gerado mantido, corpo substituído por: `ALTER TABLE … RENAME TO` (5 tabelas), `ALTER TYPE … RENAME TO` (3 enums), `ALTER TABLE campaign_demand RENAME COLUMN action_plan_id TO activity_id`, idem em `payload_locked_documents_rels` (precedente: `20260723_202000_reconcile_municipality_remodel`), `ALTER INDEX … RENAME TO` e `ALTER TABLE … RENAME CONSTRAINT …` para os ~30 objetos derivados. Tudo dentro de guardas `IF EXISTS`/`DO $$` para ser idempotente, com contagem logada; `down()` reverte na ordem inversa. Ensaio obrigatório sobre cópia real (`pnpm db:pull` → `pnpm migrate`) antes do deploy, conforme o guard rail do skill `payload-migrations`.
|
||||
- **Utilities** (`src/utilities/`): `actionPlanUi.ts` → `activityUi.ts`, `actionPlanPageData.ts` → `activityPageData.ts`, `actionPlanDetailPageData.ts` → `activityDetailPageData.ts`, `actionPlanViewModels.ts` → `activityViewModels.ts`, `actionPlanFormData.ts` → `activityFormData.ts`, `actionPlanDetailTabUi.ts` → `activityDetailTabUi.ts`, `actionPlanLeadershipOptions.ts` → `activityLeadershipOptions.ts`, `access/actionPlans.ts` → `access/activities.ts` (re-export por `campaignAccess.ts` mantido). `campaignDemandData.ts`, `organizationData.ts`, `municipalityDossierData.ts`, `campaignRelationOptions.ts` e `campaignFormActionError.ts` acompanham os símbolos.
|
||||
- **`src/lib/schemas/actionPlan.ts` → `activity.ts`**: `activityKinds`, `activityStatuses`, `activityOrigins` e seus `*Labels` (valores de enum inalterados); `campaignDemandInput.ts` passa a falar `activity`.
|
||||
- **`src/components/campaign/actionPlan/` → `src/components/campaign/activity/`** (13 arquivos `ActionPlan*.tsx` → `Activity*.tsx`), mais as variáveis locais `plan`/`plans`/`PlanListItem` em `MunicipalityDossier.tsx` e nas rotas de organização/demanda.
|
||||
- **Rotas**: `src/app/(campaign)/campanha/(app)/planos/` → `.../atividades/` (11 arquivos), incluindo os literais de `revalidatePath('/campanha/atividades/[slug]')` e os `redirect()`; `src/app/(campaign)/campanha/actions/actionPlan.ts` → `actions/activity.ts`; `nav.ts` com `{ title: 'Atividades', href: '/campanha/atividades' }` (ícone `CalendarDaysIcon` mantido).
|
||||
- **`tests/unit/codebaseConventions.unit.spec.ts`**: o describe "banned campaign terminology" vira table-driven —
|
||||
`[{ id, pattern, remedy, allowlist }]` com a linha existente (Praça/Núcleo → Município, allowlist `src/lib/cities.ts`) e a nova (termo antigo da agenda → Atividade/`activity`, allowlist do próprio spec). O walker passa a percorrer `src/`, `tests/` e `scripts/`, pulando `src/migrations/`.
|
||||
- **Testes renomeados**: `tests/int/campaignActionPlan.int.spec.ts` → `campaignActivity.int.spec.ts`, `tests/unit/actionPlanUi.unit.spec.ts` → `activityUi.unit.spec.ts`, `tests/e2e/campaignActionPlan.e2e.spec.ts` → `campaignActivity.e2e.spec.ts`; `tests/helpers/campaignFixtures.ts` e `tests/e2e/fixtures/campaignE2EFixtures.ts` acompanham (inclusive a chave `action_plan_id` do cleanup de locked documents).
|
||||
- **Docs vivos**: `AGENTS.md` (seções "Campaign action plans (C3)" e a lista de collections), `.cursor/rules/codebase-map.mdc` (lista de domínios de `components/campaign`), `.cursor/rules/projects/nucleos-eleitorais.mdc` e o `docs/roadmap.md`. Planos entregues e migrations ficam como estão.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Nenhuma dura.** Reusa a collection, o access (`src/utilities/access/actionPlans.ts`), os shells de lista e o padrão de migration à mão já existentes.
|
||||
- **Soft — fazer antes de:** **E13** planejador de giros (constrói uma visão nova dentro desta vertical; renomear depois multiplica o trabalho) e **C11** escala de planos. Não conflita com B28–B34 (lista de lideranças/dobradinhas) nem com o programa E8–E16.
|
||||
- **Soft — janela:** antes do onboarding do time (Onda 0 §4) para que a equipe aprenda uma palavra só; e obrigatoriamente antes do congelamento (~20/09), porque carrega migration.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Critique/polish visual da vertical → **R6**.
|
||||
- Qualquer mudança de comportamento, campo, access ou fluxo da agenda (tarefas, atualizações, resultado, ciclo de vida) → permanecem como o C3/C12 os deixaram.
|
||||
- Visão "Giros" e camada de decisão de agenda → **E13** ([planejador-de-giros.md](planejador-de-giros.md)).
|
||||
- Escala/perf da lista e do feed → **C11** ([escala-dry-pos-c7.md](escala-dry-pos-c7.md)).
|
||||
- Renomear os valores de `kind`/`status` (dado em pt-BR) e os arquivos de `docs/design-refs/latest/`.
|
||||
- Estender o guard a `docs/` (planos entregues são história; reescrevê-los apagaria o rastro da decisão).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou renomeando, vou também arrumar a vertical."** Se alguém encostar no craft: o rename passa a competir com o R6 e o PR fica irreversível de revisar. **Mitigação:** um PR só de rename, diff mecânico, zero mudança de comportamento — qualquer melhoria vista no caminho vira linha no R6.
|
||||
- **Renomear índices/constraints "na mão" e esquecer um.** O drizzle compara nomes: um objeto esquecido faz o próximo `migrate:create` gerar um DROP/CREATE surpresa. **Mitigação:** enumerar os objetos a partir do `.json` do snapshot gerado (é a lista-alvo canônica) e conferir com `pnpm migrate:status` + um `migrate:create` de teste que deve sair **vazio** depois do rename (descartado em seguida).
|
||||
- **Guard genérico virando framework de vocabulário.** Se alguém "só generalizar", nasce um mini-DSL de regras de copy com severidade, escopo e config. **Mitigação:** tabela literal de 2 linhas dentro do spec; terceira entrada só quando existir um terceiro rename real.
|
||||
- **Banir `plan`/`plano` isolado.** Parece completar a limpeza e quebra a Fase 2 white-label e nomes de cidade. **Mitigação:** decisão travada acima; varredura manual única no PR.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Rótulo "Agenda" no sidebar.** Revisitar se, no onboarding (ou no R6), ≥2 pessoas do time chamarem o destino de "agenda" ao navegar — aí o rail muda de rótulo mantendo a entidade "Atividade".
|
||||
- **Terceira entrada no guard de vocabulário (extração para módulo próprio).** Revisitar quando existir um 3º rename de termo — antes disso, a tabela no spec basta.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha C / "Demais itens abertos"; Janela 1; grafo)
|
||||
- `src/collections/ActionPlan.ts` e `src/collections/CampaignDemand.ts` — schema, hooks, labels e o campo relacional a renomear
|
||||
- `src/migrations/20260718_222832_add_action_plan.ts` — inventário real de tabelas, enums, índices e constraints a renomear
|
||||
- `src/migrations/20260715_215834_rename_tag_visible_to_hidden.ts` e `20260723_202000_reconcile_municipality_remodel.ts` — padrão de migration à mão, data-preserving e guardada
|
||||
- `tests/unit/codebaseConventions.unit.spec.ts` — guard de terminologia a estender (walker, allowlist, escopo)
|
||||
- `src/components/campaign/shell/nav.ts`, `src/app/(campaign)/campanha/(app)/planos/**` — rota e navegação
|
||||
- `docs/plans/eventos-agenda-mobilizacao.md` (C3, origem do nome) · `docs/plans/planejador-de-giros.md` (E13, herdeiro da vertical) · `docs/plans/remodelagem-municipios.md` (precedente de rename amplo)
|
||||
- AGENTS.md — naming (identificadores em inglês, copy pt-BR), migrations (`push: false`, snapshot commitado), checklist de verificação; skill `payload-migrations` (guard rails de migration à mão)
|
||||
- `PRODUCT.md` (§Users, §Product Purpose, princípio 2) / `DESIGN.md` (register `product`) — o produto escrito já diz "agenda"
|
||||
119
docs/plans/selecionados-no-topo-filtros-header.md
Normal file
119
docs/plans/selecionados-no-topo-filtros-header.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Opções selecionadas no topo nos filtros do header
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos — "Selecionados no topo nos filtros do header"; superfície de coordenação, encaixe em B16 ✓)
|
||||
Impeccable: B — encaixe no Popover de filtro já entregue (`MunicipalityHeaderFilter`) e no select mobile equivalente; sem rota nova, sem tela nova
|
||||
Appetite: ~0,25–0,5 dia eng; helper puro client-safe + consumo nos dois controles + unit test; sem migration, sem collection, sem Consent, sem server action
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 8 — Feel the action) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells do sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignTransitionAnchor`, `CampaignListPendingBoundary`) · Popover shadcn já em uso.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. Sem `harden`/`optimize` (navegação por URL, ≤435 opções já carregadas no cliente).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Assessor / CG na lista de municípios com 2–5 territórios (ou assessores) já marcados; abre o funil da coluna para **tirar** um deles e precisa caçar o item marcado no meio de 27 (Território) ou 435 (Município) linhas — hoje a única pista é a checkbox, e a busca não ajuda quem não lembra o nome exato do que marcou.
|
||||
- **Job principal:** ver imediatamente o que está selecionado naquela coluna e desmarcar em um gesto.
|
||||
- **Estratégia de cor:** Restrained — agrupamento por posição + o divisor de hairline que o Popover já usa acima da lista; sem badge colorido, sem realce de fundo novo.
|
||||
- **Edit where you see:** não se aplica — filtro é navegação por URL (B15/B16), não mutação; as células editáveis do B9 continuam sendo o canal de escrita.
|
||||
- **Anti-goals:** virar painel de "filtros ativos" dentro do Popover (isso é o resumo/chips da barra slim); chips removíveis dentro do funil; seção com título "Selecionados" (chrome a mais para ≤5 linhas); reordenar sob o dedo do usuário durante a interação.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não — **Dados: N/A**. Nenhuma métrica, série ou agregado novo; o item só reordena as opções de um controle de recorte já existente.
|
||||
- **Decisões desbloqueadas:** as mesmas do B16 (qual TI / assessor / município atacar) — este item reduz o custo de **desfazer** um recorte, não cria leitura nova.
|
||||
- **Forma escolhida:** N/A (lista de opções do Popover, inalterada em conteúdo).
|
||||
- **Anti-goals de dado:** nenhuma contagem nova no trigger do funil; nenhuma reordenação das **linhas** da tabela (isso é sort, B15).
|
||||
|
||||
## Contexto
|
||||
|
||||
O **B16 ✓** (entregue 2026-07-25) pôs o filtro no `TableHead` de cada coluna de `/campanha/municipios`: `MunicipalityHeaderFilter` (`src/components/campaign/municipality/MunicipalityHeaderFilter.tsx`) abre um Popover com linhas exclusivas no topo (Prioritária; com/sem assessor) e, abaixo, a lista multi-seleção — Município (até 435 slugs, com busca a partir de 8 opções), Território (27), Assessores e Tendência. As opções vêm **facetadas** pelo servidor (`loadMunicipalityListFilterFacets`), na ordem natural do catálogo/consulta; os valores já selecionados entram na união justamente para poderem ser desfeitos. No mobile, o equivalente é o `MobileMultiFilterField` (`MunicipalityFilters.tsx`), um `NativeSelect` que prefixa "✓" nos escolhidos.
|
||||
|
||||
O atrito relatado (2026-07-25, uso real): com várias opções marcadas, **desmarcar exige caçar** — no desktop a checkbox marcada pode estar fora do viewport de 288px do Popover (`max-h-72` com scroll), no mobile o "✓" está perdido no meio da lista nativa. A ordem das opções é a única affordance disponível e hoje não trabalha a favor.
|
||||
|
||||
O pedido é ordenar as selecionadas no topo **e** fazê-lo no componente do sistema de listas, não numa gambiarra local: **B21** (página dos Territórios de Identidade) já está planejada para replicar este header, e o próprio plano do B21 registra a extração de um head genérico como "adiado com gatilho" (3º call site). Este item entrega o pedaço que **já** dá para compartilhar sem abstração prematura: a regra de ordenação, pura e testada.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- No Popover de filtro multi-seleção, as opções **selecionadas aparecem primeiro**, na ordem original entre si, separadas do resto pelo mesmo hairline usado hoje acima da lista; as não selecionadas mantêm a ordem original.
|
||||
- A ordem **não muda enquanto o Popover está aberto**: marcar/desmarcar altera só a checkbox; o reagrupamento acontece na próxima abertura.
|
||||
- A busca dentro do Popover respeita a mesma regra (selecionadas que casam com o termo primeiro).
|
||||
- O select mobile (`MobileMultiFilterField`) usa a mesma regra e o mesmo helper.
|
||||
- A regra vive num módulo **puro e client-safe** em `src/lib/`, com unit test, consumível por qualquer lista da campanha (B21/E12) sem copiar código.
|
||||
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; contrato de URL congelado intacto (`municipalityListUrl.ts`); `leader` segue redirecionado; nenhuma mudança no `where` do loader nem nas facetas do servidor.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **A peça compartilhada é o helper de ordenação (`src/lib/`), não a extração do `MunicipalityHeaderFilter` inteiro.** O plano do Pass 2 W1 nomeia "generalizar o `MunicipalityHeaderFilter` inteiro" como rabbit hole e o B21 condiciona o head genérico ao 3º call site; uma função pura sobre `{ value, label }[]` + `selected[]` é reutilizável hoje (desktop, mobile, B21) e testável sem DOM. **Rejeitado:** extrair `CampaignHeaderFilter` genérico agora (estoura o appetite em 4×, mistura este item com B21 e congela uma interface antes do segundo consumidor existir); reordenar direto no servidor, dentro das facetas (a ordenação passa a depender do estado da URL no cache do loader e o mobile, que não usa facetas para tendência, ficaria de fora); duplicar a lógica nos dois controles (o terceiro consumidor chega com B21).
|
||||
- **Ordem congelada enquanto o Popover está aberto (snapshot da seleção na abertura).** Reordenar a cada clique move as linhas debaixo do ponteiro e do foco de teclado/leitor de tela: o usuário que desmarca dois territórios seguidos erraria o segundo clique. **Rejeitado:** reordenar a cada toggle (regressão de a11y e de "Feel the action" — o feedback vira movimento, não confirmação); animar a transição de posição (movimento decorativo em controle de trabalho, contra o Field Desk); não reordenar nunca (é o pedido).
|
||||
- **Agrupamento por posição + divisor, sem cabeçalho de seção.** O Popover já usa `border-b` para separar as linhas exclusivas (Prioritária / com-sem assessor); repetir o mesmo recurso mantém a gramática. **Rejeitado:** título "Selecionadas" (chrome para tipicamente 1–5 linhas); chips removíveis no topo do Popover (duplica o resumo da barra slim e cria um segundo alvo de desmarcação).
|
||||
- **Escopo = filtros multi-seleção.** O único single-select (Tipo) tem no máximo um valor, já destacado, e ganha nada com reordenação; as linhas exclusivas continuam ancoradas acima da lista. **Rejeitado:** aplicar a comboboxes/`RelationMultiSelect` de formulários nesta entrega (outro padrão de interação — ver Adiado com gatilho).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`src/lib/listFilterOptions.ts`, `orderFilterOptionsSelectedFirst`, `FilterOptionLike`, `selectionSnapshot`), copy pt-BR inalterada.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **O divisor aparece sempre que há seleção, ou só quando a lista é rolável?** **Opções:** (A) sempre que há ≥1 selecionada; (B) só quando `options.length ≥ SEARCHABLE_OPTION_THRESHOLD`. **Recomendação: A** — regra única é mais previsível e o custo visual é uma linha de 1px; B economiza pixel ao preço de um comportamento condicional que ninguém consegue prever.
|
||||
- **Mobile reordena imediatamente após cada escolha?** **Opções:** (A) sim (o `NativeSelect` fecha a cada escolha, então não há "sob o dedo"); (B) espelhar o snapshot do desktop. **Recomendação: A** — reabrir o select já mostrando o recém-marcado no topo é exatamente o gesto de desfazer; sem sobreposição, não há risco de erro de alvo.
|
||||
- **Vale aplicar na lista de opções do `CampaignFilterChips`/barra slim?** **Recomendação:** não — chips ativos já vivem separados por natureza; nada a reordenar.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Facets["loadMunicipalityListFilterFacets\n(servidor, ordem natural)"]
|
||||
Helper["lib/listFilterOptions.ts\norderFilterOptionsSelectedFirst (puro)"]
|
||||
Popover["MunicipalityHeaderFilter\n(snapshot da seleção na abertura)"]
|
||||
Mobile["MobileMultiFilterField\n(NativeSelect)"]
|
||||
B21["B21 TerritorySortableHead\n(consumidor futuro)"]
|
||||
|
||||
Facets --> Popover
|
||||
Facets --> Mobile
|
||||
Helper --> Popover
|
||||
Helper --> Mobile
|
||||
Helper -.-> B21
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`src/lib/listFilterOptions.ts`** (novo, puro/client-safe): `orderFilterOptionsSelectedFirst<T extends FilterOptionLike>(options: readonly T[], selected: readonly string[]): { ordered: T[]; selectedCount: number }` — partição estável (selecionadas na ordem original, depois as demais), sem alocar quando `selectedCount === 0` ou quando tudo está selecionado. `FilterOptionLike = { value: string }` para servir também às linhas do mobile. Fica em `lib/` por ser lógica pura: `src/utilities/municipalityListFilters.ts` é o módulo de domínio do município e este helper precisa ser neutro (B21/E12).
|
||||
- **`MunicipalityHeaderFilter.tsx`** (em `src/components/campaign/municipality/`): guarda `selectionSnapshot` (array de valores) fixado no `onOpenChange(true)` e limpo no fechamento — a mesma função onde `query` já é resetada; a ordenação usa o snapshot, a checkbox continua lendo `viewState` (otimista), preservando o feedback imediato do B16. Aplica o helper **depois** do filtro de busca, sobre `visibleOptions`, e insere o divisor após a última selecionada. Nenhuma mudança nos `href`s nem no `useOptimistic`.
|
||||
- **`MobileMultiFilterField`** (em `MunicipalityFilters.tsx`): aplica o helper direto sobre `options` com `selected` corrente (sem snapshot — o select fecha a cada escolha), mantendo o prefixo "✓".
|
||||
- **Testes:** `tests/unit/listFilterOptions.unit.spec.ts` — estabilidade da ordem dentro de cada grupo, ausência de duplicatas, seleção vazia = identidade, valores selecionados fora do conjunto de opções ignorados (caso real: faceta que deixou de alcançar um valor).
|
||||
- **Sem migration, sem collection, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Duras:** nenhuma — **B16 ✓** (Popover e facetas) e o sistema de listas do Pass 2 W1 ✓ já estão entregues.
|
||||
- **Suaves:** **B21** (primeiro consumidor externo do helper; se B21 vier antes, o head dele já nasce chamando a função); **B22** (promove o tooltip de coluna para `shared/` pelo mesmo critério — peça compartilhada sem head genérico; os dois tocam o mesmo `TableHead` e devem ser feitos em sequência, não em paralelo, para evitar conflito no arquivo); **B17** (visibilidade de coluna não interage, mas divide o mesmo header).
|
||||
- Reusa: `src/utilities/municipalityListFilters.ts` (tipos e toggles), `src/lib/wordStartFilter.ts` (busca), `CampaignTransitionAnchor` (`CampaignListPending.tsx`).
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Extrair um `CampaignHeaderFilter` / head genérico para `shared/` — **B21** (adiado com gatilho: 3º call site).
|
||||
- Contador de selecionadas no trigger do funil ou chips dismissíveis no resumo — débito P2 do critique do B16, segue registrado lá.
|
||||
- Novos params de filtro, filtros numéricos ou reordenação de colunas — B17 / "Fora de escopo" do roadmap.
|
||||
- Ordenação das **linhas** da tabela (B15) e das opções nos comboboxes de formulário (`RelationMultiSelect`, `ContactCombobox`).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **"Já que estou aqui, generalizo o header inteiro."** Vira B21 antecipado sem o consumidor real, com risco de congelar a interface errada. **Mitigação:** o único artefato compartilhado desta entrega é a função pura; o Popover continua específico.
|
||||
- **Sincronizar snapshot com `useOptimistic` via `useEffect`.** É a mesma corrida que gerou o bug do "clique que se auto-desfazia" no B16. **Mitigação:** o snapshot é fixado no callback de abertura do Popover, nunca em efeito; ele governa **só** a ordem, jamais o estado marcado nem o `href`.
|
||||
- **Reordenar também as facetas no servidor "para ficar consistente".** Acopla ordenação a cache de loader e quebra a suposição de ordem natural do catálogo. **Mitigação:** ordenação é exclusivamente de apresentação, no cliente.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Aplicar a regra a `RelationMultiSelect` / comboboxes de formulário.** Revisitar quando houver relato de atrito de desmarcação em formulário (ex.: carteira de municípios em `/campanha/assessores`) — o padrão lá é chip + busca, não lista de checkboxes.
|
||||
- **Head de filtro genérico em `shared/`.** Gatilho herdado do B21: 3º call site (E12 ou outra lista com header rico).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins abertos; B16 ✓; B21; B22; B17)
|
||||
- [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) (B16 — Popover, facetas, bug do clique que se auto-desfazia)
|
||||
- [sistema-listas-campanha.md](sistema-listas-campanha.md) (Pass 2 W1 — rabbit hole de generalizar o header) · [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21 — gatilho do head genérico) · [explicacao-colunas-header-listas.md](explicacao-colunas-header-listas.md) (B22 — mesma estratégia de peça compartilhada; vizinho no mesmo `TableHead`) · [seletor-colunas-lista-municipios.md](seletor-colunas-lista-municipios.md) (B17)
|
||||
- `src/components/campaign/municipality/MunicipalityHeaderFilter.tsx`, `MunicipalityFilters.tsx` — os dois controles a alterar
|
||||
- `src/utilities/municipalityListFilters.ts`, `src/utilities/municipalityPageData.ts` (facetas) — origem e tipos das opções
|
||||
- AGENTS.md — naming (identificadores em inglês, copy pt-BR), fronteira `lib/` puro vs `utilities/` acoplado; `.cursor/rules/campanha-action-feedback.mdc` (Feel the action)
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, Restrained, anti-goal de spreadsheet/data-grid
|
||||
144
docs/plans/tooltip-celulas-listas.md
Normal file
144
docs/plans/tooltip-celulas-listas.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Tooltip de conteúdo nas células das listas
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B23**)
|
||||
Impeccable: B — encaixe nas células "Assessores" e "Tendência" já existentes de `/campanha/municipios`, com a capacidade promovida para o sistema de listas compartilhado (`CampaignTable`)
|
||||
Appetite: ~0,75 dia eng; 1 prop em `CampaignTableColumn`, 1 wrapper na célula, 1 flag no primitivo de tooltip, 2 consumidores (Assessores nos dois papéis + justificativa da Tendência); sem migration
|
||||
Responsável: —
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 3 — edit where you see; anti-goal spreadsheet/data-grid) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells existentes: `CampaignTable` (`src/components/campaign/shared/CampaignTable.tsx`), `MunicipalityHoverTooltip` → `CampaignHoverTooltip` (**B22**), `MunicipalityAdvisorAvatarStack`, `MunicipalityListAdvisorsControl`.
|
||||
|
||||
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só sob gatilho do Passo 8 (não há dado novo, escrita, PII nem query nova neste item).
|
||||
|
||||
Brief compacto:
|
||||
|
||||
- **Persona / contexto:** Alex (Coordenador Geral) e o assessor varrendo a tabela de 435 municípios no desktop. A coluna "Assessores" mostra hoje até 3 avatares com **iniciais** (`campaignUserInitials`) — dois assessores com as mesmas iniciais são indistinguíveis, e a partir do 4º nem avatar existe. O nome completo só aparece para leitor de tela (`sr-only`) ou abrindo o Popover de edição (só coordenador). Na coluna "Tendência", o mesmo varredor lê "AUMENTO"/"QUEDA" e não sabe **por quê**: a justificativa existe (`politicalTrend.note`, semeada em massa pelo E4R) e já está carregada na célula, mas só trafega como `<input type="hidden">`.
|
||||
- **Job principal:** saber **quem** responde por este município — e **por que** a tendência dele é essa — sem tirar os olhos da linha nem abrir nada.
|
||||
- **Estratégia de cor:** Restrained — a tooltip não introduz cor nova; usa o `TooltipContent` do tema.
|
||||
- **Edit where you see:** preservado — no papel coordenador a célula continua sendo o gatilho do Popover de edição (B9/B19); a tooltip é uma camada de **leitura** que não pode roubar o clique nem competir com ele.
|
||||
- **Anti-goals:** tooltip que atrasa/rouba o clique do Popover de assessores; tooltip em toda célula "por garantia" (ruído em 9 colunas × 435 linhas); cartão rico dentro da tooltip (mini-perfil, foto, métricas) — isso é o detalhe do município/assessor; novos tab stops no corpo da tabela.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Não como métrica: `Dados: N/A` — a tooltip mostra **conteúdo já carregado** no view model (nomes em `advisorNamesById`, justificativa em `politicalTrendNote`), sem número, série, agregado ou query nova.
|
||||
- **Decisões desbloqueadas:** Coordenador: "quem cobro por este município?" e "esta linha prioritária está com o assessor certo — reatribuo?" (hoje resolvido só abrindo o Popover linha a linha). Coordenador/assessor, na tendência: "esta QUEDA é boato de bastidor ou perda concreta de vereador — vale abrir demanda/marcar visita, ou seguir a fila?" (hoje exige abrir o detalhe do município).
|
||||
- **Forma escolhida:** texto curto (nomes, um por linha; ou a nota crua) na tooltip de hover/foco da própria célula — o degrau mais pobre que resolve. **Rejeitado:** coluna de nomes por extenso ou coluna "Justificativa" (estouram a largura de uma tabela já com 9 colunas, e texto livre longo destrói a densidade de 435 linhas); expandir o stack para todos os avatares (iniciais continuam ambíguas); linha expansível (segundo modelo de interação numa lista que já tem Popover); abrir o Popover só para ler (custa clique e mistura leitura com escrita); resumir/derivar a nota (inventar semântica sobre texto livre da mesa).
|
||||
- **Anti-goals de dado:** nada de KPI, sparkline ou contagem derivada dentro de tooltip — se um número precisar de explicação, o lugar é a célula (E8/E9) ou o header (**B22**).
|
||||
|
||||
## Contexto
|
||||
|
||||
O Pass 2 W1 transformou as listas de `/campanha` num sistema: colunas são dado (`CampaignTableColumn` com `id`/`head`/`cell`/`cellClassName` + seams `mandatory`/`defaultVisible`), a tabela é server component e a interatividade vive em ilhas referenciadas pelas definições. Oito superfícies usam esse shell.
|
||||
|
||||
O primitivo de tooltip existe e é bom: `MunicipalityHoverTooltip` (`src/components/campaign/municipality/MunicipalityHoverTooltip.tsx`) resolve hover, foco de teclado **e** tap em touch (Radix ignora pointer de toque no `TooltipTrigger`), com dismiss por `pointerdown` que trata o próprio conteúdo como "dentro" — comportamento conquistado no critique do E18. Ele está preso ao domínio `municipality` e só é usado em **headers** (`MunicipalitySortableHead`) e em dois cards. **B22** promove esse primitivo para `src/components/campaign/shared/CampaignHoverTooltip.tsx` e dá explicação a cada header.
|
||||
|
||||
Falta o lado das **células**: `CampaignTable` renderiza `column.cell(row)` cru dentro do `<TableCell>` e não oferece nenhuma forma declarativa de explicar o conteúdo da célula. O caso que motivou o pedido é a coluna "Assessores" de `/campanha/municipios` (`MunicipalityList.tsx` ~290–320): papel assessor vê `MunicipalityAdvisorAvatarStack` (até 3 avatares de iniciais + `sr-only` com todos os nomes); papel coordenador vê o mesmo stack dentro do gatilho do `MunicipalityListAdvisorsControl` (Popover de edição). Em nenhum dos dois o nome é legível a olho.
|
||||
|
||||
O segundo caso, pedido no mesmo dia, é a coluna **"Tendência"** (staff-only, `MunicipalityList.tsx` ~321–337 + card mobile ~456–467). `municipality.politicalTrend` é um group com `status` (`desfavoravel | neutra | favoravel`), `note` (texto livre) e a auditoria `recordedBy`/`recordedAt` derivada em `beforeChange` (`src/collections/Municipality.ts`). O **E4R ✓** preencheu esse group em massa: SITUAÇÃO da planilha virou `status` e a `note` carrega a justificativa da mesa mais a nota de proveniência. A célula renderiza `MunicipalityListTrendControl` — um `PopoverTrigger` com o Badge do status que abre o select de edição rápida (B9) — e **já recebe a nota** (`trendNote`), apenas para reemiti-la num `<input type="hidden">` e não perdê-la ao salvar o status. O dado está na célula, invisível; para lê-lo é preciso abrir `/campanha/municipios/<slug>`, onde `MunicipalityStrategyCard` o mostra no bloco "Tendência política".
|
||||
|
||||
Pedidos de produto (2026-07-25): (a) tooltip no hover dos assessores mostrando os nomes, **implementado como capacidade genérica da célula no componente de listas**, reutilizável em outros campos e páginas; (b) tooltip no hover da coluna "Tendência" com a justificativa como conteúdo, **sem tooltip quando não houver justificativa**.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Passar o mouse (ou focar, ou tocar) na célula de assessores de `/campanha/municipios` revela os nomes completos de **todos** os assessores do município — inclusive os que não couberam nos 3 avatares.
|
||||
- A capacidade é do **sistema de listas**: qualquer coluna de qualquer superfície que use `CampaignTable` declara `cellTooltip` e ganha o mesmo comportamento, sem componente novo por página.
|
||||
- Passar o mouse (ou focar) na célula de tendência revela a justificativa registrada; sem justificativa, nada muda em relação a hoje (nenhuma tooltip vazia, nenhum "—").
|
||||
- Nas células que já são gatilho de edição (assessores no papel coordenador, tendência para todo staff) a tooltip **não** interfere no Popover: o clique/tap continua abrindo a edição na primeira interação, e a informação continua alcançável por lá em touch.
|
||||
- Zero tab stops novos no corpo da tabela; a informação continua disponível a leitores de tela pelo texto já presente na célula.
|
||||
- Sem migration, sem collection, sem server action, sem query nova (os nomes já vêm em `advisorNamesById`) e sem mudança de contrato de URL. Nada muda para o papel `leader`.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **A tooltip de célula é campo de `CampaignTableColumn` (`cellTooltip?: (row) => ReactNode`), renderizado pela `CampaignTable`.** Diferente do header — que **B22** decidiu manter como prop dos componentes de head, porque `head` é um `ReactNode` opaco (ilha cliente que possui `aria-sort` e o Popover de filtro) —, a célula **já** é uma função `(row) => ReactNode` que a tabela renderiza dentro de um `<TableCell>` que ela mesma controla. É o ponto natural da capacidade, e sobrevive ao **B17** (uma coluna escondida leva sua tooltip junto). **Rejeitado:** repetir o padrão do header e pedir que cada célula se envolva sozinha (a capacidade não seria do sistema — foi exatamente o pedido do produto); `cloneElement` sobre o retorno de `cell` (frágil com fragmentos e múltiplos filhos).
|
||||
- **Um único primitivo compartilhado: `CampaignHoverTooltip` (o `MunicipalityHoverTooltip` promovido pelo B22).** Se o **B22** ainda não tiver sido implementado quando este item começar, **este item faz a promoção** (mover + atualizar os 3 call sites + comentário de cabeçalho, knip em zero) e o B22 passa a só consumir. **Rejeitado:** segundo componente de tooltip para células (duas implementações de touch divergindo — o dismiss por `pointerdown` já custou um critique); `title=` nativo (sem touch, sem estilo, atraso do SO).
|
||||
- **Sem novos tab stops no corpo da tabela: a tooltip é affordance redundante, nunca a única fonte da informação.** O wrapper da célula não recebe `tabIndex`; quem já tem controle focável dentro (o gatilho do Popover de assessores) segue focável e ganha a tooltip por foco de graça. A regra do contrato: só declara `cellTooltip` a coluna cujo conteúdo já é acessível por texto na célula (visível ou `sr-only`, como o stack de avatares faz). **Rejeitado:** `tabIndex={0}` no wrapper (435 linhas × N colunas de tab stops mortos, e a leitura de tela passaria a ouvir o nome duas vezes); virar `role="grid"` com roving tabindex (redesenho de acessibilidade fora do appetite — ver Rabbit holes).
|
||||
- **Na célula editável (assessores do coordenador, tendência), a tooltip é hover/foco apenas — o toque é do Popover.** `CampaignHoverTooltip` ganha `openOnTouch` (default `true`, o comportamento de hoje) e a supressão explícita `disabled` (ligada ao `open` do Popover, para a tooltip não ficar viva por trás do `PopoverContent`). Essas células passam `openOnTouch={false}`, porque um tap ali deve abrir a edição. **Rejeitado:** deixar o tap alternar os dois (o mesmo gesto abrindo tooltip e Popover é o defeito clássico de composição Radix); long-press para a tooltip (gesto não descoberto, conflita com a seleção de texto do SO); não pôr tooltip nenhuma nas células editáveis (é justamente onde Alex, a persona que mais varre a lista, está).
|
||||
- **Em touch, o conteúdo escondido aparece dentro do próprio Popover.** O de assessores já lista todos os nomes com checkbox; o de tendência passa a exibir a justificativa como **leitura** acima do select (o `<input type="hidden" name="trendNote">` continua intacto — é ele que preserva a nota no save do status). Assim a informação deixa de ser inalcançável em metade do uso de campo. **Rejeitado:** aceitar que o mobile não veja a justificativa; transformar a nota em textarea no Popover (ver Não escopo).
|
||||
- **Sem conteúdo, sem tooltip.** `cellTooltip` que retorna vazio/`null` para a linha não gera wrapper algum — vale para município sem assessor (a célula já diz "sem responsável" por extenso) e para tendência sem nota, que é a maioria das linhas. Pedido explícito de produto (2026-07-25). **Rejeitado:** tooltip com placeholder ("Sem justificativa"); CTA "adicionar justificativa" no hover (a escrita da nota mora no form de estratégia).
|
||||
- **A nota de tendência entra crua, sem truncar no servidor.** É texto curto da mesa; a contenção é visual (`max-w-xs` já no `TooltipContent` + `whitespace-pre-wrap`). **Rejeitado:** cortar em N caracteres com "…" (esconde justamente a parte que decide, e a lista não tem "ver mais"); separar justificativa de nota de proveniência (parser de planilha dentro da UI — ver Rabbit holes).
|
||||
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`cellTooltip`, `CampaignHoverTooltip`, `openOnTouch`, `disabled`, `formatAdvisorNamesTooltip`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **A tooltip de assessores mostra só nomes ou nome + papel/e-mail?** **Opções:** A) só os nomes, um por linha | B) nomes + e-mail | C) nomes + nº de municípios da carteira. **Recomendação:** **A** — e-mail é leitura privilegiada que hoje só o `advisorData` do B19 faz atrás do gate de rota, e carteira exigiria agregado novo; a tooltip serve a "quem cobro?", não a um mini-perfil. Quem precisa de mais tem `/campanha/assessores`. _(assumido)_
|
||||
- **Existe affordance visual de que a célula tem tooltip?** **Opções:** A) nenhuma (descoberta por hover; o cursor já muda no gatilho do Popover) | B) sublinhado pontilhado como o do header no B22 | C) `cursor-help`. **Recomendação:** **A** nas duas células desta entrega — avatares e Badge já leem como interativos, e sublinhado pontilhado sob eles introduziria um segundo vocabulário tipográfico. Reavaliar junto com o **B22** (que decide o pontilhado dos headers) e no critique do **R6**, quando uma coluna de **texto puro** adotar a capacidade. _(assumido)_
|
||||
- **A tooltip de tendência mostra só a nota, ou nota + "Registrada por X em DD/MM"?** **Opções:** A) só a nota | B) nota + autoria/data | C) nota + data. **Recomendação:** **A** — `recordedBy`/`recordedAt` não estão no view model da lista (só no de estratégia); trazê-los pediria mudança de loader/`select` por um dado que não muda a decisão de campo. Autoria continua no detalhe. _(assumido)_
|
||||
- **Quais colunas entram neste item?** **Opções:** A) só Assessores | B) Assessores + Tendência | C) também Território (texto truncável) | D) varrer as 8 listas. **Recomendação:** **B** — as duas foram pedidas por produto no mesmo dia (2026-07-25) e exercitam os dois modos do contrato (célula de leitura e célula que é gatilho de Popover), o que valida a capacidade antes de espalhá-la. As demais entram quando alguém não conseguir ler a célula, com a capacidade já pronta (ver Adiado com gatilho). B21 (territórios) nasce podendo usar.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
col["CampaignTableColumn<br/>cellTooltip?(row)"] --> table["CampaignTable<br/><TableCell>"]
|
||||
table --> tip["CampaignHoverTooltip<br/>(shared, B22)"]
|
||||
names["advisorNamesById<br/>(view model, já carregado)"] --> mun["municipalityListColumns()<br/>coluna advisors"]
|
||||
mun --> col
|
||||
mun --> ctrl["MunicipalityListAdvisorsControl<br/>(coordenador: openOnTouch=false)"]
|
||||
ctrl --> tip
|
||||
note["politicalTrendNote<br/>(view model, já carregado)"] --> trend["MunicipalityListTrendControl<br/>(openOnTouch=false, disabled={open})"]
|
||||
trend --> tip
|
||||
trend --> pop["PopoverContent:<br/>nota (leitura) + select + Salvar"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
|
||||
- **`CampaignTableColumn`** (`src/components/campaign/shared/CampaignTable.tsx`): novo campo opcional `cellTooltip?: (row: Row) => ReactNode`. Quando a função existe **e** retorna conteúdo não vazio para aquela linha, a `CampaignTable` envolve `column.cell(row)` num `CampaignHoverTooltip` cujo gatilho é um `<span className="inline-flex">` (sem `tabIndex`, sem `role`); caso contrário o render é byte a byte o de hoje. Documentar no JSDoc do tipo a regra de acessibilidade (conteúdo redundante) ao lado dos seams do B17.
|
||||
- **`CampaignHoverTooltip`** (`src/components/campaign/shared/…`, promovido no B22 ou aqui): duas props novas, opcionais e sem efeito nos call sites atuais — `openOnTouch?: boolean` (default `true`) que desliga o toggle por `pointerType === 'touch'` e o listener de `pointerdown`, e `disabled?: boolean` que força `open = false` e ignora reaberturas enquanto verdadeiro (usado por quem também abre um Popover). Hover e foco ficam intactos; o comentário de cabeçalho — que documenta o controle manual de `open`, herdado do critique do E18 — ganha o caso "gatilho que também abre Popover".
|
||||
- **`MunicipalityList.tsx`** (~290–320): a coluna `advisors` passa `cellTooltip: (municipality) => formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById))`, que retorna `null` quando não há assessor (a célula já diz "Sem responsável"/"Sem assessor" por extenso — tooltip ali seria eco).
|
||||
- **`MunicipalityListAdvisorsControl`** (`src/components/campaign/municipality/…`): envolve o próprio `PopoverTrigger` no `CampaignHoverTooltip` com `openOnTouch={false}`; a coluna não aplica a tooltip genérica nesse papel (senão haveria duas). A composição fica local porque só ela precisa conviver com um Popover.
|
||||
- **`formatAdvisorNamesTooltip`** (helper pequeno junto de `MunicipalityAdvisorAvatarStack.tsx`, onde as entradas de avatar já vivem): recebe `MunicipalityAdvisorAvatarEntry[]` e devolve a lista em pt-BR (um nome por linha) ou `null`.
|
||||
- **`MunicipalityListTrendControl`** (`src/components/campaign/municipality/…`): extrai o botão-gatilho atual para uma variável e o envolve no `CampaignHoverTooltip` **apenas** quando `trendNote?.trim()` for não vazio (`disabled={open}`, `openOnTouch={false}`, `side="bottom"`, `align="start"`) — a coluna não declara `cellTooltip` para tendência, pela mesma razão da célula de assessores do coordenador (senão haveria duas). Dentro do `PopoverContent`, acima do `Field`, um `<p className="whitespace-pre-wrap text-sm text-muted-foreground">` com a nota quando existir; o `<input type="hidden" name="trendNote">` permanece como está.
|
||||
- **Testes**: unit do helper de nomes (vazio → `null`; 1, 3 e 5 nomes) + unit do contrato da tabela garantindo que uma coluna sem `cellTooltip` renderiza a célula sem wrapper (protege o `overflow-x-visible`/sticky header da lista de municípios contra regressão de layout) + unit em `tests/unit/campaignComponents.unit.spec.ts` (família já pinada) de que o gatilho de tendência ganha a nota como descrição quando ela existe e nenhuma tooltip quando não existe.
|
||||
- **Sem migration, sem collection, sem server action.**
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Suave: B22** (promoção de `MunicipalityHoverTooltip` → `shared/CampaignHoverTooltip`). Quem chegar primeiro faz a promoção; nenhum dos dois bloqueia o outro. Ordem preferida: B22 antes (mesmo appetite, e deixa o primitivo já no lugar).
|
||||
- Suaves já entregues: **Pass 2 W1** (colunas como dado em `CampaignTable`), **B19 ✓** (o Popover de assessores e a carteira que dá sentido aos nomes), **E9 ✓** (badge "sem responsável" que a tooltip não deve duplicar), **B9** (edição rápida na célula — é o gatilho que as duas células compartilham), **E4R ✓** (quem populou as notas de tendência em massa; sem ele o hover da tendência explicaria pouco).
|
||||
- Reusa sem alterar: `MunicipalityListViewModel.politicalTrendNote` (`src/utilities/municipalityViewModels.ts`) e `advisorNamesById` — nenhuma mudança de loader, `select` ou query.
|
||||
- **B17** (seletor de colunas) e **B21** (territórios) são consumidores futuros, não dependências.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Copy/tooltip de **header** por coluna — é o **B22**; este item cuida do conteúdo da célula. Em particular, explicar **o que a coluna "Tendência" mede** é B22; aqui só aparece a justificativa daquela linha.
|
||||
- Tooltip nas outras colunas de municípios e nas outras 7 listas — capacidade pronta, adoção sob demanda (ver Adiado com gatilho).
|
||||
- **Editar a justificativa pela lista** — continua no form de estratégia (`MunicipalityStrategyForm` / `/campanha/municipios/<slug>/editar`); a Popover da lista é single-field por decisão do B9, e um textarea ali a transformaria em formulário.
|
||||
- Autoria/data da tendência na lista (ver Questões em aberto) e qualquer mudança em `recordedBy`/`recordedAt`.
|
||||
- Cards mobile de `/campanha/municipios`: já mostram os nomes de assessores por extenso (`names.join(', ')`); a justificativa da tendência chega pelo mesmo Popover do card — nada específico a fazer.
|
||||
- Mini-perfil do assessor (e-mail, carteira, contato) — é `/campanha/assessores` (**B19 ✓**).
|
||||
- Truncamento inteligente de texto com "…" + tooltip automática por overflow — ver Rabbit holes.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Detectar overflow para "só mostrar tooltip quando truncar".** Se alguém "só completar": `ResizeObserver`/medição por célula em 435 linhas × N colunas, re-render em resize, e a tabela deixa de ser server component barata. **Mitigação neste item:** a tooltip é declarada pela coluna, nunca inferida do layout.
|
||||
- **Transformar a tooltip em cartão rico (HoverCard) com foto, e-mail e carteira.** Explode em leitura privilegiada de e-mail fora do gate do B19, novo loader e decisão de access. **Mitigação:** decisão travada "só nomes"; caminho de aprofundamento é `/campanha/assessores`.
|
||||
- **Tornar as células focáveis "por acessibilidade".** Puxa `role="grid"` + roving tabindex + revisão das 8 listas. **Mitigação:** contrato de que a tooltip é redundante; o texto acessível continua na célula.
|
||||
- **Composição Tooltip + Popover no mesmo gatilho.** Se tratada de passagem, dá tap duplo, foco preso ao fechar o Popover e tooltip presa aberta. **Mitigação:** `openOnTouch={false}` + `disabled={open}` nas células editáveis e verificação explícita no critique (abrir/fechar o Popover por teclado sem tooltip órfã).
|
||||
- **Sanitizar/normalizar a nota do E4R** (separar a justificativa da nota de proveniência, parsear a célula crua da planilha). Vira parser de planilha dentro da UI. **Mitigação:** exibir cru; qualquer normalização é decisão do seed (E4R), não da lista.
|
||||
- **"Já que a nota está no hover, deixa editar por ali."** Vira textarea no Popover, validação de tamanho, conflito com o hidden input e um segundo caminho de escrita para o mesmo campo. **Mitigação:** leitura apenas (Não escopo acima).
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Adotar `cellTooltip` em outras colunas/listas** (Território truncado, dobradinhas por município). Revisitar quando: alguém do time relatar em sessão/R6 não conseguir ler uma célula, ou quando uma coluna nova nascer com conteúdo abreviado.
|
||||
- **Justificativa em tooltip nas outras superfícies de tendência** (`MunicipalityStrategyCard`, dossiê). Revisitar quando: alguma delas passar a esconder a nota por densidade — hoje ambas a exibem por extenso.
|
||||
- **Affordance visual (sublinhado pontilhado) na célula.** Revisitar quando: a primeira coluna de **texto** adotar a capacidade, ou o critique do R6 registrar falha de descoberta.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha B — "Demais itens abertos", grafo, Janela 1–2, Cortes seguros)
|
||||
- `src/components/campaign/shared/CampaignTable.tsx` — `CampaignTableColumn`, render de `<TableCell>`, seams do B17
|
||||
- `src/components/campaign/municipality/MunicipalityList.tsx` (~186–337, 450–500) — colunas `advisors` e `trend`, `advisorEntries`, card mobile
|
||||
- `src/components/campaign/municipality/MunicipalityAdvisorAvatarStack.tsx` — stack de iniciais + `sr-only` (base da regra de redundância) e `MissingAdvisorBadge`
|
||||
- `src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx` — Popover de edição que a tooltip não pode atrapalhar
|
||||
- `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` — gatilho da tendência, Popover de edição rápida e o `trendNote` hoje só em hidden input
|
||||
- `src/utilities/municipalityViewModels.ts` — `politicalTrendNote` já no view model da lista (nenhuma mudança necessária)
|
||||
- `src/collections/Municipality.ts` — group `politicalTrend` (status/note/auditoria derivada)
|
||||
- `src/components/campaign/municipality/MunicipalityStrategyCard.tsx` — onde a nota aparece hoje (referência de copy)
|
||||
- `docs/plans/import-planilha-projecao.md` (**E4R ✓**) — origem das notas de tendência (SITUAÇÃO + nota de proveniência)
|
||||
- `src/components/campaign/municipality/MunicipalityHoverTooltip.tsx` — primitivo (hover/foco/tap + dismiss por `pointerdown`) a compartilhar
|
||||
- `docs/plans/explicacao-colunas-header-listas.md` (**B22**) — decisão irmã no header e a promoção para `shared/`
|
||||
- `docs/plans/gerenciar-assessores.md` (**B19**) — carteira de assessores e leitura privilegiada de e-mail (por que ela fica fora da tooltip)
|
||||
- AGENTS.md — naming (identificadores em inglês / copy pt-BR), client boundary (`'use client'` só nas ilhas), knip zero após o move
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, clareza sob pressão, edit where you see, anti-goal data-grid
|
||||
121
docs/roadmap.md
121
docs/roadmap.md
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue