diff --git a/.cursor/rules/campanha-edit-where-you-see.mdc b/.cursor/rules/campanha-edit-where-you-see.mdc index bda62e75..fcec306c 100644 --- a/.cursor/rules/campanha-edit-where-you-see.mdc +++ b/.cursor/rules/campanha-edit-where-you-see.mdc @@ -13,13 +13,15 @@ When adding or changing a **staff** (coordinator / advisor) view that displays m 1. **Default to in-context edit** if the role can update that field — list cell, detail card, queue row — not a dead readout that only links to `/editar`. 2. **Reuse existing server actions** and the **same access** as the full form. Thin route wrappers OK; do not invent a generic quick-edit service (<3 call sites → named controls). 3. **UX pattern:** named Popover / select / short number input per affordance; touch `min-h-11`. **Auto-save** on select change, blur, or short debounce — no “Salvar” button on single-field / in-context edits. Show pending / saved / error on the control (Feel the action). Then server revalidate / RSC refresh. + - **Exception — relation-by-chips cells** (B34, 2026-07-27): a column whose value is a set of related records (municípios de uma liderança, carteira de um assessor) may mount its editor inline, always, **with a fine pointer** — chips with a hover/focus-revealed remove button plus a trailing "add" input, no Popover trigger and no table-wide "Editar" toggle. On any other pointer the same cell is a read-only trigger that opens a **Drawer** for editing, because a coarse pointer scrolling a table cannot be asked to distinguish "tap to navigate" from "tap to delete". This is the only sanctioned always-mounted input; single-value fields keep the Popover. 4. **Keep `/editar` (or long Sheet) + explicit submit** only for multi-field strategy forms, rare fields, and flows that need a note, confirmation, or legal consent. 5. **Never** expose staff-only fields to `leader`. View models stay role-split. ## Anti-goals - Full-row spreadsheet / data-grid mode -- Always-mounted inputs on every list row +- A table-wide "Editar" toggle that flips every column into inputs +- Always-mounted inputs on every list row — except the relation-by-chips cell of rule 3 - Generic `EditableCell` / contentEditable mass edit - Optimistic UI that skips server refresh when the list is source of truth - Save buttons on Popovers / quick-edit cells “just in case” diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index d41e0b34..c7bd5509 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -29,6 +29,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - URL: `utilities/campaignListUrl.ts` (parse/canonicalize/`resolveListUrl`) + módulo por domínio (`municipalityListUrl.ts`, `supporterUi.ts`, `activityUi.ts`, parsers nos `*Data.ts`). Contrato de URL congelado (B18). - Tabela: `shared/CampaignTable.tsx` — colunas como dado (`id`, `head`, `cell`, seams `mandatory`/`defaultVisible` p/ B17); heads ricos = ilhas (`municipality/MunicipalitySortableHead` + `MunicipalityHeaderFilter`). - Shells: `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPagination`, pending compartilhado em `CampaignListPending.tsx` (dima o resultado, não o controle). +- Relação por chips numa célula: `shared/MunicipalityPortfolioCell` (B34) — sempre montada em `pointer-fine`, Drawer em `pointer-coarse`; costuras `minItems`/`maxItems`/`addableIds`/`draft`/`commitAction`. Serve lideranças e assessores; não escrever uma terceira. Lógica pura em `lib/municipalityPortfolio.ts`, índice em `utilities/municipalityPortfolioIndex.ts`. - Exceções documentadas: atividades = cards; `TerritoryOverviewTable` = sort no cliente; comparação de candidatos, LeaderContacts e preview de import ficam fora. - Detalhe por tabs: `utilities/detailTabUi.ts` (factory) + sections RSC colocadas na rota (`municipios/[slug]/MunicipalityDetailTabs.tsx`, `atividades/[slug]/ActivityOverviewTab.tsx`). diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 2b3415b0..f6038e41 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -230,3 +230,4 @@ alwaysApply: false - **B7 entregue (2026-07-21):** mapa em `/campanha/municipios` usa `buildMunicipalityListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**. - **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador). - **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). +- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) segue com Popover próprio e é ele — não o B37 — o gatilho de extração de um editor de chips agnóstico. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md). diff --git a/AGENTS.md b/AGENTS.md index d558977b..b80e8628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,10 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. **Recently resolved (2026-07-27):** **E13** Planejador de presença/giros — visit eligibility as **five conditions ✓/—** (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and **never** blocking; the calendar phase (`construcao|consolidacao|ativacao`) changes the suggested "produto da visita". Pure math in `src/utilities/visitEligibility.ts`, cut points in `src/lib/visitPlannerAnchors.ts` (illustrative, on the `territorialClassAnchors.ts` precedent; the phase turns at midnight in Bahia via the new `formatBahiaCivilDate`), candidates grouped by identity territory in `src/utilities/visitPlannerData.ts` (`server-only`). Surfaces: an "Elegibilidade para visita" card on the municipality `OverviewTab`, and a composer at **`/campanha/atividades/giros`** (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft `activity` rows in one transaction. **Three plan premises died in the pre-implementation audit and should not come back:** `kind: 'visita'` never shipped with C12 and is **not** wanted — `activity.deputyPresent` already IS the candidate-presence marker, so a giro is derivable from `deputyPresent` + território + date window and each stop keeps its real kind; `allocationDecision` still has no application write path, so the "não vá" is advisory text and recording an override is **E11**'s first job; and "satélites contíguos" was never possible — `bahiaTerritories.ts` carries membership only (polygon adjacency exists solely inside the build scripts), so **encaixe = same TI**, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into `municipalityPotential.ts` (memoized), so the card and the "Classe" column cannot compute the same number twice; and `createTourDraftActivitiesRecord` re-reads every stop's município id with `overrideAccess: false` **inside** the transaction and aborts if one is missing, because `activity` create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no `Consent`, no persisted `tour` entity. Plano: [`docs/plans/planejador-de-giros.md`](docs/plans/planejador-de-giros.md). + +**Recently resolved (2026-07-27):** **B34** Chips editáveis de municípios na lista de lideranças — the "Municípios" column of `/campanha/liderancas` was `municipalityNames.join(', ')`, dead text with no link; it is now chips that navigate to `/campanha/municipios/[slug]`, with a territory chip (`MapPinIcon` + count) when the leadership covers a whole TI, and it is **editable in place**. **The decision that generalizes beyond this item: what picks inline vs. drawer is the `pointer:` media query, not viewport width.** On `pointer-fine` the editor is always mounted — the "×" sits at the chip's corner revealed on hover/focus (absolutely positioned, so revealing it moves nothing) and a search input trails the chip row; on `pointer-coarse` a tap anywhere in the cell opens a **Drawer** with 44 px targets, because a thumb scrolling a table must not be asked to distinguish "open the município" from "delete the link". A tablet with a mouse gets the inline editor; a touch laptop gets the Drawer. This **amends** the anti-goal "always-mounted inputs on every list row" (`PRODUCT.md` principle 3 + `.cursor/rules/campanha-edit-where-you-see.mdc`) **for chip-based relations on fine pointers** — the input IS the readout there, and a per-row Popover would add a click to a target already on screen. It does **not** reopen B28/B31/B32: nothing here is a table mode, and `AdvisorsTable`'s toggle was renamed **"Editar nome e contato"**, which is all it still unlocks. Engineering, all divergences from the plan measured: `AdvisorMunicipalityCell` **was** promoted to `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` (the plan rejected it — but the `ResizeObserver` + "Ver mais…" machinery it called dead weight became the requirement the moment the cell was always visible), and `/campanha/assessores` migrated onto the shared piece in the same PR, so the 2nd call site was born with the 1st; **no context provider** (the catalog index and `addableIds` travel as props of the column factory the RSC builds once per table); **one batch action only** — `setLeadershipMunicipalitiesMembership` (repeated `municipalityIds` + `assigned`), the single-id twin never existed, and the advisor twin (`setAdvisorMunicipalityMembershipRecord` + its schema) was **deleted** with its int tests migrated to the batch path; `LeadershipRowViewModel.municipalityNames` → `municipalityIDs: number[]` (names/slugs resolve client-side against the catalog the cell already holds); the floor of 1 uses `aria-disabled` + the reason in a toast rather than `disabled`, because `disabled` drops the button from the tab order and the reason lived only in its `aria-label`; the cap of 30 is refused client-side **before** the optimistic apply, so a TI batch does not bounce back whole with a message that never says which limit blew. **`/impeccable` closed two real P0/P1s:** suggestions were mouse-only (now a full ARIA 1.2 combobox — arrows/Home/End/Enter, `aria-activedescendant`, portaled listbox with its own name, "Nenhum resultado." kept **outside** the listbox) and auto-save was mute (`aria-busy` + dimmed row + `sr-only` live region, rollback scoped to the failed delta so a neighbouring saved toggle is not reverted, plus **undo** on batch removal); target size of the "×" went 16 → **24 px** via `before:-inset-1` (SC 2.5.8 has no mouse exemption, and the 44 px Drawer equivalent is `pointer-fine:hidden`), the "×" border got real contrast (the token's is 1.17:1 on a `secondary` chip), and the chips are links **inside the Drawer** again. **`/simplify` caught two real bugs the gate could not see**, both in the same shape — state captured by the render that scheduled the work. Every optimistic delta is now applied **functionally** (`withDelta(current ?? municipalityIds, …)`), because the success path computed from the `assignedIds` of its own render: the toast's "Desfazer", pressed after a later add, dropped that add from the UI while the server kept it, and the reconcile effect only clears the optimistic value when both id lists match exactly, so the divergence outlived every later revalidation. And the `saved`/`errorMessage` pair was **sticky**, so from the first save onward the live region said "Municípios salvos." forever and never announced the result count again — the one thing an ARIA combobox needs; they collapsed into one `feedback`, cleared on typing, with an explicit priority (pending > error > searching > saved). Also: the pointer policy had been encoded **twice** (a `matchMedia('(pointer: fine)')` in the root `onClick` next to the `pointer-*` variants — the coarse overlay now stops propagation and the policy lives only in CSS); `MunicipalityPortfolioIndexEntry.city`/`zoneNumber` were read by **nothing** (TI/ZE resolution runs against the static catalog by slug) and were shipping in 435 entries of two routes' RSC payload; the advisor route's local `parseMunicipalityIds` (which swallowed invalid ids silently) gave way to the shared `repeatedRelationshipFormValues`; the advisor-scope refusal became `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE` (a bare literal in 4 files, matched by `safeMessages` on exact string); the membership helper returns `{ next, added }` instead of letting the caller re-derive `added` for the scope check; and `onPersisted` died because `setAdvisorMunicipalitiesBatch` already revalidates. **One fix was created by the simplification itself:** unifying the suggestion gate would have opened the inline list — which is **portaled**, so `pointer-coarse:hidden` cannot reach it — on top of the Drawer, hence separate `inlineSuggesting` / `drawerSuggesting`, which also closes the pre-existing case of opening the Drawer with the inline popover already up. + +**Latent bug fixed for free:** `CampaignTableHead`'s `description` seam (**B22**) broke **any** RSC caller — `campaignHoverTooltipAlign`/`campaignHoverExplanationClassName` lived in the `'use client'` module of `CampaignHoverTooltip`, so the server was calling a client reference; they moved to `src/lib/campaignHoverTooltip.ts`, and the leaderships list is the first RSC header with an explanation. **Post-merge `/simplify` follow-up (same day), all measured:** the perf pass cut real CPU — `buildMunicipalityPortfolioChips` 4.2 → **0.78 ms** cold per 25-row table and **0.043 ms** on every later render (a `WeakMap` keyed on the index array, which one RSC builds once and all rows share, so it survives re-renders where a per-row `useMemo` would not), and search 4.59 → **0.077 ms** per keystroke (normalize the query once instead of inside the matcher for each of ~660 candidates; `matchesNormalizedAtWordStart` is the new shared entry point and `matchesAtWordStart` delegates to it). Also: `sameIdSet` replaced a hand-rolled sorted-key comparison (3rd call site of a single-purpose helper), `scrollIntoView` is keyboard-only (the mouse is already where it points — it was a forced layout per option the pointer crossed), the Drawer body is gated on `drawerOpen` (it was allocated on every row including the fine pointer where it never opens), and `revalidatePath` now uses the real delta (`changed`), so removing a município that was never linked no longer busts its route. **One reviewer suggestion was rejected by measurement, and it looked obvious:** dropping `sort: 'name'` from the loader ("nothing depends on the DB ordering") **broke the suggestion list** — `searchMunicipalityPortfolio` walks the index in arrival order and stops at `limit`, so the alphabetical suggestions were alphabetical only because of that `ORDER BY`. The B34 e2e caught it; the sort is back with the reason written beside it. **The finding that did NOT get fixed, because both fixes oppose each other:** the delivery costs **+58 kB** First Load JS on `/campanha/liderancas` (246 → 304) and **+42 kB** on `/campanha/assessores` (253 → 295), measured with two full builds against a stashed clean `main`. Assessores already imported the static Bahia catalog, so its +42 kB is the **cell**, not the catalog — only ~16 kB of lideranças' delta is the catalog reaching a new route. Pulling the catalog out of the browser fattens the payload of every chip toggle; shrinking the payload to `{id,slug}` keeps the bundle. Registered as **B34+** with the recommendation (payload-minimal) and its one real caveat. No migration, collection, `Consent` or URL change. **Debt with a trigger:** `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover and measuring machinery in the same table — **it**, not B37, is the extraction trigger for a relation-agnostic chip editor (B37 plugs the existing shared cell into a third relation without new machinery). **Measured, and now P1 in the ledger:** two e2e tests fail **deterministically at one worker on a clean `main` tree** (verified by stashing this whole delivery) — `campaignNearestMunicipality:156` and `campaignMunicipalities:394`. That is not the fichado 2-worker flake; do not dismiss a red `pnpm test:e2e` as flakiness without re-checking those two by name. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](docs/plans/chips-municipios-lista-liderancas.md). **Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md). **Recently resolved (2026-07-27):** **E14** Níveis de envolvimento N0–N4 — `municipality.engagementLevel` (staff-only select `n0…n4`) records **what the campaign decided to invest** in a município, next to the E8/E10 metrics that describe what it knows. It is also the **first production writer of `allocationDecision`** (C12 shipped 2026-07-24 with access and tests but no caller). Migration `20260727_161752_add_municipality_engagement_level` adds `engagementLevel` (indexed, **no default**), `levelNote` (textarea 2000) and `levelChangedAt` (indexed date, `readOnly`, stamped by a `beforeChange` hook only when the level actually changes — and cleared when the level is), plus the value `movimento` on the `allocationDecision.outcome` enum, because a level move is neither `aceita` nor `descarta` and pretending otherwise would poison E11's triage. **No backfill:** the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it **is** the triage queue. Moving a município is a reallocation decision, so writes are `isCampaignUnrestricted` only (`canManageMunicipalityEngagementLevel` in `utilities/access/municipalities.ts`); the advisor reads it and proposes through a signal. `setMunicipalityEngagementLevelRecord` takes the advisory lock `municipality-engagement-level:{id}` **before** reading the current level (otherwise the snapshot's `from` is a guess under concurrency) and writes both documents in one transaction, with `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe `src/lib/engagementLevel.ts` (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from `levelChangedAt` alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. **The route has three states, not two:** `blocked` (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. **Explicit submit, not auto-save** — the exception `campanha-edit-where-you-see` already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine **B32+** will audit. On the list: an 11th staff column with a numbered outline badge, plus filter `?level=`, which — unlike E10's derived `classe` — is **native**, because the field is stored and reaches the `where`; "sem nível" cannot ride inside an `in` and becomes an `or` branch with `{ exists: false }`. **The sort is not native**, though `n0…n4` does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". `nivel` joins `applyDerivedMunicipalitySort` and reuses `sortByNullableValue`, which sinks the absent level in both directions — the same treatment `sem_base` gets in `classe`. New E18 concept `nivel-de-envolvimento`. **Worth knowing:** with `overrideAccess: false` Payload **silently drops** a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did **not** change rather than expecting a throw. Plano: [`docs/plans/niveis-de-envolvimento.md`](docs/plans/niveis-de-envolvimento.md). diff --git a/PRODUCT.md b/PRODUCT.md index 75e9d622..ab7f9aec 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -42,7 +42,7 @@ Not generic SaaS dashboards (purple gradients, hero-metric templates, identical 1. **Sovereignty as the horizon** — own audience, data, and ops over time; Big Tech shortcuts (WhatsApp kits, share links) are fine for now when they help mobilization, but design should keep the campaign’s own records and workflows as the source of truth and leave room to reduce platform dependency later. 2. **Clarity under pressure** — every screen earns its keep for field use; prefer plain language and obvious next actions over decoration. -3. **Edit where you see** — on `/campanha` staff surfaces (coordinator / advisor), mutable data that the role can update should be editable in context whenever it fits (list cell, detail card, queue row), so assessors and the general coordinator do not burn time navigating to a separate edit screen for every correction. Prefer named Popovers / selects / short inputs that reuse existing server actions and the same access rules as the full form. Keep dedicated `/editar` (or long Sheets) for multi-field strategy forms, rare fields, and anything that needs a note or confirmation flow. Leadership paths stay read-or-declare as already scoped — never expose staff-only fields. Anti-goals: full-row spreadsheet / data-grid mode; always-mounted inputs on every row; a second generic “editable cell” design system; optimistic _writes of list/aggregate results_ that skip server refresh when the list is the source of truth (optimistic control state is fine — see Feel the action). +3. **Edit where you see** — on `/campanha` staff surfaces (coordinator / advisor), mutable data that the role can update should be editable in context whenever it fits (list cell, detail card, queue row), so assessors and the general coordinator do not burn time navigating to a separate edit screen for every correction. Prefer named Popovers / selects / short inputs that reuse existing server actions and the same access rules as the full form. Keep dedicated `/editar` (or long Sheets) for multi-field strategy forms, rare fields, and anything that needs a note or confirmation flow. **One sanctioned exception to “no always-mounted inputs”:** a cell whose value is a _set_ of related records (chips) may be editable inline at all times where the pointer is fine — chips remove on a hover-revealed “×”, a trailing input adds — and becomes a read-only trigger opening a Drawer on coarse pointers, where scrolling and deleting share the same gesture. Leadership paths stay read-or-declare as already scoped — never expose staff-only fields. Anti-goals: full-row spreadsheet / data-grid mode; a table-wide “Editar” toggle over every column; always-mounted inputs on single-value fields; a second generic “editable cell” design system; optimistic _writes of list/aggregate results_ that skip server refresh when the list is the source of truth (optimistic control state is fine — see Feel the action). 4. **Auto-save, no Save button** — editable fields should persist automatically whenever it is safe (on select change, blur, or short debounce — pick what fits the control). Do not require an explicit “Salvar” / Save button for single-field or in-context edits. Pair with Feel the action: optimistic control state + honest pending / saved / error feedback on the field itself. Keep an explicit submit only when the write must be atomic across several fields, needs a note/confirmation, or is a legal consent / irreversible flow. Anti-goals: silent auto-save with no status; saving on every keystroke for free-text without debounce; Save buttons on Popovers and quick-edit cells “just in case.” 5. **Depth and simplicity as peers** — staff workflows can be rich; leadership paths stay light and phone-first without becoming a second-class product. 6. **Intelligence serves organization** — electoral baseline and insights exist to decide where to act, not to impress. The product delivers intelligence, not a fancy spreadsheet: it computes the bulk of the analysis (vigilance across 435 municipalities, structural differential diagnosis, a prioritized decision queue with suggested action menus) so staff spend judgment, not arithmetic. Decision metrics are **relative and local** — coverage of per-municipality goals by auditable pledges, capture of the campo's majoritarian ceiling, quantiles/LQ of the candidate's own distribution — never absolute statewide % (a DF seat costs ~80–150k votes; the statewide record is ~5% of valid votes, so absolute scales flatten into noise). Suggestions are grounded in `docs/research/` (approved discovery, 25 data→decision patterns) and are always a menu for a human: the product never auto-decides, and each decision — including the discarded alternative reading — is recorded ex-ante for the post-election backtest. Anti-goals: raw-count KPIs, 0–100% fixed choropleth scales, "available votes" reservoirs from abstention/null tallies, suggestion-as-rule. diff --git a/docs/TECH-DEBT.md b/docs/TECH-DEBT.md index dfb85de0..67e30c3b 100644 --- a/docs/TECH-DEBT.md +++ b/docs/TECH-DEBT.md @@ -4,27 +4,31 @@ Ledger of known engineering debt. Items large enough to be scheduled work become ## Debt Ledger -| Item | Location | Type | Risk | Effort | Priority | Status | Ref | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | -| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | -| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | open 2026-07-26 (found during B8 F2) | | -| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | -| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 | -| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | -| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | | -| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | -| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | | -| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | -| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | | -| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | -| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | -| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | -| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 | -| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | -| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f | -| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | | -| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ | +| Item | Location | Type | Risk | Effort | Priority | Status | Ref | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b | +| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | | +| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. The outage was transient, so treat this row as a watch item — if exit 2 returns, the SCA gap (incl. `shpjs`) reopens | | +| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | | +| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 | +| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | | +| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | | +| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 | +| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | | +| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | | +| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | | +| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | | +| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | | +| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | | +| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | | +| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 | +| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | | +| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f | +| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | | +| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ | +| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation | B34 | +| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse` | B34+ | +| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 | ## Smell Inventory diff --git a/docs/plans/chips-municipios-lista-liderancas.md b/docs/plans/chips-municipios-lista-liderancas.md index f8044dc2..773b64ea 100644 --- a/docs/plans/chips-municipios-lista-liderancas.md +++ b/docs/plans/chips-municipios-lista-liderancas.md @@ -1,15 +1,39 @@ # Chips editáveis de municípios na lista de lideranças (paridade com Assessores) -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue (2026-07-27) +Atualizado em: 2026-07-27 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B34**) Impeccable: B — encaixe na coluna "Municípios" de `/campanha/liderancas`; sem rota nova Appetite: ~1–1,25 dia eng — promoção de lib/loader puros existentes, 1 componente de célula novo (Popover + Command), 2 server actions + schema, provider de catálogo; sem migration Responsável: — -## ⚠️ Conflito com o pedido literal — leia antes de implementar +## Entregue (2026-07-27) — as-built -O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma tabela** (`/campanha/liderancas`) já travou três vezes a decisão oposta: **B28** ("edição in-list adiada"), **B31** ("paridade é da coluna, não do modo... o toggle 'Editar' de tabela inteira... é o anti-goal literal do princípio 3 do `PRODUCT.md` já travado no B28") e **B32** ("sem depender do toggle 'Editar' de tabela inteira, anti-goal nomeado explicitamente no pedido e já travado no B28/B31"). Este plano **segue o precedente travado**, não o texto literal do pedido — ver "Decisões travadas" abaixo. Todas as demais funcionalidades pedidas (chips, navegação, remover, adicionar por digitação, território/ZE em lote) estão contempladas; só o **container** da edição muda de "modo de tabela" para "Popover por célula". _(proposto — validar com produto antes de implementar; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32 também.)_ +**O conflito foi resolvido a favor do pedido literal, emendando o anti-goal em vez de reabrir B28/B31/B32.** A coluna é **sempre editável em ponteiro fino** (`pointer-fine`): "×" no canto do chip só no hover/foco (posicionado em `absolute`, para revelar não mexer no tamanho do chip nem na linha) e input de busca no fim da linha de chips. Em **ponteiro grosso** (`pointer-coarse`) o toque em qualquer ponto da célula abre um **Drawer** com "×" de 44 px — um polegar rolando a tabela não pode ser convidado a distinguir "abrir o município" de "apagar o vínculo". O que decide não é largura de viewport, é a mídia `pointer:` (um tablet com mouse recebe o editor inline; um laptop touch recebe o Drawer). `PRODUCT.md` (princípio 3) e `.cursor/rules/campanha-edit-where-you-see.mdc` passaram a admitir input sempre montado **para relações por chips em `pointer-fine`**: o input É o readout, e um Popover por linha seria um clique a mais para um alvo que já está na tela. Nada aqui virou "modo de tabela" — o toggle de `AdvisorsTable` foi renomeado para **"Editar nome e contato"**, que é tudo o que ele ainda destrava depois desta entrega. + +**Divergências do plano abaixo (que fica como registro do rascunho):** + +- **`AdvisorMunicipalityCell` FOI promovido** a `src/components/campaign/shared/MunicipalityPortfolioCell.tsx`, ao contrário do que este plano decidiu. A inversão vem do container: com a célula sempre visível, a máquina de `ResizeObserver` + "Ver mais…" que o plano chamou de peso morto passou a ser exatamente o requisito. Costuras da peça compartilhada: `minItems` (piso), `maxItems` (teto), `addableIds` (escopo do assessor), `draft`/`onDraftChange` (linha nova de assessor), `commitAction`, `drawerTitle`. `/campanha/assessores` migrou no mesmo PR — o 2º call site nasceu junto, não depois. +- **Sem provider de contexto.** O índice do catálogo e `addableIds` viajam por props da factory de colunas (`leadershipColumns`), que o RSC monta uma vez por tabela — o provider resolveria um problema de payload por linha que a factory já não tem. +- **Uma única action de lote.** `setLeadershipMunicipalitiesMembership` recebe `municipalityIds` repetidos + `assigned`; a variante de um id só nunca nasceu, e a gêmea de assessor (`setAdvisorMunicipalityMembershipRecord` + `advisorMunicipalityMembershipSchema`) foi **deletada**, com os int tests que a exercitavam migrados para a de lote — testar o caminho que a produção usa vale mais do que preservar dois caminhos. +- **`LeadershipRowViewModel.municipalityNames` → `municipalityIDs: number[]`** (não o `municipalities: {id,name,slug}[]` planejado): a célula já recebe o catálogo completo, então nome e slug se resolvem no cliente e o loader deixa de repetir 435 strings por linha. +- **Piso de 1 com `aria-disabled` + razão no toast**, não `disabled`: `disabled` tira o botão da ordem de tabulação e a razão só existia no `aria-label` dele, ou seja, ficava inalcançável justamente para quem navega por teclado. Mesma regra no Drawer. +- **Teto de 30 recusado no cliente antes do apply otimista** — um lote de TI que estouraria o teto voltava inteiro depois do round-trip, com uma mensagem que não dizia qual limite tinha estourado; a sugestão que estouraria já aparece esmaecida com "excede o limite". +- **Undo em remoção de lote:** remover um chip de TI/ZE apaga até 30 vínculos num gesto e não deixa rastro na linha; a remoção múltipla ganha toast com "Desfazer" (a de um município só não — está a uma busca de voltar). + +**`/impeccable critique` — P0/P1 fechados nesta entrega:** (1) as sugestões eram **só de mouse** — agora o input é um combobox ARIA 1.2 de verdade (setas/Home/End/Enter, `aria-activedescendant`, primeira opção pré-selecionada, listbox portalizado com nome próprio e o "Nenhum resultado." **fora** do listbox, onde não conta como opção selecionável); (2) o auto-save era **mudo** — `aria-busy` + linha esmaecida + live region `sr-only` ("Salvando…"/"Salvos."/contagem de resultados), com rollback **só do delta que falhou** (reverter para o baseline apagaria um toggle vizinho já salvo); (3) alvo do "×" de 16 px → **24 px** de área de ponteiro via `before:-inset-1` (SC 2.5.8 não tem exceção de mouse, e a equivalente de 44 px do Drawer é `pointer-fine:hidden`), borda do círculo com contraste real (a do token media 1,17:1 sobre o chip `secondary`) e "Ver mais…" com `min-h-6`; (4) o overlay de ponteiro grosso cobria os links dos chips — os chips voltaram a ser links **dentro do Drawer**. + +**Bug latente corrigido de graça:** o seam `description` de `CampaignTableHead` (**B22 ✓**) quebrava **qualquer** chamador RSC (`Attempted to call campaignHoverTooltipAlign() from the server`) porque `campaignHoverTooltipAlign` e `campaignHoverExplanationClassName` moravam no módulo `'use client'` do `CampaignHoverTooltip`, e um `'use client'` transforma todo export em referência de cliente. Foram para `src/lib/campaignHoverTooltip.ts`; até aqui o seam só tinha chamadores client (`MunicipalitySortableHead`, `TerritorySortableHead`), então nunca havia sido exercido de um server component. + +**Gate:** `tsc --noEmit`, `pnpm lint` (0 warnings), `format:check`, `knip` (só o erro pré-existente de `payload.config.ts`), `check:cycles` (0 ciclos), unit + int (53 arquivos / 428 testes), e2e de lideranças/municípios/territórios verde (o e2e novo cobre adicionar por clique, **adicionar por teclado**, piso e persistência), Aikido 0 findings. + +**`/simplify` — o que a revisão de qualidade mudou depois do gate:** dois bugs reais e uma dívida de payload. (1) **Todo delta passa a ser funcional**, ida e volta (`withDelta(current ?? municipalityIds, …)`): o caminho de sucesso computava a partir do `assignedIds` do render que o capturou, então o "Desfazer" do toast, pressionado depois de um novo chip, apagava esse chip da tela sem apagá-lo no servidor — e o efeito de reconciliação só limpa o otimista quando as listas batem exatamente, então a divergência sobrevivia a todo revalidate seguinte. (2) **`saved`/`errorMessage` eram grudentos** e sequestravam a live region: a partir do primeiro save a região dizia "Municípios salvos." para sempre e nunca mais anunciava a contagem de resultados que o combobox precisa. Viraram um `feedback` só, limpo ao digitar, com prioridade explícita (pendente > erro > busca > sucesso). (3) A política de ponteiro estava **codificada duas vezes** — um `matchMedia('(pointer: fine)')` no `onClick` da raiz; o overlay grosso agora faz `stopPropagation` e a decisão volta a viver só nas media queries. (4) `MunicipalityPortfolioIndexEntry.city`/`zoneNumber` **não eram lidos por ninguém** (a resolução de TI/ZE roda contra o catálogo estático por slug) e viajavam nas 435 entradas do payload RSC de duas rotas: saíram do tipo e do `select`. (5) `parseMunicipalityIds` local de assessores (que engolia id inválido em silêncio) deu lugar ao `repeatedRelationshipFormValues` compartilhado, que é o que lideranças já usava; a mensagem de escopo do assessor virou `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE` (era literal solta em 4 arquivos, e `safeMessages` casa por string exata); os dois guardas de "lista vazia" morreram porque o zod já os cobre. (6) O helper de membership devolve `{ next, added }` — o caller recomputava `added` para o teste de escopo, duas expressões que precisavam concordar. (7) `onPersisted={refresh}` saiu: `setAdvisorMunicipalitiesBatch` já chama `revalidatePath`, e o seam ficou sem consumidor. **Uma correção nasceu da própria simplificação:** ao unificar o gate das sugestões, a lista inline (que é **portalizada**) passaria a abrir junto com o Drawer, onde `pointer-coarse:hidden` não a alcança — daí `inlineSuggesting` e `drawerSuggesting` separados, que também fecham o caso pré-existente de abrir o Drawer com o popover inline já aberto. + +**Débitos registrados:** `LeadershipStateDeputyRelationCell` (**B31 ✓**) segue com Popover + máquina de medição próprios e "Ver mais…" de 16 px — é ele, não o B37, o gatilho de extração de um editor de chips agnóstico; `AdvisorsTable` ainda mistura um modo de edição (nome/e-mail/celular) com uma coluna sempre viva; e o `/simplify` mediu que **dois e2e falham de forma determinística a 1 worker numa árvore `main` limpa** (`campaignNearestMunicipality:156`, `campaignMunicipalities:394`) — não é o flake de 2 workers já fichado, e um gate cronicamente vermelho é como uma falha real embarca (P1 no ledger). + +## Rascunho original (2026-07-25) — conflito registrado antes da decisão + +O pedido de produto pedia um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`), enquanto a mesma tabela já havia travado a decisão oposta três vezes em 2026-07-25 (**B28**, **B31**, **B32**). O rascunho seguia o precedente (Popover por célula) e marcava a decisão como proposta a validar com produto; a validação aconteceu em 2026-07-27 e escolheu o caminho descrito acima. Tudo abaixo é o rascunho como estava. ## Design (Impeccable) diff --git a/docs/plans/escala-dry-pos-b34.md b/docs/plans/escala-dry-pos-b34.md new file mode 100644 index 00000000..cee4d05a --- /dev/null +++ b/docs/plans/escala-dry-pos-b34.md @@ -0,0 +1,73 @@ +# B34+ — Escala e DRY pós-B34 + +Fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓** (chips editáveis de municípios na lista de lideranças). Entrega-mãe: [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md). + +Três achados dos relatórios foram aplicados **no próprio fechamento do B34** e não voltam aqui: campos mortos no índice RSC (`city`/`zoneNumber`), o parser divergente de `FormData` em `/campanha/assessores`, e a mensagem de seleção vazia declarada duas vezes. + +## Já resolvido no fechamento (medido, não estimado) + +O passe de perf localizado que fechou o B34 rendeu, com bancada em `tsx` sobre o catálogo real (435 entradas, tabela de 25 linhas): + +| Caminho | Antes | Depois | Como | +| ------------------------------------ | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------- | +| `buildMunicipalityPortfolioChips` | 4,2 ms | **0,78 ms** | `WeakMap` por array de índice (`portfolioIndexDerivations`) + curto-circuito `territoryIds.length > remaining.size` | +| idem, em re-render (índice quente) | 4,2 ms | **0,043 ms** | o `WeakMap` sobrevive entre renders; um `useMemo` por linha não sobreviveria | +| `searchMunicipalityPortfolio`, tecla | 4,59 ms | **0,077 ms** | query normalizada **uma vez** + rótulos pré-normalizados (`matchesNormalizedAtWordStart`) | + +Também entraram: `sameIdSet` no lugar do comparador de chaves ordenadas escrito à mão (3º call site do helper), `scrollIntoView` restrito a navegação por teclado (o mouse já está onde aponta — era um layout forçado por opção percorrida), corpo do `Drawer` atrás de `drawerOpen` (era alocado em toda linha, inclusive no ponteiro fino onde nunca abre), e o `revalidatePath` passou a usar o delta real (`changed`) em vez de tudo que foi pedido — remover um município que já não estava vinculado não busta mais a rota dele. + +**Um item do relatório foi rejeitado por medição, e vale registrar porque parecia óbvio:** remover o `sort: 'name'` do loader ("nada depende da ordem do banco") **quebrou a lista de sugestões**. `searchMunicipalityPortfolio` percorre o índice na ordem em que ele chega e para no `limit`, então a ordem alfabética das sugestões era a ordem do `ORDER BY` — sem ele o usuário passa a receber os 12 primeiros casamentos em ordem física de linha. O e2e do B34 pegou (o teste digita um prefixo e aperta Enter no primeiro item), e o `sort` voltou com o motivo escrito ao lado. + +## F1 — Regressão de First Load JS nas duas listas (prioritário) + +**Medido** contra `main` limpo (stash com `-u`, dois `pnpm build` completos, números como o Next reporta — minificados, não gzip): + +| Rota | main | B34 | Δ | +| ---------------------- | ------ | ---------- | ---------- | +| `/campanha/liderancas` | 246 kB | **304 kB** | **+58 kB** | +| `/campanha/assessores` | 253 kB | **295 kB** | **+42 kB** | + +A decomposição importa e ninguém deve re-derivá-la: `/campanha/assessores` **já** importava o catálogo estático da Bahia antes do B34 (via `advisorMunicipalityPortfolio.ts`, o módulo que virou `municipalityPortfolio.ts`), então os +42 kB de lá são a **célula em si** — Drawer, links, o contrato de combobox, a máquina de medição. Os +16 kB restantes em lideranças são o catálogo (`municipalityCatalog` + `bahiaTerritories` + `bahiaTseZones`, ~40 kB min / ~12,9 kB gzip) chegando a uma rota que não o tinha. É exatamente o custo que o **B14 ✓** já havia medido e nomeado (importar um serializador de URL no cliente custou 21 kB por um link). + +**Duas direções, e elas se opõem — escolher, não fazer as duas:** + +- **(a) Catálogo fora do browser.** O índice RSC passa a carregar `tseZones` e a célula deixa de importar `municipalityCatalog`/`bahiaTseZones`. Ganha ~12,9 kB gzip de bundle em duas rotas; **paga** mais payload em todo render _e em todo toggle de chip_ (a action revalida, o RSC re-serializa o índice inteiro). +- **(b) Payload mínimo.** O loader devolve só `{ id, slug }` (~13 kB crus contra ~45 kB) e nome/região/cidade saem do catálogo, que já está no bundle. Ganha em toda interação; mantém o bundle como está. + +O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago uma vez e fica em cache imutável, o payload é pago a cada toggle. A ressalva registrada é honesta: `buildMunicipalityPortfolioChips` hoje prefere de propósito o `region` **do banco** à resolução por slug do catálogo, "para os chips colaparem mesmo com divergência de alias entre ambientes". Essa justificativa é fraca — `municipalityCatalog` é snapshot-testado contra o catálogo semeado (`tests/fixtures/municipality-catalog.snapshot.json`), que é precisamente o guard que transforma divergência em teste vermelho — mas foi uma decisão deliberada, então a troca precisa de uma chamada explícita, não de um corte silencioso. + +Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas. + +## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada + +`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**. + +A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro. + +## F3 — Um só delta de membership + +`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um. + +No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra. + +**Não** estender ao lado do assessor: `setAdvisorMunicipalitiesBatchRecord` percorre a relação **inversa** (`municipality.advisors`), com N locks / N leituras / N updates sob bypass admin. Um helper cobrindo os dois teria de ser parametrizado por "de que lado da aresta eu escrevo", que é a fontanaria genérica que o repo já rejeitou uma vez. + +## F4 — Duas formas de view model para uma célula + +`leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo. + +## F5 — Combobox à mão onde o kit já embrulha um + +São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo. + +## F6 — Baixos + +- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? : ` está escrito duas vezes (~14 linhas) nas duas superfícies. O resto da divergência entre elas é a tese da entrega (16 px revelado no hover contra 44 px sempre visível) e deve continuar divergindo. +- **e2e**: `createStaffLeadership({ role, municipalities })` e `expectPostResponse(page, fragment)` em `campaignE2EFixtures.ts` — 3 call sites hoje. +- **`relationIds()`** exportado de `tests/helpers/campaignFixtures.ts`: `typeof entry === 'number' ? entry : entry.id` está reescrito em 9 lugares nos int specs, com `src/utilities/relationship.ts` já exportando o equivalente de produção. Débito anterior; o spec novo é a 9ª instância. +- **`loadMunicipalityPortfolioIndex` fora da escada de cache**: leitura do catálogo inteiro, de campos só-geografia, idêntica para todo ator e toda request, sem `cache()` nem `unstable_cache`. Uma chamada por request hoje, então o degrau 1 não compra nada — mas o degrau 2 é a resposta da escada. Decidir, não deixar por omissão. +- **Duas respostas para "quais ids formam o TI T?"**: resolvido no fechamento (busca e colapso agora leem o mesmo `idsByTerritory` do índice vivo), mas `municipalityIdsForTseZone` continua sendo o gêmeo de um laço que resolve slugs pelo `bySlug`. Colapsa em `municipalityIdsForEntries(entries, bySlug)` quando alguém tocar o arquivo. + +## Fora de escopo (verificado, não é duplicação) + +`runCampaignFormAction` está corretamente usado; `matchesAtWordStart`/`normalizeSearchPhrase` reusados sem normalizador paralelo; **nenhum 4º call site** da máquina de auto-save por debounce foi criado (o único `setTimeout` da célula é um deferral de 120 ms em `onBlur`, não um save — o débito **B32+** segue com seus 3+1 call sites); `CampaignListPendingBoundary` não se aplica (a célula dispara uma action, não uma navegação); a detecção de ponteiro não ignorou nenhum hook (`use-mobile.ts` é por largura, e escolher `pointer:` é a tese do B34); e a extração de `campaignHoverTooltip` é a correção certa de um bug de RSC real, não duplicação. diff --git a/docs/roadmap.md b/docs/roadmap.md index cccb82ac..11e484ba 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); **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-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) · [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) @@ -115,8 +115,9 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**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) -- **B34** chips editáveis de municípios na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas`, a coluna "Municípios" (hoje `row.municipalityNames.join(', ')`, texto morto sem link) ganha **chips**: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon`) aparece quando a liderança cobre o TI inteiro, e um ícone de edição ao fim da célula abre um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` com busca acento-insensível por município/território/ZE — adicionar/remover grava **na hora**, sem "Salvar", incluindo lote de território/ZE inteiro (paridade explicitamente pedida com `/campanha/assessores`, **B19 ✓**). **⚠️ Conflito registrado com o pedido literal:** o pedido pede um **botão de "modo de edição"** de tabela inteira, espelhando o toggle de `AdvisorsTable` — mas essa **mesma tabela** já travou a decisão oposta três vezes no mesmo dia (**B28**, **B31 ✓**, **B32**: "paridade é da coluna, não do modo... toggle Editar de tabela inteira é o anti-goal literal do princípio 3"). Este plano segue o precedente (Popover por célula, não toggle) e marca a decisão como _proposta — validar com produto_ antes de implementar. Diferença estrutural do B19: o vínculo mora no próprio `leadership.municipalities` (`hasMany`, piso de 1 via hook `requireAtLeastOneMunicipality`, teto de 30), não numa reverse relationship que pode ir a zero — mutação por **delta** direto no documento da liderança (não replace via `updateLeadershipInternalRecord`, que validaria o array inteiro contra o escopo do assessor e quebraria lideranças cross-boundary), lock único `leadership-municipalities:{id}` (ao contrário do B19, que trava por município), piso de 1 desabilitado no cliente + recusado no servidor como defesa em profundidade, sugestões de busca restritas ao escopo do assessor enquanto os chips já atribuídos usam o catálogo completo. Promove `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros (`municipalityPortfolio.ts`/`loadMunicipalityPortfolioIndex`) **sem** portar a UI de `AdvisorMunicipalityCell` (o `ResizeObserver` resolve um problema — empacotar chips numa célula sempre-visível — que não existe dentro de um Popover); catálogo entregue uma vez por tabela via provider cliente. `revalidatePath` (não endpoint JSON como o B27): a lista tem 25 linhas, não 435, e há derivados a jusante (`/campanha/liderancas/[id]`, e `/campanha/municipios/[slug]` via `MunicipalityLeadershipsPanel`/dossiê **E16 ✓**). Sem migration/collection/Consent/mudança de URL · ~1–1,25d · Janela 1–2 · Impeccable B · soft: B19 ✓ (lib de origem), B27 ✓ (mesma peça de célula), B31 ✓ (mesma tabela e mesma decisão de container — extração de editor de chips genérico só no 3º call site), B28/B29/B30/B32 (mesma tabela), B17 (tabela cresce), E16 ✓ (painel do município a revalidar) · cortável · [plano](plans/chips-municipios-lista-liderancas.md) -- **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e um ícone de edição na célula abrindo um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` (busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". **⚠️ Mesmo conflito com o pedido literal já registrado quatro vezes no mesmo dia** (B28/B31 ✓/B32/B34): o pedido pede o toggle "Editar" de tabela inteira de `AdvisorsTable`; este plano segue o precedente e fica com o Popover por célula, marcado como _proposta — validar com produto_. Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. Promove a mesma lib pura do B19/B34 (`advisorMunicipalityPortfolio.ts` → `municipalityPortfolio.ts`) — quem entre B34 e este chegar primeiro faz o `git mv`, o outro só importa; os três (B31 ✓+B34+este) juntos são o gatilho já registrado para extrair um `RelationshipChipEditor` agnóstico — B31 ✓ entregou o 2º call site do padrão sem extrair, então este seria o 3º. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 ✓ (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 ✓ (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) +- ~~**B34** chips editáveis de municípios na lista de lideranças~~ — **entregue 2026-07-27** (a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon` + contagem) aparece quando a liderança cobre o TI inteiro, e a célula é **sempre editável em ponteiro fino** — "×" no canto do chip só no hover/foco, input de busca no fim da linha — enquanto em **ponteiro grosso** o toque em qualquer ponto da célula abre um **Drawer** com "×" de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir o município" de "apagar o vínculo". **O pedido literal ganhou, com o anti-goal emendado, não reaberto:** `PRODUCT.md` princípio 3 e `.cursor/rules/campanha-edit-where-you-see.mdc` passaram a admitir input sempre montado **para relações por chips em `pointer-fine`** (o input É o readout; um Popover por linha seria um clique a mais para um alvo já na tela) — B28/B31 ✓/B32 ✓ seguem valendo: nada aqui é "modo de tabela", e o toggle de `AdvisorsTable` foi renomeado para **"Editar nome e contato"**, que é tudo o que ele ainda destrava. **Divergências do plano, todas medidas:** `AdvisorMunicipalityCell` **foi** promovido a `shared/MunicipalityPortfolioCell` (o plano rejeitava — mas com a célula sempre visível a máquina de `ResizeObserver`/"Ver mais…" da origem passou de peso morto a requisito, e `/campanha/assessores` migrou para a peça compartilhada no mesmo PR, então o 2º call site nasceu junto); **sem provider de contexto** (índice + `addableIds` viajam por props da factory de colunas, montada uma vez pelo RSC); **uma única action de lote** (`setLeadershipMunicipalitiesMembership`, `municipalityIds` repetidos + `assigned`) — a gêmea de um id só nunca nasceu e a de assessor (`setAdvisorMunicipalityMembershipRecord` + schema) foi **deletada**, com os int tests migrados para a de lote; `LeadershipRowViewModel.municipalityNames` → `municipalityIDs: number[]` (nome/slug resolvidos no cliente contra o catálogo); piso de 1 com `aria-disabled` + razão no toast em vez de `disabled`, porque `disabled` esconde do teclado a única cópia da explicação; teto de 30 recusado no cliente **antes** do apply otimista, para um lote de TI não voltar inteiro com uma mensagem que não diz qual limite estourou. **`/impeccable` fechou dois P0/P1 reais:** as sugestões eram só de mouse (agora contrato ARIA 1.2 completo — setas/Home/End/Enter, `aria-activedescendant`, listbox portalizado com nome próprio, e-mail e "nenhum resultado" fora do listbox) e o auto-save era mudo (`aria-busy` + dim + live region, rollback só do delta que falhou, e **undo** em remoção de lote); mais alvo de 24 px no "×" via `before:-inset-1` (SC 2.5.8), links dos chips restaurados dentro do Drawer e borda do "×" com contraste real. **Bug latente encontrado e corrigido de graça:** o seam `description` de `CampaignTableHead` (**B22 ✓**) quebrava **qualquer** chamador RSC — `campaignHoverTooltipAlign`/`campaignHoverExplanationClassName` moravam no módulo `'use client'`, então o servidor chamava uma referência de cliente; foram para `src/lib/campaignHoverTooltip.ts` e a lista de lideranças é o primeiro header com explicação em RSC. Sem migration/collection/Consent/mudança de URL) · [plano](plans/chips-municipios-lista-liderancas.md) +- **B34+** escala e DRY pós-B34 — fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓**, cujos relatórios de perf e reuso chegaram depois do fechamento. O achado que manda é **medido, não estimado**: a entrega custou **+58 kB** de First Load JS em `/campanha/liderancas` (246 → 304 kB) e **+42 kB** em `/campanha/assessores` (253 → 295 kB), e a decomposição importa — assessores **já** importava o catálogo estático da Bahia, então os +42 kB de lá são a **célula em si** (Drawer, links, contrato de combobox, máquina de medição) e só os ~16 kB restantes em lideranças são o catálogo chegando a uma rota que não o tinha; é o mesmo custo que o **B14 ✓** já havia nomeado. As duas saídas se **opõem** (tirar o catálogo do browser engorda o payload de todo toggle; encolher o payload para `{id,slug}` mantém o bundle), e o plano recomenda a segunda por ser staff que edita muito e navega pouco — com a ressalva registrada de que isso troca o `region` do banco pelo do catálogo, que é snapshot-testado. Também entram: `useChipRowOverflow` (o gatilho de extração que o próprio B34 nomeou **disparou** — ~105 linhas idênticas entre `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell`, na mesma tabela), um só `nextRelationMembershipIds` para os três módulos de delta (generalização **verificada exata**, não aproximada) mais um `withLeadershipRelationDelta` local para as duas actions gêmeas e adjacentes, caminho só-ids no loader de lista de assessores, e o `Combobox` do base-ui — que ao contrário do cmdk **não** está descartado, porque `ui/combobox.tsx` já aceita `anchor` e tem hook `data-chips`. O passe localizado que fechou o B34 já derrubou o custo de CPU medido (chips 4,2 → 0,78 ms frio e **0,043 ms** quente; busca 4,59 → **0,077 ms** por tecla) e não volta ao plano · ~0,75–1d · Janela 1–2 · soft: **B37** (pluga a mesma célula numa 3ª relação — extrair antes evita copiar a máquina uma 3ª vez) · cortável · [plano](plans/escala-dry-pos-b34.md) +- **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e a mesma célula editável do **B34 ✓** (chips removíveis + busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. **Atualizado pelo B34 ✓ (2026-07-27):** a lib pura já foi promovida (`municipalityPortfolio.ts` + `utilities/municipalityPortfolioIndex.ts`) e a célula também — `shared/MunicipalityPortfolioCell` já serve lideranças **e** assessores, com `minItems`/`maxItems`/`addableIds`/`draft` como costuras. Então este item deixa de ser "desenhar de novo com Popover" e passa a ser **plugar a peça existente** numa relação que mora do lado do município: reusar a célula (sem piso, sem `assertMunicipalitiesWithinScope`) e escrever por delta no município tocado com lock por município. O container também herda a decisão do B34: sempre editável em `pointer-fine`, Drawer em `pointer-coarse`, sem toggle de tabela — o conflito com o pedido literal está **fechado**, não pendente. Com lideranças + assessores + dobradinhas a peça chega ao 3º call site sem nenhuma extração nova: o gatilho do `RelationshipChipEditor` agnóstico continua sendo o `LeadershipStateDeputyRelationCell` (B31 ✓), que segue com Popover e máquina de medição próprias. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 ✓ (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 ✓ (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) - ~~**B36** coluna de lideranças na lista de dobradinhas~~ — **entregue 2026-07-26** (coluna **Lideranças** em `/campanha/dobradinhas`: chips clicáveis → `/campanha/liderancas/`, edição por Popover+`Command` com escrita por server action + `useTransition`, reusando **sem alteração** `setLeadershipStateDeputyMembership`/`leadershipStateDeputyMembershipSchema`/lock `leadership-state-deputies:{id}`/`nextStateDeputyIdsAfterMembership` do **B31 ✓**; `LeadershipStateDeputiesCell` promovido a `shared/LeadershipStateDeputyRelationCell` bidirecional (`direction` + `measureOverflow` — clamp 3 linhas + "Ver mais…" no lado do deputado; lado liderança passa `false`); columns factory `stateDeputyColumns(..., leadershipOptions)` — não Context; casca própria `dobradinhas/formActions.ts`; `loadLeadershipOptions`; `StateDeputyRowViewModel.leadershipCount` → `leaderships: {id,name}[]` na mesma query batch. Sem migration) · [plano](plans/chips-liderancas-lista-dobradinhas.md) - **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; **Pass 2 W1 deixou trivial:** colunas já são dado (`CampaignTableColumn.id`/`mandatory`/`defaultVisible`); soft: B15 ✓ (ids) / B16 ✓ (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md) - **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); **Pass 2 W1 deixou trivial:** serialização canônica pronta e congelada em `municipalityListUrl.ts`; acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md) diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index 2dec4e6e..bc59f427 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -4,11 +4,11 @@ import { createAdvisor, sendAdvisorPasswordReset, setAdvisorMunicipalitiesBatch, - setAdvisorMunicipalityMembership, updateAdvisorProfile, } from '@/app/(campaign)/campanha/actions/advisor' import { nullableFormText, + repeatedRelationshipFormValues, requiredFormBoolean, requiredFormText, requiredRelationshipFormValue, @@ -20,21 +20,8 @@ import { } from '@/utilities/campaignFormActionError' const INVALID_FIELD_MESSAGE = 'Campo inválido.' -const EMPTY_BATCH_MESSAGE = 'Selecione ao menos um município.' -const advisorSafeMessages = [ - ...ADVISOR_ACTION_SAFE_MESSAGES, - INVALID_FIELD_MESSAGE, - EMPTY_BATCH_MESSAGE, -] as const - -const parseMunicipalityIds = (formData: FormData): number[] => { - const raw = formData.getAll('municipalityIds') - const ids = raw - .map((value) => (typeof value === 'string' ? Number(value) : NaN)) - .filter((id) => Number.isInteger(id) && id > 0) - return [...new Set(ids)] -} +const advisorSafeMessages = [...ADVISOR_ACTION_SAFE_MESSAGES, INVALID_FIELD_MESSAGE] as const export const updateAdvisorProfileFormAction = async ( _state: CampaignFormActionState, @@ -64,36 +51,20 @@ export const updateAdvisorProfileFormAction = async ( genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.', }) -export const setAdvisorMunicipalityMembershipFormAction = async ( +/** + * Add or remove municipalities in the advisor's carteira — one município or a + * whole território / ZE. `ownerId` is the field name `MunicipalityPortfolioCell` + * sends, so the same cell serves lideranças and assessores. + */ +export const setAdvisorMunicipalitiesFormAction = async ( _state: CampaignFormActionState, formData: FormData, ): Promise => runCampaignFormAction({ execute: async () => { - await setAdvisorMunicipalityMembership({ - advisorId: requiredRelationshipFormValue(formData, 'advisorId'), - municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'), - assigned: requiredFormBoolean(formData, 'assigned'), - }) - return { message: 'Carteira atualizada.' } - }, - safeMessages: advisorSafeMessages, - genericMessage: 'Não foi possível atualizar a carteira do assessor.', - }) - -export const setAdvisorMunicipalitiesBatchFormAction = async ( - _state: CampaignFormActionState, - formData: FormData, -): Promise => - runCampaignFormAction({ - execute: async () => { - const municipalityIds = parseMunicipalityIds(formData) - if (municipalityIds.length === 0) { - throw new Error(EMPTY_BATCH_MESSAGE) - } await setAdvisorMunicipalitiesBatch({ - advisorId: requiredRelationshipFormValue(formData, 'advisorId'), - municipalityIds, + advisorId: requiredRelationshipFormValue(formData, 'ownerId'), + municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), assigned: requiredFormBoolean(formData, 'assigned'), }) return { message: 'Carteira atualizada.' } @@ -129,7 +100,7 @@ export const createAdvisorFormAction = async ( phone: nullableFormText(formData, 'phone') ?? undefined, }) - const municipalityIds = parseMunicipalityIds(formData) + const municipalityIds = repeatedRelationshipFormValues(formData, 'municipalityIds') if (municipalityIds.length > 0) { await setAdvisorMunicipalitiesBatch({ advisorId: created.id, diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index edad0044..733e26cc 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -13,16 +13,15 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell import { advisorListHrefForPage, loadAdvisorListPageData, - loadAdvisorMunicipalityIndex, parseAdvisorListParams, } from '@/utilities/advisorData' import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' import { createAdvisorFormAction, sendAdvisorPasswordResetFormAction, - setAdvisorMunicipalitiesBatchFormAction, - setAdvisorMunicipalityMembershipFormAction, + setAdvisorMunicipalitiesFormAction, updateAdvisorProfileFormAction, } from './formActions' @@ -39,7 +38,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) const state = parseAdvisorListParams(rawSearchParams) const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([ loadAdvisorListPageData(payload, state), - loadAdvisorMunicipalityIndex(payload), + loadMunicipalityPortfolioIndex(payload), ]) return ( @@ -66,8 +65,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) municipalityIndex={municipalityIndex} hasQuery={Boolean(state.q)} updateProfileAction={updateAdvisorProfileFormAction} - membershipAction={setAdvisorMunicipalityMembershipFormAction} - batchAction={setAdvisorMunicipalitiesBatchFormAction} + municipalitiesAction={setAdvisorMunicipalitiesFormAction} createAction={createAdvisorFormAction} passwordResetAction={sendAdvisorPasswordResetFormAction} /> diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts index a7b0ca22..660debd1 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/formActions.ts @@ -10,14 +10,18 @@ import { repeatedRelationshipFormValues, requiredRelationshipFormValue, } from '@/lib/formData' -import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership' +import { + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + leadershipSectors, + leadershipSupportStatuses, +} from '@/lib/schemas/leadership' import { runCampaignFormAction, type CampaignFormActionState, } from '@/utilities/campaignFormActionError' const safeMessages = [ - 'Você só pode vincular lideranças aos municípios que assessora.', + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, ...leadershipStaffEditSafeMessages, ] as const diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index a2e23685..dce09b70 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -1,9 +1,21 @@ 'use server' import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' -import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership' -import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData' -import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership' +import { + setLeadershipMunicipalitiesMembership, + setLeadershipStateDeputyMembership, +} from '@/app/(campaign)/campanha/actions/leadership' +import { + repeatedRelationshipFormValues, + requiredFormBoolean, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + MAX_LEADERSHIP_STATE_DEPUTIES, +} from '@/lib/schemas/leadership' import { runCampaignFormAction, type CampaignFormActionState, @@ -12,6 +24,9 @@ import { const safeMessages = [ ...leadershipStaffEditSafeMessages, `Cada liderança aceita no máximo ${MAX_LEADERSHIP_STATE_DEPUTIES} dobradinhas.`, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, ] as const /** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */ @@ -31,3 +46,26 @@ export const setLeadershipStateDeputyMembershipFormAction = async ( safeMessages, genericMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.', }) + +/** + * Add or remove municipalities in the "Municípios" column of + * `/campanha/liderancas` (B34) — one chip or a whole território/ZE. `ownerId` is + * the field name `MunicipalityPortfolioCell` sends, so the same cell serves + * lideranças and assessores. + */ +export const setLeadershipMunicipalitiesFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setLeadershipMunicipalitiesMembership({ + leadershipId: requiredRelationshipFormValue(formData, 'ownerId'), + municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Municípios atualizados.' } + }, + safeMessages, + genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index 2cbc5c1a..2ab324b0 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -3,7 +3,11 @@ import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership' import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData' -import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership' +import { + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + leadershipSectors, + leadershipSupportStatuses, +} from '@/lib/schemas/leadership' import { runCampaignRedirectFormAction, type CampaignFormActionState, @@ -11,7 +15,7 @@ import { const safeMessages = [ 'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.', - 'Você só pode vincular lideranças aos municípios que assessora.', + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, ...leadershipStaffEditSafeMessages, 'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.', ] as const diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx index aea42654..fd0ca92b 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx @@ -23,11 +23,14 @@ import { LeadershipStateDeputyRelationCell, type RelationCellOption, } from '@/components/campaign/shared/LeadershipStateDeputyRelationCell' +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 type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' -import { isCampaignStaff } from '@/utilities/campaignAccess' +import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership' +import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions' import { @@ -36,16 +39,27 @@ import { parseLeadershipListParams, type LeadershipRowViewModel, } from '@/utilities/leadershipData' +import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex' -import { setLeadershipStateDeputyMembershipFormAction } from './formActions' +import { + setLeadershipMunicipalitiesFormAction, + setLeadershipStateDeputyMembershipFormAction, +} from './formActions' type LeadershipsPageProps = { searchParams: Promise> } -const leadershipColumns = ( - stateDeputyOptions: RelationCellOption[], -): Array> => [ +const leadershipColumns = ({ + stateDeputyOptions, + municipalityIndex, + addableMunicipalityIds, +}: { + stateDeputyOptions: RelationCellOption[] + municipalityIndex: MunicipalityPortfolioIndexEntry[] + /** Advisors may only link municipalities they administer; staff: undefined. */ + addableMunicipalityIds?: ReadonlySet +}): Array> => [ { id: 'name', mandatory: true, @@ -86,9 +100,26 @@ const leadershipColumns = ( }, { id: 'municipalities', - head: Municípios, - cellClassName: 'max-w-64 whitespace-normal text-muted-foreground', - cell: (row) => row.municipalityNames.join(', ') || '—', + head: ( + + Municípios + + ), + cellClassName: 'max-w-64 whitespace-normal', + cell: (row) => ( + + ), }, { id: 'organizations', @@ -178,12 +209,15 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP if (!isCampaignStaff(user)) redirect('/campanha') const state = parseLeadershipListParams(rawSearchParams) - const [{ rows, totalDocs, totalPages }, stateDeputyOptions] = await Promise.all([ - loadLeadershipListPageData(payload, user, state), - loadStateDeputyOptions(payload, user), - ]) - const columns = leadershipColumns( - stateDeputyOptions.map((option) => ({ + 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 columns = leadershipColumns({ + stateDeputyOptions: stateDeputyOptions.map((option) => ({ id: option.id, searchLabel: option.name, item: { @@ -193,7 +227,9 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP ...(option.party ? { party: option.party } : {}), }, })), - ) + municipalityIndex, + ...(administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}), + }) return ( diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 00537d6f..061430c7 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -12,13 +12,11 @@ import { PLACEHOLDER_RESET_MESSAGE, advisorCreateSchema, advisorMunicipalitiesBatchSchema, - advisorMunicipalityMembershipSchema, advisorPasswordResetSchema, advisorProfileUpdateSchema, isPlanilhaPlaceholderEmail, type AdvisorCreateInput, type AdvisorMunicipalitiesBatchInput, - type AdvisorMunicipalityMembershipInput, type AdvisorPasswordResetInput, type AdvisorProfileUpdateInput, } from '@/lib/schemas/advisor' @@ -160,68 +158,6 @@ export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) => return updated } -export const setAdvisorMunicipalityMembershipRecord = async ( - payload: Payload, - actor: CampaignUser, - input: AdvisorMunicipalityMembershipInput, -) => { - const { advisorId, municipalityId, assigned } = advisorMunicipalityMembershipSchema.parse(input) - - return withPayloadTransaction( - payload, - async ({ req }) => { - const currentActor = await reloadUnrestrictedActor( - payload, - actor, - ADVISOR_UNRESTRICTED_MESSAGE, - req, - ) - await assertTargetAdvisor(payload, advisorId, currentActor.id) - - await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipalityId}`]) - - const municipality = await payload.findByID({ - collection: 'municipality', - id: municipalityId, - depth: 0, - select: { advisors: true, slug: true }, - overrideAccess: true, - req, - }) - - const currentAdvisorIDs = uniqueRelationshipIds(municipality.advisors) - const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned) - if (nextAdvisorIDs === null) return municipality - - // Intentional admin bypass: unrestricted role verified above; advisors - // field update access is admin-only in the collection config. - const updated = await payload.update({ - collection: 'municipality', - id: municipalityId, - data: { advisors: nextAdvisorIDs }, - depth: 0, - overrideAccess: true, - req, - }) - - return updated - }, - { beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' }, - ) -} - -export const setAdvisorMunicipalityMembership = async ( - input: AdvisorMunicipalityMembershipInput, -) => { - const { payload, actor } = await getCampaignActionContext() - const municipality = await setAdvisorMunicipalityMembershipRecord(payload, actor, input) - revalidateMunicipalityListPaths({ - slug: typeof municipality.slug === 'string' ? municipality.slug : undefined, - }) - revalidateAdvisorPaths(input.advisorId) - return municipality -} - export const setAdvisorMunicipalitiesBatchRecord = async ( payload: Payload, actor: CampaignUser, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index 125da4ce..7cb6b96b 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -3,12 +3,16 @@ import { revalidatePath } from 'next/cache' import type { Payload } from 'payload' +import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership' import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership' import { + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, leadershipCreateSchema, leadershipInternalUpdateSchema, + leadershipMunicipalitiesMembershipSchema, leadershipStateDeputyMembershipSchema, type LeadershipInternalUpdateInput, + type LeadershipMunicipalitiesMembershipInput, type LeadershipStateDeputyMembershipInput, } from '@/lib/schemas/leadership' import type { CampaignUser, Contact } from '@/payload-types' @@ -44,7 +48,7 @@ const assertMunicipalitiesWithinScope = async ( const administered = new Set(await getAdvisorMunicipalityIds(payload, actor.id, req)) const outside = municipalityIDs.filter((id) => !administered.has(id)) if (outside.length > 0) { - throw new Error('Você só pode vincular lideranças aos municípios que assessora.') + throw new Error(LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE) } } @@ -330,3 +334,115 @@ export const setLeadershipStateDeputyMembership = async ( if (stateDeputySlug) revalidateLeadershipStateDeputyPaths(input.leadershipId, stateDeputySlug) return leadership } + +const revalidateLeadershipMunicipalityPaths = ( + leadershipId: number, + municipalitySlugs: readonly string[], +) => { + revalidatePath('/campanha/liderancas', 'page') + revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page') + for (const slug of municipalitySlugs) { + revalidatePath(`/campanha/municipios/${slug}`, 'page') + } +} + +/** + * Delta write for the "Municípios" column of `/campanha/liderancas` (B34) — + * adds or removes a set of municipalities (one chip, or a whole território/ZE) + * on `leadership.municipalities`. + * + * Deliberately NOT `updateLeadershipInternalRecord`: that one replaces the whole + * array and revalidates every id against the advisor's scope, which would make a + * cross-boundary leadership unsavable by the advisor who only owns part of it. + * Only the *added* ids are scope-checked; removal needs no scope beyond the row + * access that already resolved the leadership. + */ +export const setLeadershipMunicipalitiesMembershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipMunicipalitiesMembershipInput, +) => { + const { leadershipId, municipalityIds, assigned } = + leadershipMunicipalitiesMembershipSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await getFreshStaffActor(payload, actor, req) + + await acquireTextAdvisoryLocks(payload, req, [`leadership-municipalities:${leadershipId}`]) + + // Row access verifies the leadership is in the actor's scope (same + // guard `updateLeadershipInternalRecord` relies on). + const current = await payload.findByID({ + collection: 'leadership', + id: leadershipId, + depth: 0, + select: { municipalities: true }, + user: currentActor, + overrideAccess: false, + req, + }) + + const change = nextMunicipalityIdsAfterLeadershipMembership( + uniqueRelationshipIds(current.municipalities), + municipalityIds, + assigned, + ) + + // No-op: nothing to write, and nothing for the caller to revalidate — + // skip the slug lookup below, which exists only to target that revalidate. + if (change === null) { + return { leadership: current, municipalitySlugs: [] } + } + + // Only the ids the write actually adds: an advisor must stay able to drop a + // link outside their scope, and re-deriving `added` here could disagree. + if (change.added.length > 0) { + await assertMunicipalitiesWithinScope(payload, currentActor, change.added, req) + } + + // Intentional admin bypass: only used to resolve the slugs of the touched + // municipalities for a targeted revalidate; existence is otherwise + // enforced by Payload's relationship validation on `update`. + const touched = await payload.find({ + collection: 'municipality', + where: { id: { in: change.changed } }, + depth: 0, + pagination: false, + select: { slug: true }, + overrideAccess: true, + req, + }) + + const updated = await payload.update({ + collection: 'leadership', + id: leadershipId, + data: { municipalities: change.next }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + + return { leadership: updated, municipalitySlugs: touched.docs.map((doc) => doc.slug) } + }, + { beginFailureMessage: 'Não foi possível atualizar os municípios da liderança.' }, + ) +} + +export const setLeadershipMunicipalitiesMembership = async ( + input: LeadershipMunicipalitiesMembershipInput, +) => { + const { payload, actor } = await getCampaignActionContext() + const { leadership, municipalitySlugs } = await setLeadershipMunicipalitiesMembershipRecord( + payload, + actor, + input, + ) + // No-op writes nothing, so there is nothing to revalidate. + if (municipalitySlugs.length > 0) { + revalidateLeadershipMunicipalityPaths(input.leadershipId, municipalitySlugs) + } + return leadership +} diff --git a/src/components/campaign/advisor/AdvisorMunicipalityCell.tsx b/src/components/campaign/advisor/AdvisorMunicipalityCell.tsx deleted file mode 100644 index 6cc1f58e..00000000 --- a/src/components/campaign/advisor/AdvisorMunicipalityCell.tsx +++ /dev/null @@ -1,473 +0,0 @@ -'use client' - -import { MapPinIcon, XIcon } from 'lucide-react' -import Link from 'next/link' -import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react' -import { toast } from 'sonner' - -import { Badge } from '@/components/ui/Badge' -import { - buildAdvisorPortfolioChips, - searchAdvisorPortfolio, - type AdvisorMunicipalityIndexEntry, - type AdvisorPortfolioChip, - type AdvisorPortfolioSearchHit, -} from '@/lib/advisorMunicipalityPortfolio' -import { cn } from '@/lib/utils' -import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' - -type MunicipalityRef = { - id: number - name: string - slug: string -} - -/** Same box in both modes so toggling edition never reflows the row. */ -const CELL_WRAPPER_CLASS = 'relative min-w-56 rounded-md border border-transparent p-1' -const CHIP_ROW_CLASS = 'flex min-h-8 flex-wrap items-center gap-1.5 overflow-hidden' - -const COLLAPSED_CHIP_ROWS = 3 -/** Matches `gap-1.5` on the chip row. */ -const CHIP_GAP_PX = 6 - -type AdvisorMunicipalityCellProps = { - advisorId: number | null - municipalities: MunicipalityRef[] - municipalityIndex: readonly AdvisorMunicipalityIndexEntry[] - editing: boolean - /** Draft mode (new row): keep changes local and report via onChange. */ - draft?: boolean - onDraftChange?: (municipalities: MunicipalityRef[]) => void - membershipAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise - batchAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise - onPersisted?: () => void -} - -export const AdvisorMunicipalityCell = ({ - advisorId, - municipalities, - municipalityIndex, - editing, - draft = false, - onDraftChange, - membershipAction, - batchAction, - onPersisted, -}: AdvisorMunicipalityCellProps) => { - const [query, setQuery] = useState('') - const [open, setOpen] = useState(false) - const [optimistic, setOptimistic] = useState(null) - const [expanded, setExpanded] = useState(false) - const [collapsedHeight, setCollapsedHeight] = useState(null) - /** `null` means "render every chip so the next effect can measure them". */ - const [visibleChipCount, setVisibleChipCount] = useState(null) - const [measureToken, setMeasureToken] = useState(0) - const lastRowWidth = useRef(0) - const [, startTransition] = useTransition() - const rootRef = useRef(null) - const inputRef = useRef(null) - const chipRowRef = useRef(null) - const baselineRef = useRef(municipalities) - - useEffect(() => { - baselineRef.current = municipalities - setOptimistic((current) => { - if (!current) return null - const baselineKey = municipalities - .map((municipality) => municipality.id) - .sort((left, right) => left - right) - .join(',') - const currentKey = current - .map((municipality) => municipality.id) - .sort((left, right) => left - right) - .join(',') - return baselineKey === currentKey ? null : current - }) - }, [municipalities]) - - const effectiveMunicipalities = optimistic ?? municipalities - - const assignedIds = useMemo( - () => new Set(effectiveMunicipalities.map((municipality) => municipality.id)), - [effectiveMunicipalities], - ) - const chips = useMemo( - () => buildAdvisorPortfolioChips(effectiveMunicipalities, municipalityIndex), - [effectiveMunicipalities, municipalityIndex], - ) - const hits = useMemo( - () => (open && editing ? searchAdvisorPortfolio(query, municipalityIndex, assignedIds) : []), - [open, editing, query, municipalityIndex, assignedIds], - ) - - /** - * The token is what makes a stale measurement recoverable: an invalidation - * batched with a measurement would otherwise settle back on `null` without - * changing any dependency, leaving the cell unmeasured forever. - */ - const invalidateMeasurement = useCallback(() => { - setVisibleChipCount(null) - setMeasureToken((token) => token + 1) - }, []) - - // Keyed by content, not array identity: a re-render that rebuilds the same - // chips must not throw away a measurement that is still valid. Toggling edit - // does invalidate it — removable chips carry an extra "×" and are wider. - const chipsKey = chips.map((chip) => chip.key).join('|') - useEffect(() => { - invalidateMeasurement() - }, [chipsKey, editing, invalidateMeasurement]) - - useEffect(() => { - const row = chipRowRef.current - if (!row) return - // Seeded here so the observer's initial callback isn't read as a resize. - lastRowWidth.current = row.clientWidth - const observer = new ResizeObserver(() => { - if (row.clientWidth === lastRowWidth.current) return - lastRowWidth.current = row.clientWidth - invalidateMeasurement() - }) - observer.observe(row) - return () => observer.disconnect() - }, [invalidateMeasurement]) - - /** - * Runs on the frames where every chip is in the DOM, so the packing of the - * three visible rows — and the room the toggle needs on the third one — is - * read from the real layout instead of estimated from label lengths. - */ - useEffect(() => { - if (visibleChipCount !== null) return - const row = chipRowRef.current - if (!row) return - - const chipElements = [...row.querySelectorAll('[data-portfolio-chip]')] - if (chipElements.length === 0) { - setVisibleChipCount(0) - return - } - - const rowRect = row.getBoundingClientRect() - const chipRects = chipElements.map((element) => element.getBoundingClientRect()) - const chipHeight = chipRects[0].height - setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1)) - - const lastVisibleTop = chipRects[0].top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1) - let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length - - // The toggle — and, while editing, the search input — must share the last - // visible line, otherwise they wrap into a line nobody asked for. - const toggleWidth = - row.querySelector('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0 - const inputWidth = inputRef.current - ? Number.parseFloat(getComputedStyle(inputRef.current).minWidth) || 0 - : 0 - const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth - while (fitting > 0 && fitting < chipElements.length) { - const trailing = chipRects[fitting - 1] - if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break - fitting -= 1 - } - - setVisibleChipCount(fitting) - }, [measureToken, visibleChipCount]) - - const indexById = useMemo(() => { - const map = new Map() - for (const entry of municipalityIndex) map.set(entry.id, entry) - return map - }, [municipalityIndex]) - - const refsFromIds = (ids: number[]): MunicipalityRef[] => - [...new Set(ids)] - .map((id) => { - const existing = effectiveMunicipalities.find((municipality) => municipality.id === id) - if (existing) return existing - const fromBaseline = baselineRef.current.find((municipality) => municipality.id === id) - if (fromBaseline) return fromBaseline - const entry = indexById.get(id) - return entry ? { id: entry.id, name: entry.name, slug: entry.slug } : null - }) - .filter((municipality): municipality is MunicipalityRef => municipality !== null) - .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) - - const applyLocal = (nextIds: number[]) => { - const next = refsFromIds(nextIds) - if (draft) { - onDraftChange?.(next) - return - } - setOptimistic(next) - } - - const revertOptimistic = () => { - setOptimistic(null) - } - - const runMembership = (municipalityId: number, assigned: boolean) => { - const next = new Set(assignedIds) - if (assigned) next.add(municipalityId) - else next.delete(municipalityId) - applyLocal([...next]) - setQuery('') - setOpen(false) - - if (draft) return - if (advisorId === null) return - - const formData = new FormData() - formData.set('advisorId', String(advisorId)) - formData.set('municipalityId', String(municipalityId)) - formData.set('assigned', assigned ? 'true' : 'false') - - startTransition(async () => { - const result = await membershipAction({}, formData) - if (result.status === 'success') { - onPersisted?.() - return - } - revertOptimistic() - toast.error(result.message ?? 'Não foi possível atualizar a carteira.') - }) - } - - const runBatch = (municipalityIds: number[], assigned: boolean) => { - if (municipalityIds.length === 0) return - const next = new Set(assignedIds) - for (const id of municipalityIds) { - if (assigned) next.add(id) - else next.delete(id) - } - applyLocal([...next]) - setQuery('') - setOpen(false) - - if (draft) return - if (advisorId === null) return - - const formData = new FormData() - formData.set('advisorId', String(advisorId)) - formData.set('assigned', assigned ? 'true' : 'false') - for (const id of municipalityIds) formData.append('municipalityIds', String(id)) - - startTransition(async () => { - const result = await batchAction({}, formData) - if (result.status === 'success') { - onPersisted?.() - return - } - revertOptimistic() - toast.error(result.message ?? 'Não foi possível atualizar a carteira.') - }) - } - - const removeChip = (chip: AdvisorPortfolioChip) => { - if (chip.kind === 'territory') { - runBatch(chip.municipalityIds, false) - return - } - runMembership(chip.municipalityId, false) - } - - const pickHit = (hit: AdvisorPortfolioSearchHit) => { - if (hit.kind === 'municipality') { - runMembership(hit.municipalityId, true) - return - } - runBatch(hit.municipalityIds, true) - } - - // Editing needs every chip reachable, so focusing the cell always expands it. - const showAllChips = expanded || (editing && open) - const measuring = visibleChipCount === null - const hasHiddenChips = visibleChipCount !== null && visibleChipCount < chips.length - const visibleChips = showAllChips || measuring ? chips : chips.slice(0, visibleChipCount) - /** - * Only the pre-measurement render needs a height cap; once the chip list is - * trimmed the row is three lines tall on its own, and capping it again would - * clip whatever shares the last line (the search input is taller than a chip). - */ - const clamping = measuring && !showAllChips - const chipRowStyle = - clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined - - const expandToggle = - measuring || hasHiddenChips ? ( - - ) : null - - if (!editing) { - return ( -
-
- {chips.length === 0 ? ( - — - ) : ( - visibleChips.map((chip) => - chip.kind === 'territory' ? ( - - - ) : ( - - - {chip.label} - - - ), - ) - )} - {expandToggle} -
-
- ) - } - - return ( -
{ - if ((event.target as HTMLElement).closest('[data-chip-remove]')) return - inputRef.current?.focus() - setOpen(true) - }} - > -
- {visibleChips.map((chip) => ( - - ))} - {expandToggle} - { - setQuery(event.currentTarget.value) - setOpen(true) - }} - onFocus={() => setOpen(true)} - onBlur={() => { - window.setTimeout(() => { - if (!rootRef.current?.contains(document.activeElement)) { - setOpen(false) - setQuery('') - } - }, 120) - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - setOpen(false) - setQuery('') - inputRef.current?.blur() - } - }} - placeholder={chips.length ? 'Adicionar…' : 'Buscar município, território ou ZE…'} - aria-label="Buscar município, território de identidade ou zona eleitoral" - className="min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" - /> -
- - {open && query.trim() ? ( -
- {hits.length === 0 ? ( -

Nenhum resultado.

- ) : ( - hits.map((hit) => ( - - )) - )} -
- ) : null} -
- ) -} diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index 154a185c..c3df83a0 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -11,17 +11,17 @@ import { } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useState, useTransition } from 'react' +import { useMemo, useState, useTransition } from 'react' import { toast } from 'sonner' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' -import { AdvisorMunicipalityCell } from '@/components/campaign/advisor/AdvisorMunicipalityCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' import { CampaignCopyableCell, campaignReadCellClassName, } from '@/components/campaign/shared/CampaignCopyableCell' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' +import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' @@ -33,7 +33,7 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' -import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio' +import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput, @@ -44,28 +44,22 @@ import { cn } from '@/lib/utils' import type { AdvisorRowViewModel } from '@/utilities/advisorData' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' -type MunicipalityRef = { id: number; name: string; slug: string } - type DraftAdvisor = { name: string email: string phone: string - municipalities: MunicipalityRef[] + municipalityIds: number[] } type AdvisorsTableProps = { rows: AdvisorRowViewModel[] - municipalityIndex: AdvisorMunicipalityIndexEntry[] + municipalityIndex: MunicipalityPortfolioIndexEntry[] hasQuery: boolean updateProfileAction: ( state: CampaignFormActionState, formData: FormData, ) => Promise - membershipAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise - batchAction: ( + municipalitiesAction: ( state: CampaignFormActionState, formData: FormData, ) => Promise @@ -83,7 +77,7 @@ const emptyDraft = (): DraftAdvisor => ({ name: '', email: '', phone: '', - municipalities: [], + municipalityIds: [], }) const displayEmail = (email: string | null): string | null => { @@ -96,8 +90,7 @@ export const AdvisorsTable = ({ municipalityIndex, hasQuery, updateProfileAction, - membershipAction, - batchAction, + municipalitiesAction, createAction, passwordResetAction, }: AdvisorsTableProps) => { @@ -108,6 +101,16 @@ export const AdvisorsTable = ({ const refresh = () => router.refresh() + // One array per row per render of `rows`, not per render of this table: the + // cell reconciles its optimistic state against this prop by identity. + const municipalityIdsByAdvisor = useMemo( + () => + new Map( + rows.map((row) => [row.id, row.municipalities.map((municipality) => municipality.id)]), + ), + [rows], + ) + const startDraft = () => { setEditing(true) setDraft(emptyDraft()) @@ -135,8 +138,8 @@ export const AdvisorsTable = ({ formData.set('name', draft.name.trim()) formData.set('email', draft.email.trim()) if (draft.phone.trim()) formData.set('phone', draft.phone.trim()) - for (const municipality of draft.municipalities) { - formData.append('municipalityIds', String(municipality.id)) + for (const municipalityId of draft.municipalityIds) { + formData.append('municipalityIds', String(municipalityId)) } startCreate(async () => { @@ -168,7 +171,7 @@ export const AdvisorsTable = ({ onClick={() => setEditing(true)} >