From 3536eb02d35c4fdfde97d59f3231b7abe8aab478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 01:43:44 -0300 Subject: [PATCH] Deliver B29: header sort and filters on the leaderships list. Bring B15/B16-style URL sort/filter to /campanha/liderancas on the B33 mold, with shared CampaignTableHead filter slot and B18 exhaustive enum canonicalize from /simplify. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- .../ordenacao-filtros-lista-liderancas.md | 74 +++--- docs/roadmap.md | 6 +- .../campanha/(app)/liderancas/page.tsx | 239 +++++++++++++---- .../campaign/leadership/LeadershipFilters.tsx | 69 +++++ .../leadership/LeadershipHeaderFilter.tsx | 129 +++++++++ .../leadership/LeadershipSortableHead.tsx | 84 ++++++ .../campaign/shared/CampaignTable.tsx | 71 +++-- src/lib/schemas/leadership.ts | 4 + src/utilities/leadershipData.ts | 117 +++++---- src/utilities/leadershipLabels.ts | 5 + src/utilities/leadershipListFilters.ts | 174 +++++++++++++ src/utilities/leadershipListUrl.ts | 245 ++++++++++++++++++ src/utilities/stateDeputyListUrl.ts | 4 +- tests/int/campaignEntityListData.int.spec.ts | 176 ++++++++++++- .../campaignEntityListParsers.unit.spec.ts | 15 +- tests/unit/leadershipListUrl.unit.spec.ts | 208 +++++++++++++++ 17 files changed, 1440 insertions(+), 183 deletions(-) create mode 100644 src/components/campaign/leadership/LeadershipFilters.tsx create mode 100644 src/components/campaign/leadership/LeadershipHeaderFilter.tsx create mode 100644 src/components/campaign/leadership/LeadershipSortableHead.tsx create mode 100644 src/utilities/leadershipListFilters.ts create mode 100644 src/utilities/leadershipListUrl.ts create mode 100644 tests/unit/leadershipListUrl.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index f6038e41..1f7fffd3 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B29 entregue em código (2026-07-28):** sort + filtro no header de `/campanha/liderancas`. Contrato próprio em `leadershipListUrl.ts` / `leadershipListFilters.ts` (`q`, `status`, `sector`, `municipality`, `access`, `sort`, `dir`, `page`; default `-updatedAt` omitido). Wrappers `LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters` consomem o chrome do **B21 ✓** e o hook **B33+ ✓** (substitui `CampaignSearchForm`). Busca e sort por nome via join `contact.name` (int verde; teto `limit: 200` eliminado). Colunas novas Setor + Última atualização (idade relativa visível). Facet de municípios cross-filtrado; empty via `CampaignTable empty`; mobile sem barra empilhada (só header). Sem migration/Consent/action. Gate: tsc/lint/format/knip(P3)/cycles/652 unit/448 int/build; Aikido 0 findings. Plano: [`ordenacao-filtros-lista-liderancas.md`](../../../docs/plans/ordenacao-filtros-lista-liderancas.md). - **E14 entregue em código (2026-07-27):** níveis de envolvimento N0–N4 por município, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓ existia desde 2026-07-24 com access e testes, sem nenhum caller). Migration `20260727_161752_add_municipality_engagement_level`: `engagementLevel` (select `n0…n4`, indexado, **sem default**), `levelNote` (textarea 2000), `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`) e o valor `movimento` no enum de `allocationDecision.outcome` — mover de nível não é `aceita` nem `descarta`, e fingir que era teria envenenado a triagem do E11. **Sem backfill:** o plano pedia default N2; carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, então nulo renderiza "Sem nível" e o filtro por ele **é** a fila de triagem inicial. Mover é decisão de realocação → `canManageMunicipalityEngagementLevel` (`isCampaignUnrestricted`: coordinator/candidate), leitura no bloco staff normal (`canReadCampaignStaffField`); o assessor lê e propõe por sinal. `setMunicipalityEngagementLevelRecord` segura `municipality-engagement-level:{id}` **antes** de ler o nível atual — sem o lock o `from` do snapshot é chute sob concorrência — e grava os dois documentos na mesma transação, com `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }`: só os valores sobre os quais a decisão foi tomada, nunca o documento. Histerese do relatório §6.8 em `src/lib/engagementLevel.ts` (puro, client-safe): salto de 2 níveis só com choque triangulado, 3 semanas de proteção pós-promoção (só contra rebaixamento) e **um movimento por mês**, derivável só de `levelChangedAt` — nenhuma query extra. **A rota tem três estados, não dois:** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar", porque um movimento barrado é uma decisão que a coordenação pode tomar mesmo assim — o Popover mantém o rascunho e oferece o override, que entra no snapshot com exatamente as regras que contrariou. **Submit explícito, não auto-save** (exceção que `campanha-edit-where-you-see` prevê para flows com nota/confirmação; efeito colateral: o controle não vira o 4º call site da máquina de debounce que o **B32+** vai auditar). Lista: 11ª coluna staff com badge outline numerado, filtro `?level=` **nativo** (ao contrário de `classe` (E10), o campo é armazenado e entra no `where`; "sem nível" não cabe num `in` e vira ramo `or` com `{ exists: false }`) e sort `nivel` **em memória**: `n0…n4` até ordena lexicograficamente, mas o Postgres põe NULL primeiro no DESC e sem backfill "N4 primeiro" abriria com uma página de "Sem nível" — `sortByNullableValue` afunda o nulo nas duas direções, como `sem_base` em `classe`, ao custo de carregar o escopo inteiro (o que o sort padrão `deficit` já faz). Verbete `nivel-de-envolvimento` no E18. **Dois achados:** com `overrideAccess: false` o Payload **descarta o campo em silêncio** quando o ator não pode escrevê-lo, em vez de rejeitar o update — o int do assessor prova que o nível **não mudou**, não que a chamada lançou; e o banco de dev local estava fora do histórico de migrations (`allocation_decision.outcome` era `varchar`, sem o tipo `enum_allocation_decision_outcome` que o `teqo_test`, construído só por migrations, tinha) — reparado à mão no `teqo`, nada a fazer em produção. **Rebase sobre o B42:** o merge encaixou o bloco "Nível" dentro da gaveta mobile que o B42 acabara de criar, então o controle largou o Popover próprio e passou a receber `variant` como os três irmãos (`CampaignCellEditOverlay`: Popover no `md+`, Drawer abaixo) — o ramo Popover do container é ponto a ponto o que o E14 tinha à mão, e no toque o trigger passa a soletrar o nível. Suítes pós-rebase: 66 unit (630) e 54 int (436); Aikido 0 findings. Plano as-built: [`niveis-de-envolvimento.md`](../../../docs/plans/niveis-de-envolvimento.md). - **E13 entregue em código (2026-07-27):** planejador de presença/giros. Núcleo decisório: `visitEligibility.ts` (puro, input explícito espelhando `TerritorialClassInput`) resolve as **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) e as 3 contraindicações (`perdida`/`sem_rede`/`no_teto`, sempre aviso com contra-oferta, nunca bloqueio); `visitPlannerAnchors.ts` guarda as datas das fases (`construcao|consolidacao|ativacao`, cortes ilustrativos no precedente de `territorialClassAnchors.ts`, comparação por data civil da Bahia via `formatBahiaCivilDate` novo em `campaignTime.ts`); `visitPlannerData.ts` (`server-only`) agrupa candidatas por TI. Superfícies: card "Elegibilidade para visita" no `OverviewTab` do detalhe do município e compositor em `/campanha/atividades/giros` (TI → âncora/satélites/semente → gerar rascunhos), alcançado por botão "Planejar giro" na página de Atividades, sem entrada nova no sidebar. **A auditoria pré-implementação derrubou duas premissas do plano de 24/07:** `kind: 'visita'` nunca entrou na migration do C12 e **não entra** — `activity.deputyPresent` já É o marcador de presença do candidato (o giro é derivável por `deputyPresent` + TI + janela de datas), e criar o enum custaria migration por um kind sobreposto a `reuniao_apoio`/`ato`; e `allocationDecision` não tem write path em `src/` (só o int spec), então o "não vá" fica textual e gravar o override virou gatilho do **E11**, que precisa desse write path de qualquer forma. Terceira correção: "satélites contíguos" era impossível — `bahiaTerritories.ts` só tem pertencimento, adjacência de polígono existe apenas nos scripts de build, então **encaixe = mesmo TI**, dito com essas palavras na UI (construir adjacência é o rabbit hole "otimizador de rota" que o plano já proibia). **Duas decisões de implementação que valem por si:** (1) o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do **E10** para `municipalityPotential.ts` (`uncapturedFieldVotes`, `catalogMedianUncapturedFieldVotes`, `catalogMedianProjectedValidVotes`, memoizadas) — sem isso o card "Elegibilidade" e a coluna "Classe" seriam duas contas do mesmo número na mesma tela, e ninguém saberia qual estava certa; (2) `createTourDraftActivitiesRecord` relê os ids das paradas com `overrideAccess: false` **dentro** da transação e aborta se algum não voltar, porque o `create` de `activity` é um booleano de staff e Payload não expressa restrição por município no create — buraco achado por int test, sem o qual um assessor semeava rascunhos no estado inteiro postando ids que o compositor nunca lhe ofereceu. Também: `loadMunicipalityVisitEligibility` roda o **mesmo** loader recortado no TI em vez de um segundo avaliador (encaixe é afirmação sobre vizinhos — não é avaliável para um município sozinho, e dois avaliadores começariam a discordar); `composeTourSuggestion` prioriza âncora → semente → satélites (a primeira versão deixava os satélites consumirem as vagas e a semente de expansão nunca aparecia); caps `TOUR_MAX_SATELLITES` 3 e `MAX_TOUR_STOPS` 8, verificados no cliente e revalidados na action; `TourRegionPicker` monta href com `usePathname` + `encodeURIComponent` em vez do serializador canônico (lição do **B14**: arrastaria `bahiaTerritories` + `municipalityCatalog` para o bundle). Dois verbetes E18 (`elegibilidade-para-visita`, `fase-do-calendario`). Suítes **fundidas, não acrescentadas** (arquivos separados empurraram unit e int para timeout): views do planejador em `tests/unit/visitEligibility.unit.spec.ts`, compositor em `tests/int/campaignActivity.int.spec.ts`; `CALENDAR_PHASES`/`VISIT_CONDITIONS`/`VISIT_CONTRAINDICATIONS` são exportadas e varridas lá para provar completude dos rótulos (um `const` usado só como tipo é warning de ESLint, e a varredura é a razão honesta de ser público). First Load JS: `/campanha/atividades/giros` 8,59 kB / 226 kB; detalhe do município 7,3 kB / 232 kB. Sem migration, sem collection, sem `Consent`, sem entidade `tour` persistida. Gate verde e bare (tsc, lint, format:check, knip com o P3 pré-existente, check:cycles, 429 unit+int, build local); Aikido **0 findings**. **e2e não serviu de veredito:** `campaignMunicipalities.e2e.spec.ts` falha em árvore stashada também, em teste diferente a cada rodada — o flake já ledgerizado. **Adiados com gatilho:** override "não vá" gravando `allocationDecision` → **E11**; níveis N0–N4 no gatilho do "não vá" → **E14** (até lá, classe E10 + rede vazia); padrões J como sugestão automática → E11 fase 2; entidade `tour` persistida → 3º giro real composto. Plano as-built: [`planejador-de-giros.md`](../../../docs/plans/planejador-de-giros.md). - **B42 entregue em código (2026-07-27):** polimento mobile de `/campanha/municipios`. **Escopo ampliado na sessão (decisão do usuário): Votos estimados entrou** junto com Tendência e Assessores, o que fez o item passar de 2 para **3 controles com a mesma casca** — critério de extração do repo — e **inverteu a questão em aberto do plano** (que recomendava branch local e extração só no `/simplify`): nasceu `src/components/campaign/shared/CampaignCellEditOverlay.tsx`, Popover em `md+` / Drawer abaixo. A casca carrega **só o container**: cada controle segue dono do seu auto-save, e como o `handleOpenChange` de cada um passa direto para `onOpenChange`, fechar o Drawer grava exatamente como dispensar o Popover (é o que preserva o flush da justificativa do **B24 ✓**). O **Sinal (B26 ✓) não migrou, e o motivo que registrei primeiro estava errado** — não é que "a casca não expressa isso sem virar genérica": o `
` não precisa envolver o `DrawerHeader` (título não é campo) e submit fora do form é HTML padrão via `form=""`. Falta só um **slot `footer`** (+ `formId` do chamador), débito com gatilho. Ele segue sendo o precedente visual (com "Fechar" no lugar de "Cancelar", porque aqui não há o que cancelar) e agora **consome** a classe de trigger da casca e os três atributos ARIA que lhe faltavam. **`useIsMobile` foi descartado por auditoria**: cards (`md:hidden`) e tabela (`hidden md:block`) são árvores irmãs, então `variant` por call site basta — media query trocaria o container depois da hidratação. **Navegação do card = link esticado, zero JS** (`` no `

` com `after:absolute after:inset-0` sobre `
`), então `MunicipalityList` continua Server Component; o botão "Abrir município" saiu — o plano previa testar "tap no título navega" quando o título **ainda não era link**, e a navegação nasceu junto. **Quem é `relative` é o trigger, não a célula:** a primeira versão posicionou os seis `
` do `
` e com eles os rótulos e o padding de cada célula, o que fez metade do card não editar nem navegar (P0 do critique, corrigido movendo `relative` para a classe compartilhada do trigger; o e2e passou a tocar num rótulo `dt` dentro da grade, exatamente a área que o bug engolia). **Dois props `layout` morreram** com a paridade pedida (`TerritorialClassReadout` = badge + `sr-only` do porquê; `MunicipalityListGoalCoverageCell` = sempre `compact`). `autoFocusScenario` suprimido no `sheet` para o teclado virtual não subir antes de o número ser lido; `municipalityName` é **exigido** onde há Drawer e **opcional** no Popover (união discriminada nos props de Votos estimados; o 3º call site, `MunicipalityGoalAccountCard`, é Popover sob o título do próprio município e não passa). A primeira versão o **proibia** no Popover (`?: never`) e criou assimetria na mesma linha da tabela — Tendência/Assessores nomeando o município no rótulo e Votos estimados não; resolvido para o lado de incluir porque a célula do nome é ``, **não** ``, então nada supre o contexto quando o foco entra num trigger (custo: 0,22 KiB gzip em 25 linhas). Sem migration/collection/action/`Consent`. **Critique + `/simplify` (mesma sessão), três defeitos de comportamento além do P0 acima:** (1) o Drawer focava o primeiro tabbable e subia o teclado virtual sobre a folha antes de ela ser lida — `initialFocus` no `DrawerTitle` (`tabIndex={-1}`), e o `preventContentAutoFocus` que devia mitigar isso era **inerte no branch `sheet`**, um prop que mentia → renomeado `preventPopoverAutoFocus`, comportamento do Drawer incondicional e pinado no spec; (2) `aria-label` **substitui** o conteúdo do trigger, então as quatro células anunciavam o verbo e engoliam o valor que o pill/avatar/número mostra → forma do Sinal ("Editar tendência em X — Favorável"), o que de passagem tirou `municipalityName` de payload RSC morto no desktop (27,9 KiB crus / 2,9 KiB gzip em 435 linhas); (3) fechar **commita** o rascunho e desmonta o `Alert`/`aria-live` que carregariam o erro, então falha na saída revertia o badge em silêncio → `toast.error` só quando já fechado, nos quatro controles. Mais: alvos de toque a 44px abaixo de `md` (inputs de votos, chip de assessor, linhas do `Command`), `CampaignHoverTooltip` na Classe do card (`sr-only` sozinho deixava o veredito inalcançável no toque — fecha o "Adiado com gatilho" do plano) e a frase da Cobertura em `sr-only` (`title` não é anunciado nem alcançado por toque). **Reuso: `LeadershipListSupportStatusControl` (B32 ✓) era o branch Popover da casca redigitado à mão** (classe de trigger byte-a-byte inclusive) → 4º call site, ~25 linhas a menos, nenhum prop novo; a string de trigger virou `campaignCellEditTriggerClassName`; `sheetBodyClassName` era o único prop de classe que substituía em vez de compor, o que obrigava o chamador de assessores a re-espalhar `px-4`. **Medido e não pago:** cada Drawer fechado ainda executa o corpo de `DrawerContent`, cujo `cn()` roda `tailwind-merge` sobre 4.068 caracteres (6,2 µs contra 0,8 µs do Popover que substituiu = +0,40 ms em 25 linhas, +7 ms no caminho de 435) — é troca, não adição; o fix é `{everOpened && …}` (não `{open && …}`, senão perde `data-starting-style`). Testes: 6 casos jsdom (3 controles × 2 variants; stubs de `ResizeObserver`/`scrollIntoView` que o `cmdk` exige) + e2e em viewport 390×844 (abrir Tendência não navega; toque num rótulo da grade abre o município). **Nada foi confirmado em device real:** a coleta de evidência visual falhou por falta de automação de browser na sessão, então alvos de toque estão checados por classe, não medidos. Gate tsc/lint/format/knip(P3)/cycles/**592 unit**/**420 int**/build (`/campanha/municipios` 13,4 kB / 320 kB); Aikido 0 findings. Falhas rotativas da suíte cheia reproduzidas **piores na árvore limpa** (4×3, mesmo teste de demanda na mesma linha) sob contenção de outra sessão Playwright — mesmo padrão de ambiente do B27 ✓/B32 ✓. **Cuidado operacional descoberto aqui: `git stash` é compartilhado entre worktrees** — stashar para medir baseline com sessões concorrentes traz o WIP alheio no `pop` (revertido com `git stash store `); prefira patch local. Plano: [`polimento-mobile-lista-municipios.md`](../../../docs/plans/polimento-mobile-lista-municipios.md). @@ -22,7 +23,7 @@ alwaysApply: false - **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md). - **B36 entregue em código (2026-07-26):** coluna "Lideranças" em `/campanha/dobradinhas` — chips nomeados (link `/campanha/liderancas/[id]`) + Popover+Command + auto-save por delta, reusando **sem alteração** a mutação/schema/lock do **B31 ✓** (`setLeadershipStateDeputyMembership`, `leadership-state-deputies:{id}`, `nextStateDeputyIdsAfterMembership`). **Auditoria do plano** (escrito antes do B31 as-built) corrigiu duas defasagens: (1) sem `RelationOptionsProvider`/Context — B31 já tinha trocado Context por columns factory no `/simplify`; (2) casca `dobradinhas/formActions.ts` própria (não import cross-rota de `liderancas/formActions`). **Extração paga aqui:** `LeadershipStateDeputiesCell` (domínio) → `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction: 'fromLeadership' | 'fromStateDeputy'`, `measureOverflow` default true — clamp 3 linhas + "Ver mais…" no lado do deputado; `liderancas` passa `false`). `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Gate: tsc/lint/format/knip/cycles/unit 530 + int (flakes de timeout re-rodados verdes)/build; Aikido 0 findings. Plano: [`chips-liderancas-lista-dobradinhas.md`](../../../docs/plans/chips-liderancas-lista-dobradinhas.md). - **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md). -- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md). +- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29 — agora entregue para status/setor/município/acesso; org/dobradinha seguem Adiado). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md). - **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md). - **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md). - **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível. diff --git a/docs/plans/ordenacao-filtros-lista-liderancas.md b/docs/plans/ordenacao-filtros-lista-liderancas.md index 551fc045..1c21c1cb 100644 --- a/docs/plans/ordenacao-filtros-lista-liderancas.md +++ b/docs/plans/ordenacao-filtros-lista-liderancas.md @@ -1,14 +1,18 @@ # B29 — Ordenação e filtro no header da lista de lideranças -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue em código +Atualizado em: 2026-07-28 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–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas; o chrome compartilhado já foi entregue pelo B21 +Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico (B15 ✓ + B16 ✓) via chrome compartilhado do B21 ✓; molde de domínio = B33 ✓ (`StateDeputy*`) +Appetite: ~1–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas + wrappers; chrome já pago Responsável: — +**As-built 2026-07-28:** join `contact.name` funcionou para `where` e `sort` (int tests verdes); `LeadershipFilters` substitui `CampaignSearchForm` (busca + resumo/Limpar, sem barra mobile empilhada); colunas Setor + Última atualização (idade relativa visível; absoluto em `title`); facet de municípios cross-filtrado; empty via `CampaignTable empty`; harden/optimize out. Critique P1s fechados (copy do header Municípios; freshness relativa). + **Atualização B21 (2026-07-25):** a extração deixou de fazer parte do appetite deste item. `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` já existem, e os wrappers de municípios já foram migrados. B29 implementa apenas os wrappers/política de liderança e o contrato de URL/filtros próprios. +**Revisão 2026-07-28 (auditoria pré-implementação):** (1) template real = B33 dobradinhas (`StateDeputySortableHead`/`StateDeputyHeaderFilter`/`StateDeputyFilters` + `useCampaignListFilterNavigation`), não re-extrair chrome; (2) Adiado "editar supportStatus na célula" fechado por **B32 ✓**; (3) `CampaignSearchForm` em lideranças é substituído por `LeadershipFilters` (preserva params de URL); (4) mobile v1 = header em todos os breakpoints + busca/resumo, sem barra empilhada. + ## 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`). @@ -20,7 +24,7 @@ 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. +- **Edit where you see:** fora do escopo de sort/filtro deste item. A edição rápida de `supportStatus` na célula já foi entregue por **B32 ✓** (`LeadershipListSupportStatusControl`); este item não toca mutação. - **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 @@ -42,7 +46,7 @@ Com o seed do E4R ✓ a base já entrou com lideranças nominais vindas da plani 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. +Do lado da UI, o chrome compartilhado (`CampaignSortableHead` / `CampaignHeaderFilterPopover`) já existe (B21 ✓) e é consumido por municípios, territórios e dobradinhas (B33 ✓). Lideranças ainda usa `CampaignTableHead` simples + `CampaignSearchForm` (só `q`+`page`). ## Objetivos @@ -50,8 +54,8 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: ` - **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). +- **Header e filtros permanecem montados com zero resultados** — a lista passa a usar a prop `empty` do `CampaignTable` (precedente B16 ✓ / B33 ✓), em vez de trocar a tabela inteira pelo empty state. +- **Wrappers de domínio** (`LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters`) consomem o chrome compartilhado já entregue — sem re-extração. - **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. @@ -61,7 +65,7 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: ` - **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). +- **Chrome compartilhado já existe (B21 ✓)** — B29 só escreve wrappers de domínio. Molde: B33 (`StateDeputy*`). **Rejeitado:** re-extrair ou duplicar o head. - **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"). @@ -76,32 +80,34 @@ Do lado da UI, o header rico hoje é **exclusivo do domínio `municipality`**: ` ```mermaid flowchart LR - Url["leadershipListUrl.ts
(state · parse · where · sort · hrefs)"] - Filt["leadershipListFilters.ts
(definições · toggles · chips)"] - Loader["leadershipData.ts
(find paginado + facets)"] - Page["/campanha/liderancas/page.tsx (RSC)"] - Dom["LeadershipSortableHead / LeadershipHeaderFilter
(estado + optimistic + hrefs)"] - MunDom["MunicipalitySortableHead / MunicipalityHeaderFilter
(migrados)"] - Shared["shared/CampaignSortableHead
shared/CampaignHeaderFilterPopover"] - Table["CampaignTable (colunas como dado)"] - Url --> Loader --> Page --> Table - Url --> Dom - Filt --> Dom - Dom --> Shared - MunDom --> Shared - Dom --> Table + Url["leadershipListUrl.ts"] + Filt["leadershipListFilters.ts"] + Loader["leadershipData.ts"] + Page["liderancas/page.tsx"] + Head["LeadershipSortableHead"] + Filter["LeadershipHeaderFilter"] + Shell["LeadershipFilters"] + Shared["CampaignSortableHead + CampaignHeaderFilterPopover"] + Nav["useCampaignListFilterNavigation"] + Url --> Loader --> Page + Url --> Head + Filt --> Filter + Filt --> Shell + Head --> Shared + Filter --> Shared + Shell --> Nav + Page --> Head + Page --> Shell ``` -Componentes: +Componentes (molde B33 — não re-extrair chrome): -- **`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). +- **`src/utilities/leadershipListUrl.ts`** (novo): contrato `q`/`status`/`sector`/`municipality`/`access`/`sort`/`dir`/`page`; where/sort/hrefs/`resolveLeadershipListUrl`. Espelha `stateDeputyListUrl.ts`. +- **`src/utilities/leadershipListFilters.ts`** (novo): toggles/clear/summary; options estáticas status/setor; municipality por id; access `com`|`sem`. +- **`src/utilities/leadershipData.ts`** (alterado): where+sort+facet de municípios; busca via `'contact.name'`; parse/hrefs saem para o módulo de URL (re-export se preciso). +- **`LeadershipSortableHead` / `LeadershipHeaderFilter` / `LeadershipFilters`** (novos): wrappers + busca que preserva URL (substitui `CampaignSearchForm`). +- **`liderancas/page.tsx`**: redirect canônico; colunas Setor + Última atualização; heads ricos; `CampaignTable empty={…}`. +- **Testes:** unit `leadershipListUrl`; int filtros + sort por nome + escopo do assessor. - **Sem migration, sem collection, sem `Consent`, sem server action.** ## Dependências @@ -130,9 +136,9 @@ Componentes: ## 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. +- ~~**Edição rápida de `supportStatus` na célula**~~ — **fechado por B32 ✓** (2026-07-26). - **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). +- **Select compacto / barra empilhada 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; B33 optou por paridade mobile — lideranças não). - **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 diff --git a/docs/roadmap.md b/docs/roadmap.md index c3ecce6c..9cfe7722 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-27 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); a triage do fechamento **não abriu ID novo** — os três débitos caíram no **B32+**: o 5º controle/rota JSON tirou **F2/F3** da condicionalidade (a política de mesma origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas, e a que esquecer a linha falha **aberta**) e o slot `footer` que o **B42 ✓** havia adiado virou **F5**, porque na folha "Fechar" está pinado e "Registrar movimento" fica abaixo da dobra; **fill-in** dimir o resultado no compositor de giro — a troca de TI em `/campanha/atividades/giros` sinaliza só no seletor; a triage do fechamento do E13 mediu que as peças de pending não são acopladas a lista e o `/simplify` as havia dispensado por isso · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B34+** fill-in de engenharia pós-B34 registrado — a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` e **+42 kB** em `/campanha/assessores` (medido contra `main` limpo; a maior parte é a célula, não o catálogo), o gatilho de extração do `useChipRowOverflow` **disparou** (~105 linhas idênticas entre as duas células de chips da mesma tabela) e os três módulos de delta de membership colapsam num só; o passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,043 ms quente; busca 4,59 → 0,077 ms por tecla), e a **segunda rodada** somou três fases que mudaram o maior lever — `revalidatePath` por toggle (374 statements / 229 ms por clique), índice de municípios sem cache no degrau 2 da escada, e o `filter` por linha que derrota o próprio WeakMap — mais o N+1 de access pré-existente promovido a **P1** no `TECH-DEBT` (367 dos 371 statements da rota), que é quem precisa cair primeiro · [plano](plans/escala-dry-pos-b34.md); **B34 ✓** chips editáveis de municípios na lista de lideranças — célula sempre editável em `pointer-fine` + Drawer em `pointer-coarse`, `AdvisorMunicipalityCell` promovido a `shared/MunicipalityPortfolioCell` com Assessores migrado no mesmo PR, action única de lote (a de um id só, inclusive a de assessor, foi deletada), anti-goal de input sempre montado **emendado** para relações por chips; corrigiu de passagem o seam `description` de `CampaignTableHead` (B22 ✓), que quebrava qualquer chamador RSC · [plano](plans/chips-municipios-lista-liderancas.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). +Atualizado em: 2026-07-28 (**E14 ✓** níveis de envolvimento N0–N4 — `municipality.engagementLevel` staff-only (sem default: nulo é "sem nível", e filtrar por ele é a fila de triagem), motivo e sinais de reversão obrigatórios, e o **primeiro escritor de produção do `allocationDecision`** (C12 ✓), gravado na mesma transação sob lock porque o "nível anterior" do snapshot precisa ser verdade; enum de `outcome` estendido com `movimento`; histerese do §6.8 (salto duplo só com choque triangulado, 3 semanas de proteção, um movimento por mês) num módulo puro que roda no cliente **e** no servidor, com a rota respondendo `blocked` (409) com as violações tipadas e o override registrado com as regras que contrariou; submit explícito em vez de auto-save (exceção prevista pela regra, e o controle não vira o 4º call site do B32+); coluna + filtro nativo (`?level=`) e sort em memória na lista (o Postgres põe NULL primeiro no DESC, e sem backfill isso entregaria "N4 primeiro" cheio de "Sem nível") + verbete no E18 · [plano](plans/niveis-de-envolvimento.md); a triage do fechamento **não abriu ID novo** — os três débitos caíram no **B32+**: o 5º controle/rota JSON tirou **F2/F3** da condicionalidade (a política de mesma origem que `isSameOriginRequest` diz possuir está copiada em cinco rotas, e a que esquecer a linha falha **aberta**) e o slot `footer` que o **B42 ✓** havia adiado virou **F5**, porque na folha "Fechar" está pinado e "Registrar movimento" fica abaixo da dobra; **fill-in** dimir o resultado no compositor de giro — a troca de TI em `/campanha/atividades/giros` sinaliza só no seletor; a triage do fechamento do E13 mediu que as peças de pending não são acopladas a lista e o `/simplify` as havia dispensado por isso · [plano](plans/feedback-pendente-compositor-giro.md); **E13 ✓** planejador de presença/giros — elegibilidade de visita em **5 condições ✓/—** (volume, headroom, rede, janela, encaixe) no detalhe do município, fase do calendário (`construcao|consolidacao|ativacao`, âncoras ilustrativas em `src/lib/visitPlannerAnchors.ts`) e compositor em `/campanha/atividades/giros` gerando `activity` em rascunho com `deputyPresent` numa transação; **duas premissas do plano caíram na auditoria** — `kind: 'visita'` nunca entrou na migration do C12 (e não entra: `deputyPresent` já É o marcador de presença) e `allocationDecision` não tem write path em `src/` (o "não vá" é aviso textual; gravar o override vira gatilho do **E11**); "encaixe em giro" = **mesmo TI**, porque não existe grafo de contiguidade em runtime; o teto-do-campo-não-capturado e as medianas do catálogo saíram do classificador do E10 para `municipalityPotential.ts` para que card e coluna "Classe" não pudessem divergir; a action relê os ids das paradas com `overrideAccess: false` **dentro** da transação (o `create` de `activity` é booleano de staff — sem isso um assessor semeava o estado inteiro), buraco achado por int test; sem migration, sem collection, sem `Consent`, sem entidade `tour` · [plano](plans/planejador-de-giros.md); **B42 ✓** polimento mobile da lista de municípios — Drawer em Tendência/Assessores/Votos estimados pela casca `CampaignCellEditOverlay`, card compacto e clicável por link esticado; a triage do fechamento não abriu ID novo: **F4** entrou no **B32+** (região `aria-live` que desmonta com o overlay) e o débito de bundle do catálogo no **B33+** deixou de ser condicional (gatilho disparado por B41 ✓ e B42 ✓) · [plano](plans/polimento-mobile-lista-municipios.md); **B34+** fill-in de engenharia pós-B34 registrado — a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` e **+42 kB** em `/campanha/assessores` (medido contra `main` limpo; a maior parte é a célula, não o catálogo), o gatilho de extração do `useChipRowOverflow` **disparou** (~105 linhas idênticas entre as duas células de chips da mesma tabela) e os três módulos de delta de membership colapsam num só; o passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,043 ms quente; busca 4,59 → 0,077 ms por tecla), e a **segunda rodada** somou três fases que mudaram o maior lever — `revalidatePath` por toggle (374 statements / 229 ms por clique), índice de municípios sem cache no degrau 2 da escada, e o `filter` por linha que derrota o próprio WeakMap — mais o N+1 de access pré-existente promovido a **P1** no `TECH-DEBT` (367 dos 371 statements da rota), que é quem precisa cair primeiro · [plano](plans/escala-dry-pos-b34.md); **B34 ✓** chips editáveis de municípios na lista de lideranças — célula sempre editável em `pointer-fine` + Drawer em `pointer-coarse`, `AdvisorMunicipalityCell` promovido a `shared/MunicipalityPortfolioCell` com Assessores migrado no mesmo PR, action única de lote (a de um id só, inclusive a de assessor, foi deletada), anti-goal de input sempre montado **emendado** para relações por chips; corrigiu de passagem o seam `description` de `CampaignTableHead` (B22 ✓), que quebrava qualquer chamador RSC · [plano](plans/chips-municipios-lista-liderancas.md); **B41 ✓** scroll horizontal + sticky Município · [plano](plans/scroll-horizontal-lista-municipios.md); **B32+** novo fill-in de engenharia registrado no fechamento do B32 — `MunicipalityListExpectedVotesControl`/`MunicipalityListTrendControl`/`LeadershipListSupportStatusControl` são o 3º call site da mesma máquina de auto-save por debounce (estado/refs/save/schedule/flush quase idênticos), gatilho que os três planos B24/B27/B32 já haviam registrado; auditoria primeiro, extração condicional (precedente B33+) — [plano](plans/escala-dry-pos-b32.md); **B32 ✓** editar Status de apoio na célula da lista de lideranças — `LeadershipListSupportStatusControl` (Popover + `NativeSelect`, sempre montado, sem toggle "Editar" de tabela inteira) grava por `POST /campanha/liderancas/support-status` (auto-save ~150 ms, sem `revalidatePath`), reusando `isSameOriginRequest`/`campaignJsonMutationRoute.ts` já extraídos pelo B24 ✓/B27 ✓; fecha o "Adiado com gatilho" do B29 — [plano](plans/autosave-status-lista-liderancas.md); **B33+ ✓** `useCampaignListFilterNavigation` — o scaffold de navegação/debounce sai dos três filtros de lista para um hook em `shared/` (281 linhas removidas contra 75 em `src`); a auditoria derrubou a premissa do plano de que os três domínios teriam assinaturas de canonicalização diferentes — `buildStateDeputyListHref`/`buildTerritoryListHref`/`buildMunicipalityListHref` **já canonicalizam internamente**, então o `parse(...)` explícito de dobradinhas/territórios era trabalho redundante e um único callback `toHref` cobre os três; a entrega achou **dois** bugs de comportamento, não de estrutura: tocar um filtro no meio da digitação **descartava** a busca pendente em dobradinhas e territórios (unificado na semântica de munícipios por `navigateWithSearch`), e o `setTimeout` do debounce capturava o `state` do render que o agendou, então um sort clicado dentro da janela de 1s era **revertido** um segundo depois — munícipios escapava por acidente, pelo `key=` da página que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix); o `/simplify` ainda achou o fallback `useCampaignListPending() ?? useTransition()` copiado em 5 lugares, com o hook novo como 6º → `useCampaignListTransition`, e um `aria-live` que fazia o resumo de filtros de munícipios ser a **terceira** região viva da mesma página; `shouldUpdateMunicipalitySearchUrl` orfanou e foi deletado; `tests/unit/campaignListFilterNavigation.unit.spec.ts` (jsdom + router mockado + fake timers, 8 casos × 3 listas) é a primeira cobertura desses componentes — [plano](plans/escala-dry-pos-b33.md); **C8 F4b ✓** guard de `formActions` ampliado para irmãos `*FormActions.ts` + `taskActions.ts`; 6 ladders migradas; `taskActions.ts` allowlisted · [plano](plans/escala-dry-pos-c6.md); **Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); ~~**C8 F4b**~~ ✓ (entregue 2026-07-27); **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -111,7 +111,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B27** combobox + chips + auto-save no popover de Assessores da lista de municípios~~ — **entregue 2026-07-26** (chips removíveis + `Command` de busca acento-insensível + gravação **por delta**, sem botão "Salvar"; `nextAdvisorIdsAfterMembership` subiu para `src/lib/municipalityAdvisorMembership.ts`; endpoint `POST /campanha/municipios/advisors` espelha `expected-votes/`; `assignMunicipalityAdvisorsFormAction`/replace do array sobrevive só para `/editar`) · [plano](plans/combobox-assessores-lista-municipios.md) - ~~**B28** e-mail + celular na lista de lideranças~~ — **entregue 2026-07-26** (em `/campanha/liderancas`: colunas **E-mail** e **Celular** com o **mesmo funcionamento de leitura** da tabela de assessores — **B19 ✓** — clique copia (toast), "—" se vazio, celular com máscara BR, mais uma coluna de **Ações** com ícone WhatsApp (`wa.me`) por linha quando o número normaliza; `LeadershipRowViewModel.email` populado sem query nova e `loadLeadershipDetail` deduplicado; primitivo de copy extraído para `shared/CampaignCopyableCell.tsx` **já no 2º call site** — `AdvisorsTable` migrado no mesmo PR — e `whatsAppHrefForPhone` promovido a `lib/phone.ts`; `CampaignTable` permanece (sem clonar `AdvisorsTable`). **Fora:** toggle "Editar" / debounce in-list (Contact update + locks = Adiado no plano); sem migration/collection/Consent/action de escrita) · [plano](plans/email-celular-lista-liderancas.md) - ~~**B30** convidar para completar cadastro por WhatsApp na lista de lideranças~~ — **entregue 2026-07-26** (`LeadershipInviteRowAction` na coluna `actions` ao lado do WhatsApp de contato do B28 ✓; `UserPlusIcon` + Popover two-step com `createCampaignInvite`/`kind: 'autopreenchimento'`; disable por `normalizeBrazilianPhone`; sem migration/action nova) · [plano](plans/convite-whatsapp-lista-liderancas.md) -- **B29** ordenação e filtro no header da lista de lideranças — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) +- ~~**B29** ordenação e filtro no header da lista de lideranças~~ — **entregue 2026-07-28** — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) - ~~**B31** coluna de dobradinhas na lista de lideranças~~ — **entregue 2026-07-26** (coluna **Dobradinhas** entre Organizações e Acesso ao app: chips clicáveis → `/campanha/dobradinhas/`, edição por Popover+`Command` — mesma mecânica do **B27 ✓**, com escrita por **server action chamada direto do cliente via `useTransition`**, padrão de `AdvisorMunicipalityCell`/**B19 ✓**, não o endpoint JSON do B27, porque aqui há derivados a revalidar; `setLeadershipStateDeputyMembership(Record)` em transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/` (id concreto — `/simplify` rodada 2 trocou o template dinâmico), `/campanha/dobradinhas` e a ficha do deputado tocado; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo (`loadStateDeputyOptions`, agora com `plainName`/`party`/`slug`) entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns` — `/simplify` rodada 2 substituiu o Context inicial); `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; `loadLeadershipDetail` deduplicado. É o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º) · [plano](plans/dobradinhas-lista-liderancas.md) - ~~**B32** editar Status de apoio na célula da lista de lideranças~~ — **entregue 2026-07-26** (`LeadershipListSupportStatusControl`: Popover + `NativeSelect` **sempre montado** na coluna "Status" de `/campanha/liderancas`, sem toggle "Editar" de tabela inteira — mesmo anti-goal já travado no B19 ✓/B28/B31 ✓; auto-save por debounce ~150 ms via `POST /campanha/liderancas/support-status` (2º/3º endpoint JSON da campanha — `isSameOriginRequest` e o shell `campaignJsonMutationRoute.ts` já existiam por B24 ✓/B27 ✓, este item só importa), reusando `updateLeadershipInternal` sem `revalidatePath`; extraiu `leadershipStaffEditSafeMessages` (completado nos irmãos `liderancas/formActions.ts`/`nova/formActions.ts` pelo `/simplify`) e `leadershipLabels.ts` (agora fonte única também para `SupportStatusBadge`); fecha o "Adiado com gatilho" que o B29 registrou) · [plano](plans/autosave-status-lista-liderancas.md) - ~~**B33** ordenação e filtro no header da lista de dobradinhas~~ — **entregue 2026-07-26** (`?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** sem extrair de novo; `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` espelham `municipalityListUrl.ts`/`territoryListUrl.ts`; paridade mobile — `StateDeputyFilters` — entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test, não assumida; `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado) · [plano](plans/ordenacao-filtros-lista-dobradinhas.md) @@ -200,7 +200,7 @@ flowchart TD B34n["B34 Chips editáveis de município
na lista de lideranças"] B37n["B37 Chips editáveis de município
na lista de dobradinhas"] B36n["B36 ✓ Chips de lideranças
na lista de dobradinhas"] - B29n["B29 Sort + filtro no header
da lista de lideranças"] + B29n["B29 ✓ Sort + filtro no header
da lista de lideranças"] C13n["C13 ✓ Plano de Ação → Atividade
(rename + guard de vocabulário)"] E4R["E4R ✓ Import planilha
(seed estratégia)"] A11n["A11 ✓ Posição em votos"] diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index fd0ca92b..c518d1b3 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -5,15 +5,19 @@ import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction' +import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters' import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl' +import { + LeadershipFilterHead, + LeadershipSortableHead, +} from '@/components/campaign/leadership/LeadershipSortableHead' import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell' -import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, CampaignListResults, + CampaignTransitionAnchor, } from '@/components/campaign/shared/CampaignListPending' -import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm' import { CampaignTable, CampaignTableHead, @@ -26,19 +30,39 @@ import { import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' -import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' +import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas/leadership' +import { cn } from '@/lib/utils' import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' +import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' +import { formatRelativeAge } from '@/utilities/formatRelativeAge' +import { loadLeadershipListPageData, type LeadershipRowViewModel } from '@/utilities/leadershipData' +import { leadershipAccessFilterLabels } from '@/utilities/leadershipLabels' +import { + buildLeadershipFilterHref, + clearLeadershipListFilters, + type LeadershipFilterOption, +} from '@/utilities/leadershipListFilters' import { buildLeadershipListHref, - loadLeadershipListPageData, - parseLeadershipListParams, - type LeadershipRowViewModel, -} from '@/utilities/leadershipData' + formatLeadershipListSortSummary, + resolveLeadershipListSort, + resolveLeadershipListUrl, + type LeadershipListState, +} from '@/utilities/leadershipListUrl' +import { leadershipSectorLabels } from '@/utilities/leadershipUi' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' import { @@ -50,20 +74,63 @@ type LeadershipsPageProps = { searchParams: Promise> } +const dateFormatter = new Intl.DateTimeFormat('pt-BR') + +const LeadershipListEmptyState = ({ state }: { state: LeadershipListState }) => ( + + + + + Nenhuma liderança encontrada + + Cadastre a primeira liderança ou ajuste a busca e os filtros. Você só vê lideranças dos seus + municípios. + + + + + Limpar busca e filtros + + + + +) + const leadershipColumns = ({ + state, stateDeputyOptions, municipalityIndex, + municipalityFilterOptions, addableMunicipalityIds, + nowMs, }: { + state: LeadershipListState stateDeputyOptions: RelationCellOption[] municipalityIndex: MunicipalityPortfolioIndexEntry[] + municipalityFilterOptions: LeadershipFilterOption[] /** Advisors may only link municipalities they administer; staff: undefined. */ addableMunicipalityIds?: ReadonlySet + nowMs: number }): Array> => [ { id: 'name', mandatory: true, - head: Nome, + head: ( + + Nome + + ), cell: (row) => ( Status, + head: ( + + Status + + ), cell: (row) => ( ), }, + { + id: 'sector', + head: ( + + Setor + + ), + cell: (row) => { + if (!row.sector || !isLeadershipSector(row.sector)) return row.sector || '—' + return leadershipSectorLabels[row.sector] + }, + }, { id: 'municipalities', head: ( - + Municípios - + ), cellClassName: 'max-w-64 whitespace-normal', cell: (row) => ( @@ -149,13 +237,35 @@ const leadershipColumns = ({ }, { id: 'appAccess', - head: Acesso ao app, + head: ( + + Acesso ao app + + ), cell: (row) => ( - {row.hasAppAccess ? 'Com acesso' : 'Sem acesso'} + {row.hasAppAccess ? leadershipAccessFilterLabels.com : leadershipAccessFilterLabels.sem} ), }, + { + id: 'updatedAt', + head: ( + + Última atualização + + ), + cell: (row) => { + const absolute = formatBahiaDateTimeLabel(row.updatedAt) + const relative = formatRelativeAge(new Date(row.updatedAt).getTime(), nowMs) + return ( + + ) + }, + }, { id: 'actions', head: ( @@ -204,19 +314,51 @@ const leadershipColumns = ({ export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) { const rawSearchParams = await searchParams + const canonicalUrl = resolveLeadershipListUrl(rawSearchParams) + if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) redirect('/campanha/login') if (!isCampaignStaff(user)) redirect('/campanha') - const state = parseLeadershipListParams(rawSearchParams) - const [{ rows, totalDocs, totalPages }, stateDeputyOptions, municipalityIndex, administeredIds] = - await Promise.all([ - loadLeadershipListPageData(payload, user, state), - loadStateDeputyOptions(payload, user), - loadMunicipalityPortfolioIndex(payload), - user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, - ]) + const [ + { rows, totalDocs, totalPages, filterFacets }, + stateDeputyOptions, + municipalityIndex, + administeredIds, + ] = await Promise.all([ + loadLeadershipListPageData(payload, user, canonicalUrl.state), + loadStateDeputyOptions(payload, user), + loadMunicipalityPortfolioIndex(payload), + user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, + ]) + const resolvedUrl = resolveLeadershipListUrl(rawSearchParams, totalPages) + if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) + const { state } = resolvedUrl + + const municipalityById = new Map(municipalityIndex.map((entry) => [entry.id, entry])) + const municipalityFilterOptions: LeadershipFilterOption[] = filterFacets.municipalityIDs + .map((id) => { + const entry = municipalityById.get(id) + return entry ? { value: String(id), label: entry.name } : null + }) + .filter((option): option is LeadershipFilterOption => option !== null) + .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + + const municipalityLabelsById: Record = {} + for (const option of municipalityFilterOptions) { + municipalityLabelsById[Number(option.value)] = option.label + } + for (const id of state.municipalities ?? []) { + if (!municipalityLabelsById[id]) { + municipalityLabelsById[id] = municipalityById.get(id)?.name ?? `Município #${id}` + } + } + + const { sort, dir } = resolveLeadershipListSort(state) + const sortSummary = formatLeadershipListSortSummary(sort, dir) const columns = leadershipColumns({ + state, stateDeputyOptions: stateDeputyOptions.map((option) => ({ id: option.id, searchLabel: option.name, @@ -228,7 +370,9 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP }, })), municipalityIndex, + municipalityFilterOptions, ...(administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}), + nowMs: Date.now(), }) return ( @@ -249,40 +393,29 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP - + +

+ {sortSummary} +

+ row.id} + empty={} + /> {rows.length ? ( - <> - row.id} /> - buildLeadershipListHref(state, page)} - /> - - ) : ( - - - - )} + buildLeadershipListHref(state, page)} + /> + ) : null}
diff --git a/src/components/campaign/leadership/LeadershipFilters.tsx b/src/components/campaign/leadership/LeadershipFilters.tsx new file mode 100644 index 00000000..ecc1e24d --- /dev/null +++ b/src/components/campaign/leadership/LeadershipFilters.tsx @@ -0,0 +1,69 @@ +'use client' + +import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' +import { Button } from '@/components/ui/button' +import { + buildLeadershipFilterHref, + clearLeadershipListFilters, + formatLeadershipActiveFiltersSummary, +} from '@/utilities/leadershipListFilters' +import { type LeadershipListState } from '@/utilities/leadershipListUrl' + +export const LeadershipFilters = ({ + state, + municipalityLabelsById, +}: { + state: LeadershipListState + /** Labels for the active municipality filter chips in the summary. */ + municipalityLabelsById?: Readonly> +}) => { + const { search, onSearchChange, draftQ, isPending, navigateWithSearch, clearSearchAndNavigate } = + useCampaignListFilterNavigation({ state, toHref: buildLeadershipFilterHref }) + const activeSummary = formatLeadershipActiveFiltersSummary( + { ...state, q: draftQ }, + municipalityLabelsById, + ) + + return ( + { + event.preventDefault() + navigateWithSearch(state) + }} + > +
+ onSearchChange(event.target.value)} + /> + {activeSummary ? ( +

+ {activeSummary} +

+ ) : null} + {activeSummary ? ( + + ) : null} +
+ + {activeSummary ? ( +

{activeSummary}

+ ) : null} + + ) +} diff --git a/src/components/campaign/leadership/LeadershipHeaderFilter.tsx b/src/components/campaign/leadership/LeadershipHeaderFilter.tsx new file mode 100644 index 00000000..4553bcb7 --- /dev/null +++ b/src/components/campaign/leadership/LeadershipHeaderFilter.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useOptimistic } from 'react' + +import { + CampaignHeaderFilterPopover, + type CampaignHeaderFilterRow, +} from '@/components/campaign/shared/CampaignHeaderFilterPopover' +import { + buildLeadershipFilterHref, + clearLeadershipAccessFilter, + clearLeadershipMunicipalityFilter, + clearLeadershipSectorFilter, + clearLeadershipStatusFilter, + isLeadershipColumnFilterActive, + leadershipAccessFilterOptions, + leadershipSectorFilterOptions, + leadershipStatusFilterOptions, + toggleLeadershipAccessFilter, + toggleLeadershipMunicipalityFilter, + toggleLeadershipSectorFilter, + toggleLeadershipStatusFilter, + type LeadershipFilterOption, + type LeadershipFilterParam, +} from '@/utilities/leadershipListFilters' +import { type LeadershipListState } from '@/utilities/leadershipListUrl' + +const FILTER_LABELS: Record = { + supportStatus: 'Status', + sector: 'Setor', + municipality: 'Municípios', + access: 'Acesso ao app', +} + +type FilterBranch = { + options: LeadershipFilterOption[] + toggle: (state: LeadershipListState, value: string) => LeadershipListState + clear: (state: LeadershipListState) => LeadershipListState + selected: (state: LeadershipListState, value: string) => boolean + checkbox?: boolean + closeOnChoose?: boolean +} + +const resolveFilterBranch = ( + filterParam: LeadershipFilterParam, + options: LeadershipFilterOption[] | undefined, +): FilterBranch => { + switch (filterParam) { + case 'supportStatus': + return { + options: leadershipStatusFilterOptions, + toggle: toggleLeadershipStatusFilter, + clear: clearLeadershipStatusFilter, + selected: (state, value) => Boolean(state.statuses?.some((status) => status === value)), + checkbox: true, + } + case 'sector': + return { + options: leadershipSectorFilterOptions, + toggle: toggleLeadershipSectorFilter, + clear: clearLeadershipSectorFilter, + selected: (state, value) => Boolean(state.sectors?.some((sector) => sector === value)), + checkbox: true, + } + case 'municipality': + return { + options: options ?? [], + toggle: toggleLeadershipMunicipalityFilter, + clear: clearLeadershipMunicipalityFilter, + selected: (state, value) => Boolean(state.municipalities?.includes(Number(value))), + checkbox: true, + } + case 'access': + return { + options: leadershipAccessFilterOptions, + toggle: toggleLeadershipAccessFilter, + clear: clearLeadershipAccessFilter, + selected: (state, value) => state.access === value, + closeOnChoose: true, + } + } +} + +export const LeadershipHeaderFilter = ({ + state, + filterParam, + options, +}: { + state: LeadershipListState + filterParam: LeadershipFilterParam + /** Facet options for Município; closed enums ignore this. */ + options?: LeadershipFilterOption[] +}) => { + const [viewState, setOptimisticState] = useOptimistic(state) + const active = isLeadershipColumnFilterActive(viewState, filterParam) + const branch = resolveFilterBranch(filterParam, options) + + const optionRows: CampaignHeaderFilterRow[] = branch.options.map((option) => { + const next = branch.toggle(viewState, option.value) + return { + value: option.value, + label: option.label, + href: buildLeadershipFilterHref(next), + selected: branch.selected(viewState, option.value), + ...(branch.checkbox ? { checkbox: true as const } : {}), + onChoose: () => setOptimisticState(next), + } + }) + + const cleared = active ? branch.clear(viewState) : null + + return ( + setOptimisticState(cleared), + } + : undefined + } + /> + ) +} diff --git a/src/components/campaign/leadership/LeadershipSortableHead.tsx b/src/components/campaign/leadership/LeadershipSortableHead.tsx new file mode 100644 index 00000000..705fd2c3 --- /dev/null +++ b/src/components/campaign/leadership/LeadershipSortableHead.tsx @@ -0,0 +1,84 @@ +'use client' + +import type { ReactNode } from 'react' + +import { LeadershipHeaderFilter } from '@/components/campaign/leadership/LeadershipHeaderFilter' +import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead' +import { CampaignTableHead } from '@/components/campaign/shared/CampaignTable' +import type { + LeadershipFilterOption, + LeadershipFilterParam, +} from '@/utilities/leadershipListFilters' +import { + buildLeadershipSortHref, + defaultLeadershipListSortDir, + leadershipListSortLabels, + resolveLeadershipListSort, + type LeadershipListSortKey, + type LeadershipListState, +} from '@/utilities/leadershipListUrl' + +type LeadershipSortableHeadProps = { + state: LeadershipListState + sortKey: LeadershipListSortKey + children?: ReactNode + align?: 'left' | 'center' | 'right' + className?: string + filterParam?: LeadershipFilterParam + filterOptions?: LeadershipFilterOption[] +} + +export const LeadershipSortableHead = ({ + state, + sortKey, + children, + align = 'left', + className, + filterParam, + filterOptions, +}: LeadershipSortableHeadProps) => { + const { sort: activeSort, dir } = resolveLeadershipListSort(state) + const active = activeSort === sortKey + const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultLeadershipListSortDir(sortKey) + + return ( + + ) : null + } + > + {children ?? leadershipListSortLabels[sortKey]} + + ) +} + +/** Filter-only column head (Municípios / Acesso) — no sort control. */ +export const LeadershipFilterHead = ({ + state, + filterParam, + children, + options, + description, +}: { + state: LeadershipListState + filterParam: LeadershipFilterParam + children: ReactNode + options?: LeadershipFilterOption[] + description?: ReactNode +}) => ( + } + > + {children} + +) diff --git a/src/components/campaign/shared/CampaignTable.tsx b/src/components/campaign/shared/CampaignTable.tsx index 5c40d047..da4e07c4 100644 --- a/src/components/campaign/shared/CampaignTable.tsx +++ b/src/components/campaign/shared/CampaignTable.tsx @@ -59,43 +59,68 @@ export type CampaignTableColumn = { * information, so the wrapping `` is a deliberate new tab * stop; the `` itself stays non-interactive. Without `description` the * render is byte-for-byte what it was before B22. + * + * Optional `filter` mirrors `CampaignSortableHead`'s slot for filter-only + * columns (no sort control) so domain wrappers don't re-copy the tooltip chrome. */ export const CampaignTableHead = ({ align = 'left', className, children, description, + filter, }: { align?: 'left' | 'center' | 'right' className?: string children?: ReactNode description?: ReactNode -}) => ( - - {description ? ( - - { + const label = description ? ( + + + {children} + + + ) : filter ? ( + {children} + ) : ( + children + ) + + return ( + + {filter ? ( +
- {children} - - - ) : ( - children - )} - -) + {label} + {filter} +
+ ) : ( + label + )} +
+ ) +} type CampaignTableProps = { columns: Array> diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 961d20e4..6c63775b 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -31,10 +31,14 @@ export const leadershipSupportStatuses = [ ] as const export type SupportStatus = (typeof leadershipSupportStatuses)[number] +export type LeadershipSector = (typeof leadershipSectors)[number] export const isSupportStatus = (value: unknown): value is SupportStatus => typeof value === 'string' && leadershipSupportStatuses.some((status) => status === value) +export const isLeadershipSector = (value: unknown): value is LeadershipSector => + typeof value === 'string' && leadershipSectors.some((sector) => sector === value) + export const MAX_LEADERSHIP_MUNICIPALITIES = 30 const MAX_LEADERSHIP_ORGANIZATIONS = 20 export const MAX_LEADERSHIP_STATE_DEPUTIES = 20 diff --git a/src/utilities/leadershipData.ts b/src/utilities/leadershipData.ts index 64ba28e4..2a1b6235 100644 --- a/src/utilities/leadershipData.ts +++ b/src/utilities/leadershipData.ts @@ -7,17 +7,15 @@ import { isSupportStatus } from '@/lib/schemas/leadership' import type { CampaignUser, Leadership } from '@/payload-types' import { isCampaignStaff } from '@/utilities/campaignAccess' import { - buildListHref, - firstValue, - normalizedText, - strictDecimalInteger, - type RawSearchParams, -} from '@/utilities/campaignListUrl' + buildLeadershipListWhere, + leadershipPageSize, + resolveLeadershipListPayloadSort, + resolveLeadershipListSort, + type LeadershipListState, +} from '@/utilities/leadershipListUrl' import { relationshipId } from '@/utilities/relationship' import { loadStateDeputySummaries, type StateDeputySummary } from '@/utilities/stateDeputyData' -const leadershipPageSize = 25 - export type LeadershipRowViewModel = { id: number contactID: number @@ -168,70 +166,79 @@ export const loadFreshestMunicipalityLeaderships = async ( } } -export type LeadershipListState = { - page: number - q?: string +export type LeadershipListFilterFacets = { + /** Municipality ids present under the other active filters (cross-filtered). */ + municipalityIDs: number[] } -export const parseLeadershipListParams = (searchParams: RawSearchParams): LeadershipListState => { - const q = normalizedText(firstValue(searchParams.q)) +/** + * Respects every OTHER filter (search, status, sector, access) but drops the + * municipality filter itself — same contract as `loadStateDeputyPartyFacet`: + * a selected id is unioned in so it stays visible to undo. + */ +const loadLeadershipMunicipalityFacet = async ( + payload: Payload, + user: CampaignUser, + state: LeadershipListState, +): Promise => { + const result = await payload.find({ + collection: 'leadership', + where: buildLeadershipListWhere({ ...state, municipalities: undefined }), + depth: 0, + limit: 0, + pagination: false, + select: { municipalities: true }, + user, + overrideAccess: false, + }) + + const available = new Set(state.municipalities ?? []) + for (const doc of result.docs) { + for (const municipality of doc.municipalities ?? []) { + const id = relationshipId(municipality) + if (id !== null) available.add(id) + } + } + return { - page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1, - ...(q ? { q } : {}), + municipalityIDs: [...available].sort((left, right) => left - right), } } -const buildLeadershipListSearchParams = ( - state: LeadershipListState, - page = state.page, -): URLSearchParams => { - const params = new URLSearchParams() - if (state.q) params.set('q', state.q) - if (page > 1) params.set('page', String(page)) - return params -} - -export const buildLeadershipListHref = (state: LeadershipListState, page: number): string => - buildListHref(state, buildLeadershipListSearchParams, '/campanha/liderancas', page) - export const loadLeadershipListPageData = async ( payload: Payload, user: CampaignUser, state: LeadershipListState, -): Promise<{ rows: LeadershipRowViewModel[]; totalDocs: number; totalPages: number }> => { - if (!isCampaignStaff(user)) return { rows: [], totalDocs: 0, totalPages: 0 } - - let contactFilter: { contact: { in: number[] } } | null = null - if (state.q) { - // Names live on Contact — resolve matching contact ids first. - const contacts = await payload.find({ - collection: 'contact', - where: { name: { contains: state.q } }, - depth: 0, - limit: 200, - pagination: false, - select: { name: true }, - overrideAccess: true, - }) - contactFilter = { contact: { in: contacts.docs.map((contact) => contact.id) } } - if (contacts.docs.length === 0) return { rows: [], totalDocs: 0, totalPages: 0 } +): Promise<{ + rows: LeadershipRowViewModel[] + totalDocs: number + totalPages: number + filterFacets: LeadershipListFilterFacets +}> => { + if (!isCampaignStaff(user)) { + return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: { municipalityIDs: [] } } } - const result = await payload.find({ - collection: 'leadership', - where: contactFilter ?? {}, - depth: 1, - limit: leadershipPageSize, - page: state.page, - sort: '-updatedAt', - user, - overrideAccess: false, - }) + const { sort, dir } = resolveLeadershipListSort(state) + const [result, filterFacets] = await Promise.all([ + payload.find({ + collection: 'leadership', + where: buildLeadershipListWhere(state), + depth: 1, + limit: leadershipPageSize, + page: state.page, + sort: resolveLeadershipListPayloadSort(sort, dir), + user, + overrideAccess: false, + }), + loadLeadershipMunicipalityFacet(payload, user, state), + ]) return { rows: await toLeadershipRows(payload, result.docs as Leadership[]), totalDocs: result.totalDocs, totalPages: result.totalPages, + filterFacets, } } diff --git a/src/utilities/leadershipLabels.ts b/src/utilities/leadershipLabels.ts index b8c2da62..4842cc10 100644 --- a/src/utilities/leadershipLabels.ts +++ b/src/utilities/leadershipLabels.ts @@ -10,3 +10,8 @@ export const supportStatusLabels: Record = { em_disputa: 'Em disputa', negativo: 'Negativo', } + +export const leadershipAccessFilterLabels: Record<'com' | 'sem', string> = { + com: 'Com acesso', + sem: 'Sem acesso', +} diff --git a/src/utilities/leadershipListFilters.ts b/src/utilities/leadershipListFilters.ts new file mode 100644 index 00000000..9d33e712 --- /dev/null +++ b/src/utilities/leadershipListFilters.ts @@ -0,0 +1,174 @@ +/** + * Leadership list filter affordances: Status / Setor / Município / Acesso + * toggles, active-filter summary and clear-filters. + */ +import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership' +import { strictDecimalInteger, truncatedNamesLabel } from '@/utilities/campaignListUrl' +import { leadershipAccessFilterLabels, supportStatusLabels } from '@/utilities/leadershipLabels' +import { + buildLeadershipListHref, + leadershipListStateToRawParams, + parseLeadershipListParams, + type LeadershipListState, +} from '@/utilities/leadershipListUrl' +import { leadershipSectorLabels } from '@/utilities/leadershipUi' + +export type LeadershipFilterParam = 'supportStatus' | 'sector' | 'municipality' | 'access' + +export type LeadershipFilterOption = { + value: string + label: string +} + +type LeadershipMultiFilterParam = 'status' | 'sector' | 'municipality' + +export const leadershipStatusFilterOptions: LeadershipFilterOption[] = + leadershipSupportStatuses.map((value) => ({ value, label: supportStatusLabels[value] })) + +export const leadershipSectorFilterOptions: LeadershipFilterOption[] = leadershipSectors.map( + (value) => ({ value, label: leadershipSectorLabels[value] }), +) + +export const leadershipAccessFilterOptions: LeadershipFilterOption[] = ( + Object.keys(leadershipAccessFilterLabels) as Array +).map((value) => ({ value, label: leadershipAccessFilterLabels[value] })) + +const withLeadershipListPageReset = (state: LeadershipListState): LeadershipListState => + parseLeadershipListParams(leadershipListStateToRawParams({ ...state, page: 1 }, 1)) + +/** + * Writes the RAW param and lets `parseLeadershipListParams` validate, so no + * branch has to cast a `string[]` into the state's narrow value types. + */ +const setLeadershipMultiFilterValues = ( + state: LeadershipListState, + param: LeadershipMultiFilterParam, + values: string[], +): LeadershipListState => + parseLeadershipListParams({ + ...leadershipListStateToRawParams({ ...state, page: 1 }, 1), + [param]: values, + }) + +const getLeadershipMultiFilterValues = ( + state: LeadershipListState, + param: LeadershipMultiFilterParam, +): string[] => { + if (param === 'status') return state.statuses ?? [] + if (param === 'sector') return state.sectors ?? [] + return (state.municipalities ?? []).map(String) +} + +const toggleLeadershipMultiFilterValue = ( + state: LeadershipListState, + param: LeadershipMultiFilterParam, + value: string, +): LeadershipListState => { + const current = getLeadershipMultiFilterValues(state, param) + return setLeadershipMultiFilterValues( + state, + param, + current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value], + ) +} + +export const toggleLeadershipStatusFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => toggleLeadershipMultiFilterValue(state, 'status', value) + +export const clearLeadershipStatusFilter = (state: LeadershipListState): LeadershipListState => + setLeadershipMultiFilterValues(state, 'status', []) + +export const toggleLeadershipSectorFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => toggleLeadershipMultiFilterValue(state, 'sector', value) + +export const clearLeadershipSectorFilter = (state: LeadershipListState): LeadershipListState => + setLeadershipMultiFilterValues(state, 'sector', []) + +export const toggleLeadershipMunicipalityFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => { + if (strictDecimalInteger(value) === undefined) return state + return toggleLeadershipMultiFilterValue(state, 'municipality', value) +} + +export const clearLeadershipMunicipalityFilter = ( + state: LeadershipListState, +): LeadershipListState => setLeadershipMultiFilterValues(state, 'municipality', []) + +/** Exclusive toggle — selecting the active value clears; selecting the other replaces. */ +export const toggleLeadershipAccessFilter = ( + state: LeadershipListState, + value: string, +): LeadershipListState => { + if (value !== 'com' && value !== 'sem') return state + return withLeadershipListPageReset({ + ...state, + access: state.access === value ? undefined : value, + }) +} + +export const clearLeadershipAccessFilter = (state: LeadershipListState): LeadershipListState => + withLeadershipListPageReset({ ...state, access: undefined }) + +/** Drop every filter and the search; keep the sort. */ +export const clearLeadershipListFilters = (state: LeadershipListState): LeadershipListState => ({ + page: 1, + sort: state.sort, + dir: state.dir, +}) + +export const buildLeadershipFilterHref = (next: LeadershipListState): string => + buildLeadershipListHref(next, 1) + +export const isLeadershipColumnFilterActive = ( + state: LeadershipListState, + filterParam: LeadershipFilterParam, +): boolean => { + switch (filterParam) { + case 'supportStatus': + return Boolean(state.statuses?.length) + case 'sector': + return Boolean(state.sectors?.length) + case 'municipality': + return Boolean(state.municipalities?.length) + case 'access': + return Boolean(state.access) + } +} + +const municipalityLabel = ( + id: number, + labels?: ReadonlyMap | Readonly>, +): string => { + if (!labels) return `Município #${id}` + if (labels instanceof Map) return labels.get(id) ?? `Município #${id}` + return labels[id] ?? `Município #${id}` +} + +export const formatLeadershipActiveFiltersSummary = ( + state: LeadershipListState, + municipalityLabelsById?: ReadonlyMap | Readonly>, +): string | null => { + const parts: string[] = [] + if (state.statuses?.length) { + parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status]))) + } + if (state.sectors?.length) { + parts.push(truncatedNamesLabel(state.sectors.map((sector) => leadershipSectorLabels[sector]))) + } + if (state.municipalities?.length) { + parts.push( + truncatedNamesLabel( + state.municipalities.map((id) => municipalityLabel(id, municipalityLabelsById)), + ), + ) + } + if (state.access) parts.push(leadershipAccessFilterLabels[state.access]) + if (state.q) parts.push(`Busca "${state.q}"`) + return parts.length ? parts.join(' · ') : null +} diff --git a/src/utilities/leadershipListUrl.ts b/src/utilities/leadershipListUrl.ts new file mode 100644 index 00000000..ee45fd3f --- /dev/null +++ b/src/utilities/leadershipListUrl.ts @@ -0,0 +1,245 @@ +/** + * Leadership list URL contract: state, param parse/canonicalize, Payload + * `where`, sort and hrefs. Own module (same shape as `stateDeputyListUrl`) — + * does not import municipality/territory list modules. + */ +import type { Where } from 'payload' + +import { + leadershipSectors, + leadershipSupportStatuses, + type SupportStatus, +} from '@/lib/schemas/leadership' +import { + allParamValues, + buildListHref, + firstValue, + normalizedText, + parseExhaustiveEnumParam, + resolveListUrl, + strictDecimalInteger, + type RawSearchParams, +} from '@/utilities/campaignListUrl' + +export const leadershipPageSize = 25 + +export type LeadershipListSortKey = 'name' | 'supportStatus' | 'sector' | 'updatedAt' +export type LeadershipListSortDirection = 'asc' | 'desc' + +/** Acesso ao app — exclusive filter (com | sem), same shape as municipality coverage. */ +export type LeadershipListAccessFilter = 'com' | 'sem' + +type LeadershipSector = (typeof leadershipSectors)[number] + +export type LeadershipListState = { + page: number + q?: string + statuses?: SupportStatus[] + sectors?: LeadershipSector[] + municipalities?: number[] + access?: LeadershipListAccessFilter + sort?: LeadershipListSortKey + dir?: LeadershipListSortDirection +} + +export type LeadershipListSearchParams = RawSearchParams + +const leadershipListParamNames = [ + 'q', + 'status', + 'sector', + 'municipality', + 'access', + 'sort', + 'dir', + 'page', +] as const +const leadershipListParamNameSet = new Set(leadershipListParamNames) + +const DEFAULT_LEADERSHIP_LIST_SORT_KEY: LeadershipListSortKey = 'updatedAt' +const DEFAULT_LEADERSHIP_LIST_SORT_DIR: LeadershipListSortDirection = 'desc' + +export const leadershipListSortLabels: Record = { + name: 'Nome', + supportStatus: 'Status', + sector: 'Setor', + updatedAt: 'Última atualização', +} + +const leadershipListSortKeySet = new Set(Object.keys(leadershipListSortLabels)) +const leadershipListSortDirSet = new Set(['asc', 'desc']) +const supportStatusSet = new Set(leadershipSupportStatuses) +const sectorSet = new Set(leadershipSectors) +const accessFilterSet = new Set(['com', 'sem']) + +/** Temporal = newest first; categorical/name = A–Z. */ +export const defaultLeadershipListSortDir = ( + key: LeadershipListSortKey, +): LeadershipListSortDirection => (key === 'updatedAt' ? 'desc' : 'asc') + +export const resolveLeadershipListSort = ( + state: LeadershipListState, +): { sort: LeadershipListSortKey; dir: LeadershipListSortDirection } => { + const sort = state.sort ?? DEFAULT_LEADERSHIP_LIST_SORT_KEY + return { sort, dir: state.dir ?? defaultLeadershipListSortDir(sort) } +} + +/** Dotted path for Contact name join; `-` prefix for descending. */ +export const resolveLeadershipListPayloadSort = ( + sort: LeadershipListSortKey, + dir: LeadershipListSortDirection, +): string => { + const path = sort === 'name' ? 'contact.name' : sort + return dir === 'desc' ? `-${path}` : path +} + +export const formatLeadershipListSortSummary = ( + sort: LeadershipListSortKey, + dir: LeadershipListSortDirection, +): string => { + const label = leadershipListSortLabels[sort] + if (sort === 'updatedAt') { + return dir === 'desc' + ? `Ordenado por ${label} (mais recente)` + : `Ordenado por ${label} (mais antigo)` + } + return dir === 'asc' ? `Ordenado por ${label} (A–Z)` : `Ordenado por ${label} (Z–A)` +} + +export const leadershipListStateToRawParams = ( + state: LeadershipListState, + page = state.page, +): LeadershipListSearchParams => ({ + page: String(page), + q: state.q, + status: state.statuses, + sector: state.sectors, + municipality: state.municipalities?.map(String), + access: state.access, + sort: state.sort, + dir: state.dir, +}) + +export const parseLeadershipListParams = ( + params: LeadershipListSearchParams, +): LeadershipListState => { + const rawPage = strictDecimalInteger(firstValue(params.page)) + const q = normalizedText(firstValue(params.q)) + const statuses = parseExhaustiveEnumParam(params.status, supportStatusSet) + const sectors = parseExhaustiveEnumParam(params.sector, sectorSet) + const municipalities = allParamValues(params.municipality) + .map((token) => strictDecimalInteger(token)) + .filter((id): id is number => typeof id === 'number' && id > 0) + const rawAccess = firstValue(params.access) + const access = + rawAccess && accessFilterSet.has(rawAccess as LeadershipListAccessFilter) + ? (rawAccess as LeadershipListAccessFilter) + : undefined + const rawSort = firstValue(params.sort) + const sort = + rawSort && leadershipListSortKeySet.has(rawSort) + ? (rawSort as LeadershipListSortKey) + : undefined + const rawDir = firstValue(params.dir) + const dir = + rawDir && leadershipListSortDirSet.has(rawDir as LeadershipListSortDirection) + ? (rawDir as LeadershipListSortDirection) + : undefined + + return { + page: rawPage ?? 1, + ...(q ? { q } : {}), + ...(statuses.length ? { statuses } : {}), + ...(sectors.length ? { sectors } : {}), + ...(municipalities.length ? { municipalities } : {}), + ...(access ? { access } : {}), + ...(sort ? { sort } : {}), + ...(dir ? { dir } : {}), + } +} + +export const buildLeadershipListWhere = (state: LeadershipListState): Where => { + const filters: Where[] = [] + if (state.q) filters.push({ 'contact.name': { contains: state.q } }) + if (state.statuses?.length) filters.push({ supportStatus: { in: state.statuses } }) + if (state.sectors?.length) filters.push({ sector: { in: state.sectors } }) + if (state.municipalities?.length) { + filters.push({ municipalities: { in: state.municipalities } }) + } + if (state.access) { + filters.push({ user: { exists: state.access === 'com' } }) + } + return filters.length ? { and: filters } : {} +} + +/** Expects already-canonical state (from parse or a rule-preserving toggle). */ +export const serializeCanonicalLeadershipListSearchParams = ( + canonicalState: LeadershipListState, +): URLSearchParams => { + const params = new URLSearchParams() + const resolvedSort = canonicalState.sort ?? DEFAULT_LEADERSHIP_LIST_SORT_KEY + const resolvedDir = canonicalState.dir ?? defaultLeadershipListSortDir(resolvedSort) + const isSortDefault = + resolvedSort === DEFAULT_LEADERSHIP_LIST_SORT_KEY && + resolvedDir === DEFAULT_LEADERSHIP_LIST_SORT_DIR + + if (canonicalState.q) params.set('q', canonicalState.q) + for (const status of canonicalState.statuses ?? []) params.append('status', status) + for (const sector of canonicalState.sectors ?? []) params.append('sector', sector) + for (const municipality of canonicalState.municipalities ?? []) { + params.append('municipality', String(municipality)) + } + if (canonicalState.access) params.set('access', canonicalState.access) + if (!isSortDefault) { + params.set('sort', resolvedSort) + if (resolvedDir !== defaultLeadershipListSortDir(resolvedSort)) { + params.set('dir', resolvedDir) + } + } + if (canonicalState.page > 1) params.set('page', String(canonicalState.page)) + + return params +} + +const buildLeadershipListSearchParams = ( + state: LeadershipListState, + page = state.page, +): URLSearchParams => + serializeCanonicalLeadershipListSearchParams( + parseLeadershipListParams(leadershipListStateToRawParams(state, page)), + ) + +export const buildLeadershipListHref = (state: LeadershipListState, page: number): string => + buildListHref(state, buildLeadershipListSearchParams, '/campanha/liderancas', page) + +export const buildLeadershipSortHref = ( + state: LeadershipListState, + nextKey: LeadershipListSortKey, +): string => { + const current = resolveLeadershipListSort(state) + const dir = + current.sort === nextKey + ? current.dir === 'asc' + ? 'desc' + : 'asc' + : defaultLeadershipListSortDir(nextKey) + + return buildLeadershipListHref({ ...state, sort: nextKey, dir, page: 1 }, 1) +} + +export const resolveLeadershipListUrl = ( + params: LeadershipListSearchParams, + totalPages?: number, +): { + state: LeadershipListState + href: string + redirectHref?: string +} => + resolveListUrl({ + params, + paramNameSet: leadershipListParamNameSet, + parse: parseLeadershipListParams, + buildSearchParams: buildLeadershipListSearchParams, + basePath: '/campanha/liderancas', + totalPages, + }) diff --git a/src/utilities/stateDeputyListUrl.ts b/src/utilities/stateDeputyListUrl.ts index 8700e58b..da9575b1 100644 --- a/src/utilities/stateDeputyListUrl.ts +++ b/src/utilities/stateDeputyListUrl.ts @@ -2,8 +2,8 @@ * State deputy ("dobradinha") list URL contract: state type, param * parsing/canonicalization, Payload `where`, sort resolution and hrefs. * Mirrors `municipalityListUrl.ts` (paginated, `resolveListUrl` clamp) and - * `territoryListUrl.ts` (lean sort-key/label shape) without importing either - * (B29's `leadershipListUrl.ts` doesn't exist yet — nothing to mirror there). + * `territoryListUrl.ts` / `leadershipListUrl.ts` (lean sort-key/label shape) + * without importing either. */ import type { Where } from 'payload' diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index d6c63943..61be74e6 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -52,6 +52,7 @@ describe('loadLeadershipListPageData', () => { municipalityIDs: [municipality.id], hasAppAccess: false, }) + expect(result.filterFacets.municipalityIDs).toContain(municipality.id) }) it('short-circuits to an empty page when no contact matches the query', async () => { @@ -63,7 +64,12 @@ describe('loadLeadershipListPageData', () => { q: fixtures.value('sem-resultado'), }) - expect(result).toEqual({ rows: [], totalDocs: 0, totalPages: 0 }) + expect(result).toEqual({ + rows: [], + totalDocs: 0, + totalPages: 0, + filterFacets: { municipalityIDs: [] }, + }) }) it('returns an empty page for leaders (staff guard, no throw)', async () => { @@ -72,7 +78,173 @@ describe('loadLeadershipListPageData', () => { const result = await loadLeadershipListPageData(payload, leader, { page: 1 }) - expect(result).toEqual({ rows: [], totalDocs: 0, totalPages: 0 }) + expect(result).toEqual({ + rows: [], + totalDocs: 0, + totalPages: 0, + filterFacets: { municipalityIDs: [] }, + }) + }) + + it('filters by supportStatus, sector, access and municipality', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipalityA = await fixtures.getMunicipality() + const municipalityB = await fixtures.getMunicipality() + const marker = fixtures.value('lead-filter') + + const matchContact = await fixtures.createContact({ name: `Ana ${marker}` }) + const missContact = await fixtures.createContact({ name: `Bruno ${marker}` }) + const match = await fixtures.createLeadership({ + contact: matchContact.id, + municipalities: [municipalityA.id], + supportStatus: 'a_abordar', + sector: 'religioso', + }) + await fixtures.createLeadership({ + contact: missContact.id, + municipalities: [municipalityB.id], + supportStatus: 'engajado', + sector: 'sindical', + }) + + const byStatus = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + statuses: ['a_abordar'], + }) + expect(byStatus.rows.map((row) => row.id)).toEqual([match.id]) + + const bySector = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + sectors: ['religioso'], + }) + expect(bySector.rows.map((row) => row.id)).toEqual([match.id]) + + const byMunicipality = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + municipalities: [municipalityA.id], + }) + expect(byMunicipality.rows.map((row) => row.id)).toEqual([match.id]) + + const withoutAccess = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + access: 'sem', + }) + expect(withoutAccess.totalDocs).toBe(2) + expect(withoutAccess.rows.every((row) => !row.hasAppAccess)).toBe(true) + + const withAccess = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + access: 'com', + }) + expect(withAccess.totalDocs).toBe(0) + }) + + it('sorts by contact.name in both directions via the dotted join path', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const marker = fixtures.value('lead-sort') + const alpha = await fixtures.createContact({ name: `Aaa ${marker}` }) + const omega = await fixtures.createContact({ name: `Zzz ${marker}` }) + const first = await fixtures.createLeadership({ + contact: alpha.id, + municipalities: [municipality.id], + }) + const last = await fixtures.createLeadership({ + contact: omega.id, + municipalities: [municipality.id], + }) + + const asc = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + sort: 'name', + dir: 'asc', + }) + expect(asc.rows.map((row) => row.id)).toEqual([first.id, last.id]) + + const desc = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + sort: 'name', + dir: 'desc', + }) + expect(desc.rows.map((row) => row.id)).toEqual([last.id, first.id]) + }) + + it('keeps the municipality facet reachable under other filters and unions the selection', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipalityA = await fixtures.getMunicipality() + const municipalityB = await fixtures.getMunicipality() + const marker = fixtures.value('lead-facet') + await fixtures.createLeadership({ + contact: (await fixtures.createContact({ name: `A ${marker}` })).id, + municipalities: [municipalityA.id], + supportStatus: 'a_abordar', + }) + await fixtures.createLeadership({ + contact: (await fixtures.createContact({ name: `B ${marker}` })).id, + municipalities: [municipalityB.id], + supportStatus: 'engajado', + }) + + const unfiltered = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + }) + expect(unfiltered.filterFacets.municipalityIDs).toEqual( + expect.arrayContaining([municipalityA.id, municipalityB.id]), + ) + + const filtered = await loadLeadershipListPageData(payload, coordinator, { + page: 1, + q: marker, + statuses: ['a_abordar'], + municipalities: [municipalityB.id], + }) + // Own filter ignored for the facet — A stays; B stays because it is selected. + expect(filtered.filterFacets.municipalityIDs).toEqual( + expect.arrayContaining([municipalityA.id, municipalityB.id]), + ) + }) + + it('does not leak out-of-scope leaderships to an advisor even with a foreign municipality filter', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const inScope = await fixtures.getMunicipality() + const outOfScope = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(inScope.id, [advisor.id]) + + const marker = fixtures.value('lead-scope') + const visible = await fixtures.createLeadership({ + contact: (await fixtures.createContact({ name: `In ${marker}` })).id, + municipalities: [inScope.id], + }) + await fixtures.createLeadership({ + contact: (await fixtures.createContact({ name: `Out ${marker}` })).id, + municipalities: [outOfScope.id], + }) + + const foreignFilter = await loadLeadershipListPageData(payload, advisor, { + page: 1, + q: marker, + municipalities: [outOfScope.id], + }) + expect(foreignFilter.totalDocs).toBe(0) + expect(foreignFilter.rows).toEqual([]) + + const scoped = await loadLeadershipListPageData(payload, advisor, { + page: 1, + q: marker, + }) + expect(scoped.rows.map((row) => row.id)).toEqual([visible.id]) }) }) diff --git a/tests/unit/campaignEntityListParsers.unit.spec.ts b/tests/unit/campaignEntityListParsers.unit.spec.ts index fdec9cdf..7a14d650 100644 --- a/tests/unit/campaignEntityListParsers.unit.spec.ts +++ b/tests/unit/campaignEntityListParsers.unit.spec.ts @@ -1,22 +1,17 @@ /** * Pins the "simple" entity list parsers ahead of the Pass 2 list-system - * consolidation (W1): lideranças, organizações, demandas and assessores. - * Their URL contracts must survive the migration byte-identical. Dobradinhas - * left this group in B33 — its parser grew a full sort/filter contract of its - * own and moved to `stateDeputyListUrl.ts`, pinned in - * `stateDeputyListUrl.unit.spec.ts`. + * consolidation (W1): organizações, demandas and assessores. Lideranças left + * this group in B29 — its parser grew a full sort/filter contract of its own + * and moved to `leadershipListUrl.ts`, pinned in + * `leadershipListUrl.unit.spec.ts`. Dobradinhas left earlier in B33. */ import { describe, expect, it } from 'vitest' import { advisorListHrefForPage, parseAdvisorListParams } from '@/utilities/advisorData' import { parseDemandListParams } from '@/utilities/campaignDemandData' -import { parseLeadershipListParams } from '@/utilities/leadershipData' import { parseOrganizationListParams } from '@/utilities/organizationData' -const qAndPageParsers = [ - ['leadership', parseLeadershipListParams], - ['advisor', parseAdvisorListParams], -] as const +const qAndPageParsers = [['advisor', parseAdvisorListParams]] as const describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => { it('defaults to page 1 without q', () => { diff --git a/tests/unit/leadershipListUrl.unit.spec.ts b/tests/unit/leadershipListUrl.unit.spec.ts new file mode 100644 index 00000000..385c1e30 --- /dev/null +++ b/tests/unit/leadershipListUrl.unit.spec.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' + +import { + clearLeadershipAccessFilter, + clearLeadershipListFilters, + clearLeadershipMunicipalityFilter, + clearLeadershipStatusFilter, + formatLeadershipActiveFiltersSummary, + toggleLeadershipAccessFilter, + toggleLeadershipMunicipalityFilter, + toggleLeadershipStatusFilter, +} from '@/utilities/leadershipListFilters' +import { + buildLeadershipListHref, + buildLeadershipSortHref, + parseLeadershipListParams, + resolveLeadershipListPayloadSort, + resolveLeadershipListSort, + resolveLeadershipListUrl, + serializeCanonicalLeadershipListSearchParams, +} from '@/utilities/leadershipListUrl' + +describe('leadership list URL contract', () => { + it('defaults to page 1 with no sort/filters', () => { + expect(parseLeadershipListParams({})).toEqual({ page: 1 }) + }) + + it('validates page as a positive decimal integer', () => { + expect(parseLeadershipListParams({ page: '4' }).page).toBe(4) + expect(parseLeadershipListParams({ page: '0' }).page).toBe(1) + expect(parseLeadershipListParams({ page: '-2' }).page).toBe(1) + expect(parseLeadershipListParams({ page: 'abc' }).page).toBe(1) + }) + + it('trims q, omits it when empty and takes the first repeated cell', () => { + expect(parseLeadershipListParams({ q: ' ana ' }).q).toBe('ana') + expect(parseLeadershipListParams({ q: ' ' }).q).toBeUndefined() + expect(parseLeadershipListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro') + }) + + it('parses and dedupes known status/sector values and integer municipality ids', () => { + expect( + parseLeadershipListParams({ + status: ['engajado', 'a_abordar', 'engajado', 'unknown'], + sector: ['religioso', 'clube', 'sindical'], + municipality: ['12', '12', '0', 'abc', '3'], + }), + ).toEqual({ + page: 1, + statuses: ['engajado', 'a_abordar'], + sectors: ['religioso', 'sindical'], + municipalities: [12, 3], + }) + }) + + it('canonicalizes selecting every status/sector member to the absent filter (B18)', () => { + expect( + parseLeadershipListParams({ + status: ['engajado', 'a_abordar', 'em_disputa', 'negativo'], + sector: [ + 'religioso', + 'sindical', + 'comunitario', + 'rural', + 'empresarial', + 'juventude', + 'saude', + 'educacao', + 'cultura', + 'outro', + ], + }), + ).toEqual({ page: 1 }) + }) + + it('keeps only known access / sort / dir tokens', () => { + expect(parseLeadershipListParams({ access: 'sem', sort: 'name', dir: 'asc' })).toEqual({ + page: 1, + access: 'sem', + sort: 'name', + dir: 'asc', + }) + expect(parseLeadershipListParams({ access: 'maybe', sort: 'phone', dir: 'sideways' })).toEqual({ + page: 1, + }) + }) + + it('omits the default sort pair (updatedAt+desc) from the canonical query', () => { + const state = parseLeadershipListParams({ sort: 'updatedAt', dir: 'desc' }) + expect(serializeCanonicalLeadershipListSearchParams(state).toString()).toBe('') + expect(resolveLeadershipListSort(state)).toEqual({ sort: 'updatedAt', dir: 'desc' }) + }) + + it('serializes a non-default sort pair and repeated filters', () => { + const state = parseLeadershipListParams({ + q: 'ana', + status: ['a_abordar'], + sector: ['religioso'], + municipality: ['7'], + access: 'sem', + sort: 'name', + dir: 'asc', + }) + expect(buildLeadershipListHref(state, 1)).toBe( + '/campanha/liderancas?q=ana&status=a_abordar§or=religioso&municipality=7&access=sem&sort=name', + ) + }) + + it('preserves filters while toggling sort direction and resets to page 1', () => { + const state = parseLeadershipListParams({ q: 'ana', page: '3' }) + + expect(buildLeadershipSortHref(state, 'name')).toBe('/campanha/liderancas?q=ana&sort=name') + expect(buildLeadershipSortHref({ ...state, sort: 'name', dir: 'asc' }, 'name')).toContain( + 'dir=desc', + ) + }) + + it('resolves the payload sort string with the contact.name join and descending prefix', () => { + expect(resolveLeadershipListPayloadSort('name', 'asc')).toBe('contact.name') + expect(resolveLeadershipListPayloadSort('name', 'desc')).toBe('-contact.name') + expect(resolveLeadershipListPayloadSort('updatedAt', 'desc')).toBe('-updatedAt') + expect(resolveLeadershipListPayloadSort('supportStatus', 'asc')).toBe('supportStatus') + }) + + it('clamps an out-of-range page and redirects non-canonical query strings', () => { + expect(resolveLeadershipListUrl({ page: '9' }, 2)).toEqual({ + state: { page: 2 }, + href: '/campanha/liderancas?page=2', + redirectHref: '/campanha/liderancas?page=2', + }) + expect(resolveLeadershipListUrl({ q: ' ana ', unknown: 'x' })).toEqual({ + state: { page: 1, q: 'ana' }, + href: '/campanha/liderancas?q=ana', + redirectHref: '/campanha/liderancas?q=ana', + }) + }) +}) + +describe('leadership list filter state', () => { + it('toggles status and municipality filters and resets the page', () => { + const withStatus = toggleLeadershipStatusFilter({ page: 3 }, 'engajado') + expect(withStatus).toEqual({ page: 1, statuses: ['engajado'] }) + expect(toggleLeadershipStatusFilter(withStatus, 'engajado')).toEqual({ page: 1 }) + + const withMunicipality = toggleLeadershipMunicipalityFilter({ page: 2 }, '15') + expect(withMunicipality).toEqual({ page: 1, municipalities: [15] }) + expect(toggleLeadershipMunicipalityFilter(withMunicipality, '15')).toEqual({ page: 1 }) + }) + + it('rejects padded municipality ids that strictDecimalInteger would drop', () => { + expect(toggleLeadershipMunicipalityFilter({ page: 1 }, '01')).toEqual({ page: 1 }) + expect(toggleLeadershipMunicipalityFilter({ page: 1 }, '1.5')).toEqual({ page: 1 }) + }) + + it('toggles access exclusively and clears only that filter', () => { + const withAccess = toggleLeadershipAccessFilter({ page: 1 }, 'sem') + expect(withAccess).toEqual({ page: 1, access: 'sem' }) + expect(toggleLeadershipAccessFilter(withAccess, 'sem')).toEqual({ page: 1 }) + expect(toggleLeadershipAccessFilter(withAccess, 'com')).toEqual({ page: 1, access: 'com' }) + expect(clearLeadershipAccessFilter(withAccess)).toEqual({ page: 1 }) + }) + + it('clears only one filter, keeping q and sort', () => { + expect( + clearLeadershipStatusFilter({ + page: 2, + q: 'ana', + statuses: ['engajado'], + sort: 'name', + dir: 'asc', + }), + ).toEqual({ page: 1, q: 'ana', sort: 'name', dir: 'asc' }) + expect( + clearLeadershipMunicipalityFilter({ + page: 1, + municipalities: [1, 2], + access: 'sem', + }), + ).toEqual({ page: 1, access: 'sem' }) + }) + + it('clears every filter and the search, keeping sort', () => { + expect( + clearLeadershipListFilters({ + page: 4, + q: 'ana', + statuses: ['engajado'], + sort: 'sector', + dir: 'asc', + }), + ).toEqual({ page: 1, sort: 'sector', dir: 'asc' }) + }) + + it('summarizes active filters with municipality labels when provided', () => { + expect( + formatLeadershipActiveFiltersSummary( + { + page: 1, + statuses: ['a_abordar'], + access: 'sem', + q: 'maria', + municipalities: [9], + }, + new Map([[9, 'Feira de Santana']]), + ), + ).toBe('A abordar · Feira de Santana · Sem acesso · Busca "maria"') + }) +})