Merge branch 'cursor/46c3831d'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 23:59:51 -03:00
commit 96926bd718
45 changed files with 2326 additions and 1056 deletions

View file

@ -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”

View file

@ -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`).

View file

@ -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).

File diff suppressed because one or more lines are too long

View file

@ -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.

View file

@ -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. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
| 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. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). 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. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
| `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. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | 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`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | 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

View file

@ -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)

View file

@ -0,0 +1,122 @@
# 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), 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.
## Fechado no `/simplify` pós-rebase (a premissa que morreu)
O rebase em `main` trouxe o **B42 ✓** e, com ele, `CampaignCellEditOverlay`. A leitura inicial foi que a casca não servia porque "ela decide por largura de viewport e o B34 decide por tipo de ponteiro". **Isso é falso no código:** a casca não decide nada — `variant` é prop, não há `matchMedia` nela, e o docstring diz isso com todas as letras. Quem decide por largura é o `MunicipalityList`, que renderiza duas árvores irmãs; `LeadershipListSupportStatusControl`, na mesma tabela do B34, fixa `variant="popover"` porque sabe a sua própria política. O B34 passou a ser a terceira política sobre um container que não tem nenhuma: **`variant="sheet"` fixo, com o `pointer-fine:hidden` no `triggerClassName`**, e a política de ponteiro continua onde estava, no CSS.
Ganhos que não eram sobre linhas (embora sejam ~25 a menos, ~14 delas cópias verbatim do `DrawerHeader`/`DrawerFooter`): o `initialFocus={titleRef}` da casca — o Drawer do B34 focava o **primeiro chip**, um link que navega para fora da linha que se está editando, ou o campo de busca numa linha em rascunho, que é exatamente o teclado-virtual-sobre-a-folha que o B42 documentou; o trigger passou a ter `aria-haspopup="dialog"`/`aria-expanded`/`aria-busy`, que todo outro trigger de célula das duas listas já tinha; e a célula entrou no spec table-driven da casca como **5º caso** — a metade Drawer do B34 não tinha nenhuma cobertura, porque o e2e roda em ponteiro fino.
**A adoção custou um bug real, achado pelo e2e e vale saber antes de adotar a casca em outro lugar:** um `Drawer` sempre montado reporta `onOpenChange(false)` quando uma dispensa em outro ponto da página varre a pilha de camadas — não só quando ele mesmo fecha. O handler novo (limpar a busca ao fechar a folha, para que o listbox portalizado não apareça num toque depois que a gaveta sai) apagava, sem guarda, o que o ponteiro **fino** tinha acabado de digitar no input inline. O guard é `if (!next && drawerOpen)`.
Também fechado aqui, e é correção de comportamento, não de estilo: `setAdvisorMunicipalitiesBatch` revalidava só o **último** município do lote (`lastSlug`). Era pré-existente do B19, mas o B34 promoveu o lote ao **único** caminho de escrita do assessor e pôs um chip de território ao alcance do dedo, então soltar um TI de 30 municípios deixava 29 páginas de detalhe velhas. Agora devolve `slugs: string[]` e revalida a lista uma vez e cada detalhe que mudou, espelhando `revalidateLeadershipMunicipalityPaths`.
**Duas recomendações do relatório foram fechadas como "não fazer", com motivo:** o ramo **Popover** da casca não serve ao B34 e não deve ser adotado — ele entrega `role="dialog"` atrás de um `PopoverTrigger`, e um dialog não pode ser o popup de um combobox ARIA 1.2 (o inline precisa de `PopoverAnchor` + `role="listbox"` que nunca toma foco). E o **F3** abaixo (colapsar os três `next*IdsAfterMembership`) continua registrado, mas com a recomendação invertida por uma segunda leitura: um genérico cobrindo "um id" e "um conjunto com piso e teto" precisaria de quatro callbacks para poupar ~20 linhas.
## Fechado na revisão de qualidade + performance (o bug que voltou)
**O achado que importa: o bug do baseline otimista tinha voltado, escrito de outro jeito.** O fechamento anterior corrigiu "aplicar o delta funcionalmente" e registrou isso como resolvido — mas o `??` _dentro_ do updater ainda lia o `municipalityIds` da render que criou a closure, e o "Desfazer" do toast é justamente o único chamador que sobrevive à sua render. Sequência real, reproduzida em teste: remover um território (toast aparece) → servidor reconcilia, `optimistic` volta a `null` → **adicionar outro município** → apertar Desfazer. O updater cai no ramo do fallback, reconstrói a linha a partir do conjunto de antes do add, e o município adicionado some da tela enquanto continua salvo no servidor. A divergência é **permanente**, porque o efeito de reconciliação só limpa em igualdade exata. Corrigido com um ref (`latestIds`) que segue `effectiveIds`, e o ramo `draft` o avança **de forma síncrona** — dois toggles no mesmo tick liam ambos a prop da render em que foram enfileirados, e o segundo descartava o primeiro.
Isso agora tem pino: `tests/unit/municipalityPortfolioCell.unit.spec.ts` monta a sequência inteira e falha (vermelho verificado) contra a versão com o bug. As duas revisões **discordaram** aqui — a de performance leu o `withDelta` funcional e concluiu "sem divergência de closure"; a de qualidade traçou a sequência. Vale registrar qual estava certa, porque a leitura otimista é a intuitiva.
**Uma otimização anterior foi desfeita por medição, e ela era minha:** o corpo do `Drawer` estava atrás de `drawerOpen` como "ganho de alocação". O ganho foi medido e é **zero** — o portal fechado não emite DOM e a string de 85 classes é acerto de cache do tailwind-merge (2,71 ms frios uma vez por sessão, 0,0001 ms depois) — enquanto o custo é real: base-ui mantém o popup montado durante a transição de saída, então fechar a gaveta animava uma caixa **vazia** encolhendo. As outras duas células em célula da casa não fazem esse gate. Removido.
Também entraram: `matchesNormalizedAtWordStart` deixou de fatiar/juntar palavra por palavra — como `normalizeSearchPhrase` emite exatamente `words.join(' ')`, "começo de palavra" é a posição 0 ou depois de um espaço, então `startsWith(q) || includes(' ' + q)` diz o mesmo em **0,0011 ms contra 0,0585 ms** por 435 rótulos (53×, equivalência verificada em 4.608 pares com apóstrofo, travessão, dígito e vazio); `matchesAtWordStart` normaliza a query **primeiro** e sai cedo na vazia, que é como os pickers filtram a lista inteira ao abrir; e `buildMunicipalityPortfolioChips` passou a **copiar** o `municipalityIds` do território ao montar o chip — aquele array é memoizado por índice e compartilhado por toda a tabela, então entregá-lo a um chamador transformava qualquer `sort`/`splice` posterior em corrupção da página inteira.
## F7 — Cada toggle de chip re-renderiza a página inteira (374 SQL, 229 ms)
`setLeadershipMunicipalitiesMembership` chama `revalidatePath('/campanha/liderancas', 'page')` a cada toggle, e a célula grava **uma action por toggle**. O Next re-executa a página dentro da resposta da action: medido localmente, com 288 lideranças, **374 statements e 228,6 ms** (`loadLeadershipListPageData` 371/183,3 ms + `loadStateDeputyOptions` 2/4,4 ms + `loadMunicipalityPortfolioIndex` 1/11,9 ms), mais ~44 kB de payload por toggle. Limpar um chip de território e adicionar três dispara isso quatro vezes — e localhost é **piso**: `gru1`↔`sa-east-1` soma ~1–3 ms por round trip.
A célula já reconcilia contra o array do servidor e já faz rollback por delta em falha, então o refresh da lista não entrega nada que o usuário veja. Direção recomendada: dispensar o `revalidatePath` da própria lista no caminho de sucesso (mantendo o dos detalhes de município, que mudam de verdade) e deixar o estado otimista de pé até navegação. Não foi feito agora porque é mudança de comportamento em caminho de escrita e merece a sua própria medição pós-fix.
## F8 — O índice de municípios é dado de referência lido sem cache
`loadMunicipalityPortfolioIndex` custa **12,4 ms medianos (1 SQL)** e roda uma vez por request em duas rotas — e de novo em cada re-render disparado pelo F7. **~10 dos 12 ms são o transform por documento do Payload**, não a query: pedir só `slug` no `select` não muda nada (10,7 ms).
Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é um único call site por request, dentro de um `Promise.all`. O degrau 2 (`unstable_cache` + tag) é o encaixe certo e não está em uso: nome/slug/região de município é geografia read-only semeada por migration, a definição de dado lento entre requests. Uma tag `municipality-catalog` no allowlist de `revalidateRequest.ts` tira 12 ms das duas rotas e de todo toggle.
**Correção ao F1:** a opção (b) "payload mínimo" encolhe o payload RSC de **35.510 B / 7.286 B gzip** para **13.610 B / 3.534 B gzip**, mas **não poupa tempo de servidor nenhum** (10,7 ms de qualquer jeito). A decisão registrada é payload-contra-bundle, não um ganho de servidor.
## F9 — O ramo do assessor entrega uma chave nova ao WeakMap por linha
`searchIndex` filtra `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha** — e é esse array que vira a chave do WeakMap de derivações. O assessor fica com 25 conjuntos de derivação (4 Maps cada) em vez de um, e paga o build O(435) de novo na primeira busca de cada linha: **1,89 ms** para as 25 contra 0,076 ms para uma. É exatamente o modo de falha que a memoização foi escrita para evitar, embora o número absoluto seja pequeno.
Duas saídas, nenhuma feita agora: passar `addableIds` como opção de `searchMunicipalityPortfolio` (preserva a contagem escopada de hits de TI/ZE e elimina as 25 alocações, mas mexe na assinatura de uma função compartilhada), ou memoizar o filtro num WeakMap aninhado dentro da célula (contido, mas é uma segunda camada de cache).
## 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** |
**Ajuste pós-rebase (medido nas duas pontas, não inferido):** adotar `CampaignCellEditOverlay` mexeu nisto, e para os dois lados. O chunk **próprio** de cada rota encolheu (assessores 6,58 → **4,05 kB**; lideranças 6,89 → **6,46 kB**), mas o First Load de assessores subiu 295 → **297 kB**, enquanto lideranças ficou em **305 kB**. A assimetria tem uma causa só: a casca arrasta `CampaignHoverTooltip` (o ramo Popover a usa para a tooltip do B23) para uma rota que ainda não tinha nenhum chamador de overlay — lideranças já tinha, pela coluna Status. Não muda a recomendação abaixo; muda a linha de base contra a qual ela será medida.
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.
**Parente, não duplicata — o rebase em `main` trouxe isto à vista:** o **B33+** já registra que `/campanha/municipios` embarca ~10 kB gzip do mesmo catálogo, e o gatilho de lá já disparou duas vezes (B41 ✓, B42 ✓) sem ninguém pagar. Os dois são a mesma classe de problema com **causas opostas**, e a correção não é a mesma: lá o catálogo entra por **acidente** (um `isMunicipalitySlug` em `municipalityListUrl.ts` arrasta o módulo inteiro para três componentes de filtro que só queriam um predicado), e o conserto é mover o predicado para um módulo client-safe; aqui ele entra **de propósito**, porque a célula precisa mesmo de nome/região/ZE, e o conserto é escolher quem carrega o dado — bundle ou payload. Quem atacar um não fecha o outro; quem atacar os dois deve fazê-lo na mesma passada, porque a medição de First Load JS das três rotas é a mesma bancada.
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.
**A releitura pós-rebase alargou o alvo: são ~165 linhas, não ~105, e o nome está errado.** Além da medição, os dois arquivos repetem a adoção de props por `sameIdSet` (~7/~12), o derivado `showAll`/`measuring`/`clamping` (~11/~15), o `expandToggle` (~32/~20) **e** o delta otimista com rollback por delta e toast (~39/~48). O que sai é `useRelationChipCell` — medição **mais** o redutor otimista —, e as duas metades da medição são idênticas até o `+ 1` de epsilon no `lastVisibleTop`. Consequência de planejamento: **o B37 não retira este débito**. B37 pluga uma terceira relação na célula compartilhada sem máquina nova; quem fecha o F2 é quem deletar `LeadershipStateDeputyRelationCell`, que é o módulo do qual a cópia sobreviveu.
## 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' ? <Badge asChild><Link/></Badge> : <Badge/>` 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.
- **`searchInputProps(surface)`**: o input de busca está escrito nas duas superfícies com **oito** props idênticas (`role`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, `onKeyDown`, `aria-label` e o corpo `setQuery` + `setFeedback(null)`), divergindo só no `setOpen(true)`/`onFocus`/`onBlur` do inline. A divergência é **correta**, e é justamente por isso que deveria ser explícita: uma factory espalhada nos dois, com os overrides do inline escritos depois do spread, poupa ~16 linhas e troca uma assimetria inferida por uma declarada.
- **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.

File diff suppressed because one or more lines are too long

View file

@ -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<CampaignFormActionState> =>
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<CampaignFormActionState> =>
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,

View file

@ -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}
/>

View file

@ -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

View file

@ -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<CampaignFormActionState> =>
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.',
})

View file

@ -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

View file

@ -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<Record<string, string | string[] | undefined>>
}
const leadershipColumns = (
stateDeputyOptions: RelationCellOption[],
): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
const leadershipColumns = ({
stateDeputyOptions,
municipalityIndex,
addableMunicipalityIds,
}: {
stateDeputyOptions: RelationCellOption[]
municipalityIndex: MunicipalityPortfolioIndexEntry[]
/** Advisors may only link municipalities they administer; staff: undefined. */
addableMunicipalityIds?: ReadonlySet<number>
}): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
{
id: 'name',
mandatory: true,
@ -86,9 +100,26 @@ const leadershipColumns = (
},
{
id: 'municipalities',
head: <CampaignTableHead>Municípios</CampaignTableHead>,
cellClassName: 'max-w-64 whitespace-normal text-muted-foreground',
cell: (row) => row.municipalityNames.join(', ') || '—',
head: (
<CampaignTableHead description="Edite aqui: passe o mouse em um chip para remover, ou busque para adicionar. Um território ou ZE entra e sai como um bloco. Cada liderança fica entre 1 e 30 municípios.">
Municípios
</CampaignTableHead>
),
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={row.municipalityIDs}
municipalityIndex={municipalityIndex}
{...(addableMunicipalityIds ? { addableIds: addableMunicipalityIds } : {})}
minItems={1}
maxItems={MAX_LEADERSHIP_MUNICIPALITIES}
commitAction={setLeadershipMunicipalitiesFormAction}
drawerTitle="Municípios da liderança"
updateErrorMessage="Não foi possível atualizar os municípios."
/>
),
},
{
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 (
<CampaignPageShell>

View file

@ -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,
@ -247,7 +183,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
uniqueMunicipalityIds.map((id) => `municipality-advisors:${id}`),
)
let lastSlug: string | undefined
// Every município actually changed, not just the last one: since B34 this
// is the only advisor write path, and a território chip moves up to 30 in
// one call — revalidating one would leave 29 detail pages stale.
const changedSlugs: string[] = []
for (const municipalityId of uniqueMunicipalityIds) {
const municipality = await payload.findByID({
@ -273,10 +212,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
overrideAccess: true,
req,
})
if (typeof municipality.slug === 'string') lastSlug = municipality.slug
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
return { slug: lastSlug }
return { slugs: changedSlugs }
},
{ beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' },
)
@ -285,7 +224,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalitiesBatchInput) => {
const { payload, actor } = await getCampaignActionContext()
const result = await setAdvisorMunicipalitiesBatchRecord(payload, actor, input)
revalidateMunicipalityListPaths({ slug: result.slug })
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of result.slugs) {
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
}
revalidateAdvisorPaths(input.advisorId)
return result
}

View file

@ -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
}

View file

@ -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<CampaignFormActionState>
batchAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
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<MunicipalityRef[] | null>(null)
const [expanded, setExpanded] = useState(false)
const [collapsedHeight, setCollapsedHeight] = useState<number | null>(null)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const [, startTransition] = useTransition()
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const chipRowRef = useRef<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[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<number, AdvisorMunicipalityIndexEntry>()
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 ? (
<button
type="button"
data-portfolio-toggle
className="px-1 text-xs font-medium text-primary underline-offset-4 hover:underline"
onClick={(event) => {
event.stopPropagation()
const next = !showAllChips
setExpanded(next)
// While editing, focus is what keeps the cell open — collapsing must release it.
if (!next) {
setOpen(false)
inputRef.current?.blur()
}
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
if (!editing) {
return (
<div className={CELL_WRAPPER_CLASS}>
<div
ref={chipRowRef}
className={cn(CHIP_ROW_CLASS, clamping && 'max-h-18')}
style={chipRowStyle}
>
{chips.length === 0 ? (
<span className="px-1 text-sm text-muted-foreground">—</span>
) : (
visibleChips.map((chip) =>
chip.kind === 'territory' ? (
<Badge
key={chip.key}
data-portfolio-chip
variant="secondary"
className="max-w-full gap-1 font-normal"
>
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
<span className="truncate">{chip.label}</span>
</Badge>
) : (
<Badge
key={chip.key}
variant="secondary"
className="max-w-full font-normal"
asChild
>
<Link
href={`/campanha/municipios/${chip.slug}`}
data-portfolio-chip
className="truncate underline-offset-4 hover:underline"
>
{chip.label}
</Link>
</Badge>
),
)
)}
{expandToggle}
</div>
</div>
)
}
return (
<div
ref={rootRef}
className={cn(CELL_WRAPPER_CLASS, 'outline-none hover:bg-muted/40 focus-within:bg-muted/40')}
onClick={(event) => {
if ((event.target as HTMLElement).closest('[data-chip-remove]')) return
inputRef.current?.focus()
setOpen(true)
}}
>
<div
ref={chipRowRef}
className={cn(CHIP_ROW_CLASS, clamping && 'max-h-18')}
style={chipRowStyle}
>
{visibleChips.map((chip) => (
<button
key={chip.key}
type="button"
data-chip-remove
data-portfolio-chip
aria-label={`Remover ${chip.label}`}
className="inline-flex max-w-full"
onClick={(event) => {
event.stopPropagation()
removeChip(chip)
}}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
{chip.kind === 'territory' ? (
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
) : null}
<span className="truncate">{chip.label}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
{expandToggle}
<input
ref={inputRef}
value={query}
onChange={(event) => {
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"
/>
</div>
{open && query.trim() ? (
<div
role="listbox"
className="absolute z-30 mt-1 max-h-64 w-[min(100%,22rem)] overflow-auto rounded-lg border bg-popover p-1 shadow-md"
>
{hits.length === 0 ? (
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
) : (
hits.map((hit) => (
<button
key={hit.key}
type="button"
role="option"
aria-selected={false}
className="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onMouseDown={(event) => event.preventDefault()}
onClick={() => pickHit(hit)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{hit.label}</span>
<span className="block text-xs text-muted-foreground">
{hit.kind === 'municipality'
? 'Município'
: hit.kind === 'territory'
? `Território · ${hit.count} municípios`
: `Zona eleitoral · ${hit.count} municípios`}
</span>
</span>
</button>
))
)}
</div>
) : null}
</div>
)
}

View file

@ -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<CampaignFormActionState>
membershipAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
batchAction: (
municipalitiesAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
@ -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)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar
Editar nome e contato
</Button>
)}
<Button type="button" className="min-h-11" onClick={startDraft} disabled={Boolean(draft)}>
@ -254,17 +257,18 @@ export const AdvisorsTable = ({
/>
</TableCell>
<TableCell>
<AdvisorMunicipalityCell
advisorId={null}
municipalities={draft.municipalities}
<MunicipalityPortfolioCell
ownerId={null}
ownerName={draft.name.trim() || 'um novo assessor'}
municipalityIds={draft.municipalityIds}
municipalityIndex={municipalityIndex}
editing
draft
onDraftChange={(municipalities) =>
setDraft((current) => (current ? { ...current, municipalities } : current))
onDraftChange={(municipalityIds) =>
setDraft((current) => (current ? { ...current, municipalityIds } : current))
}
membershipAction={membershipAction}
batchAction={batchAction}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">
@ -365,14 +369,14 @@ export const AdvisorsTable = ({
)}
</TableCell>
<TableCell>
<AdvisorMunicipalityCell
advisorId={row.id}
municipalities={row.municipalities}
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={municipalityIdsByAdvisor.get(row.id) ?? []}
municipalityIndex={municipalityIndex}
editing={editing}
membershipAction={membershipAction}
batchAction={batchAction}
onPersisted={refresh}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">

View file

@ -2,13 +2,11 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint'
import { Badge } from '@/components/ui/Badge'
import { Progress } from '@/components/ui/Progress'
import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip'
import {
CAMPAIGN_CONCEPTS_PATH,
campaignConceptHref,

View file

@ -72,9 +72,9 @@ export const MunicipalityLeadershipsPanel = ({
))}
</div>
</div>
{leadership.municipalityNames.length > 1 ? (
{leadership.municipalityIDs.length > 1 ? (
<span className="text-sm text-muted-foreground">
Atua em {leadership.municipalityNames.length} municípios
Atua em {leadership.municipalityIDs.length} municípios
</span>
) : null}
</div>

View file

@ -3,12 +3,12 @@
import type { ReactNode } from 'react'
import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
} from '@/lib/campaignHoverTooltip'
import type {
MunicipalityFilterOption,
MunicipalityFilterParam,

View file

@ -3,12 +3,12 @@
import type { ReactNode } from 'react'
import { TerritoryHeaderFilter } from '@/components/campaign/municipality/TerritoryHeaderFilter'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
} from '@/lib/campaignHoverTooltip'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import {
buildTerritorySortHref,

View file

@ -41,20 +41,6 @@ type TappableElement = ReactElement<{
ref?: Ref<HTMLElement>
}>
/**
* Dotted-underline affordance for any label wrapped in a `CampaignHoverTooltip`
* (column headers, card metrics). Shared so the four call sites don't drift
* on the exact underline styling.
*/
export const campaignHoverExplanationClassName =
'underline decoration-dotted decoration-muted-foreground/70 underline-offset-2'
/** Maps a left/center/right layout align to the tooltip `align` it should open toward. */
export const campaignHoverTooltipAlign = (
align: 'left' | 'center' | 'right',
): 'start' | 'center' | 'end' =>
align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'
export const CampaignHoverTooltip = ({
content,
children,

View file

@ -1,11 +1,7 @@
import { Fragment, type ReactNode } from 'react'
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
import {
CampaignHoverTooltip,
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
Table,
TableBody,
@ -15,6 +11,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import {
campaignHoverExplanationClassName,
campaignHoverTooltipAlign,
} from '@/lib/campaignHoverTooltip'
import { cn } from '@/lib/utils'
/**

View file

@ -0,0 +1,807 @@
'use client'
import { MapPinIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
useTransition,
type KeyboardEvent,
} from 'react'
import { toast } from 'sonner'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import {
buildMunicipalityPortfolioChips,
searchMunicipalityPortfolio,
type MunicipalityPortfolioChip,
type MunicipalityPortfolioIndexEntry,
type MunicipalityPortfolioSearchHit,
} from '@/lib/municipalityPortfolio'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
const CHIP_GAP_PX = 6
const SEARCH_PLACEHOLDER = 'Buscar município, território ou ZE…'
const SEARCH_LABEL = 'Buscar município, território de identidade ou zona eleitoral'
/** Distinct from `SEARCH_LABEL`: two controls must not share one accessible name. */
const SUGGESTIONS_LABEL = 'Sugestões de municípios'
const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
if (hit.kind === 'municipality') return 'Município'
if (hit.kind === 'territory') return `Território · ${hit.count} municípios`
return `Zona eleitoral · ${hit.count} municípios`
}
const resultsMessage = (count: number): string => {
if (count === 0) return 'Nenhum resultado.'
return count === 1 ? '1 resultado.' : `${count} resultados.`
}
const withDelta = (
base: readonly number[],
changedIds: readonly number[],
assigned: boolean,
): number[] => {
const next = new Set(base)
for (const id of changedIds) {
if (assigned) next.add(id)
else next.delete(id)
}
return [...next]
}
type SuggestionSurface = 'inline' | 'drawer'
type MunicipalityPortfolioCellProps = {
/** `null` in draft mode (a row that does not exist yet). */
ownerId: number | null
/** Whose portfolio this is — spoken in the aria-labels and the Drawer. */
ownerName: string
municipalityIds: number[]
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
/**
* Ids the actor may ADD. Omit for "the whole catalog". Chips always render from
* the full index: an advisor must see (and be able to drop) a link outside
* their portfolio, they just cannot create one.
*/
addableIds?: ReadonlySet<number>
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
minItems?: number
/**
* Ceiling the relation enforces server-side. Checked here so a território that
* would blow the cap is refused with the reason BEFORE the optimistic apply —
* the server rejection would otherwise revert a whole batch with a message
* that never says which limit was hit.
*/
maxItems?: number
/** Draft mode (new row): keep changes local and report them upward. */
draft?: boolean
onDraftChange?: (municipalityIds: number[]) => void
/** Sends `ownerId`, repeated `municipalityIds` and `assigned`. */
commitAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
drawerTitle: string
updateErrorMessage: string
}
export const MunicipalityPortfolioCell = ({
ownerId,
ownerName,
municipalityIds,
municipalityIndex,
addableIds,
minItems = 0,
maxItems,
draft = false,
onDraftChange,
commitAction,
drawerTitle,
updateErrorMessage,
}: MunicipalityPortfolioCellProps) => {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [optimistic, setOptimistic] = useState<number[] | null>(null)
const [expanded, setExpanded] = useState(false)
/** `null` means "render every chip so the next effect can measure them". */
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
const [measureToken, setMeasureToken] = useState(0)
const lastRowWidth = useRef(0)
const [isPending, startTransition] = useTransition()
const [feedback, setFeedback] = useState<
{ kind: 'saved' } | { kind: 'error'; message: string } | null
>(null)
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
const [activeIndex, setActiveIndex] = useState(0)
/** Only keyboard movement should scroll: the mouse is already where it points. */
const activeFromKeyboard = useRef(false)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const chipRowRef = useRef<HTMLDivElement>(null)
const baseId = useId()
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
const optionId = useCallback(
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
[baseId],
)
useEffect(() => {
setOptimistic((current) => {
if (!current) return null
return sameIdSet(current, municipalityIds) ? null : current
})
}, [municipalityIds])
const effectiveIds = optimistic ?? municipalityIds
/**
* The baseline a deferred delta falls back to, which cannot be the render's
* `municipalityIds`: the toast's "Desfazer" outlives the render that created
* it, and by the time it fires the optimistic value has usually reconciled to
* null — so the functional updater takes the fallback branch and would rebuild
* the row from a set that predates every edit made since. That divergence is
* permanent, because the reconcile effect above only clears on an exact match.
*/
const latestIds = useRef(municipalityIds)
useEffect(() => {
latestIds.current = effectiveIds
}, [effectiveIds])
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
const chips = useMemo(
() => buildMunicipalityPortfolioChips(effectiveIds, municipalityIndex),
[effectiveIds, municipalityIndex],
)
/**
* Suggestions are scoped, chips are not: filtering the index the search reads
* also shrinks the território / ZE hits to what the actor may actually add,
* instead of offering a batch the server would reject halfway.
*/
const searchIndex = useMemo(
() =>
addableIds
? municipalityIndex.filter((entry) => addableIds.has(entry.id))
: municipalityIndex,
[addableIds, municipalityIndex],
)
const searching = query.trim().length > 0
/**
* Per surface, because Radix can close the popover with text still in the
* input — and because the inline list is PORTALED, so `pointer-coarse:hidden`
* on the cell would not hide it while the Drawer is up.
*/
const inlineSuggesting = searching && open && !drawerOpen
const drawerSuggesting = searching && drawerOpen
const suggesting = inlineSuggesting || drawerSuggesting
const hits = useMemo(
() => (suggesting ? searchMunicipalityPortfolio(query, searchIndex, assignedIds) : []),
[suggesting, query, searchIndex, assignedIds],
)
const hitsKey = hits.map((hit) => hit.key).join('|')
useEffect(() => {
setActiveIndex(0)
}, [hitsKey])
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
useEffect(() => {
if (!activeFromKeyboard.current) return
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
block: 'nearest',
})
}, [activeIndex, activeSurface, optionId])
/**
* 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. The remove
// button is absolutely positioned, so revealing it never changes a chip's box.
const chipsKey = chips.map((chip) => chip.key).join('|')
useEffect(() => {
invalidateMeasurement()
}, [chipsKey, 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<HTMLElement>('[data-portfolio-chip]')]
if (chipElements.length === 0) {
setVisibleChipCount(0)
return
}
const rowRect = row.getBoundingClientRect()
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
const lastVisibleTop =
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
// The toggle — and the search input — must share the last visible line,
// otherwise they wrap into a line nobody asked for. The input's box is read
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
// leftover of the row, which would reserve the whole line. A zero-width
// rect means the input is `display: none` (coarse pointer), where the row
// has no trailing control to make room for at all.
const toggleWidth =
row.querySelector<HTMLElement>('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0
const inputElement = inputRef.current
const inputWidth =
inputElement && inputElement.getBoundingClientRect().width > 0
? Number.parseFloat(getComputedStyle(inputElement).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])
/**
* Every delta is applied functionally, forward and back: two chips toggled in
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
* each compute from the set their own render captured and drop the other's work.
*/
const commit = (changedIds: number[], assigned: boolean) => {
if (changedIds.length === 0) return
if (draft) {
// Advanced synchronously as well: the parent holds the draft in state, so
// two toggles batched into one tick would otherwise both read the prop of
// the render they were queued from and the second would drop the first.
const next = withDelta(latestIds.current, changedIds, assigned)
latestIds.current = next
onDraftChange?.(next)
} else {
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
}
setQuery('')
setOpen(false)
setFeedback(null)
if (draft) return
if (ownerId === null) return
const formData = new FormData()
formData.set('ownerId', String(ownerId))
formData.set('assigned', assigned ? 'true' : 'false')
for (const id of changedIds) formData.append('municipalityIds', String(id))
startTransition(async () => {
const result = await commitAction({}, formData)
if (result.status === 'success') {
setFeedback({ kind: 'saved' })
// A território/ZE removal drops up to 30 links in one tap, and the row
// it left behind carries no trace of what was there. Single removals
// are one search away from being restored and get no toast.
if (!assigned && changedIds.length > 1) {
toast.success(`${changedIds.length} municípios removidos.`, {
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
})
}
return
}
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
})
}
const chipMunicipalityIds = (chip: MunicipalityPortfolioChip): number[] =>
chip.kind === 'territory' ? chip.municipalityIds : [chip.municipalityId]
/** The relation's floor, refused here so the server never has to say no. */
const removalFloorReason = (chip: MunicipalityPortfolioChip): string | null =>
assignedIds.size - chipMunicipalityIds(chip).length < minItems
? `${ownerName} precisa de pelo menos ${minItems === 1 ? 'um município' : `${minItems} municípios`}`
: null
const additionCapReason = (additions: number[]): string | null =>
maxItems !== undefined && assignedIds.size + additions.length > maxItems
? `${ownerName} aceita no máximo ${maxItems} municípios.`
: null
/** A batch chip says how much it removes, in the label a screen reader reads. */
const chipScopeLabel = (chip: MunicipalityPortfolioChip): string =>
chip.kind === 'territory'
? `${chip.label} — ${chip.municipalityIds.length} municípios`
: chip.label
const chipRemoveLabel = (chip: MunicipalityPortfolioChip, floorReason: string | null): string =>
floorReason
? `${floorReason} — não é possível remover ${chip.label}`
: `Remover ${chipScopeLabel(chip)}`
/**
* Refusals explain themselves on attempt instead of going silent: the control
* keeps `aria-disabled` (so it stays in the tab order and can still be
* activated) rather than `disabled`, which would hide the only copy of the
* reason from every keyboard user.
*/
const attemptRemove = (chip: MunicipalityPortfolioChip) => {
const floorReason = removalFloorReason(chip)
if (floorReason) {
toast.error(`${floorReason}.`)
return
}
commit(chipMunicipalityIds(chip), false)
}
const hitMunicipalityIds = (hit: MunicipalityPortfolioSearchHit): number[] =>
hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds
const pickHit = (hit: MunicipalityPortfolioSearchHit) => {
const additions = hitMunicipalityIds(hit)
const capReason = additionCapReason(additions)
if (capReason) {
toast.error(capReason)
return
}
commit(additions, true)
}
const closeSuggestions = () => {
setOpen(false)
setQuery('')
}
/**
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
* abandons the search. Without it the suggestions are mouse-only.
*/
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (hits.length === 0) return
event.preventDefault()
const delta = event.key === 'ArrowDown' ? 1 : -1
activeFromKeyboard.current = true
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
return
}
if (event.key === 'Home' || event.key === 'End') {
if (hits.length === 0) return
event.preventDefault()
activeFromKeyboard.current = true
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
return
}
if (event.key === 'Enter') {
const hit = hits[activeIndex]
if (!hit) return
event.preventDefault()
pickHit(hit)
return
}
if (event.key === 'Escape') {
if (drawerOpen) {
setQuery('')
return
}
closeSuggestions()
inputRef.current?.blur()
}
}
// Editing needs every chip reachable, so focusing the cell always expands it.
const showAllChips = expanded || 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
/**
* One region, four things to say, in this order: a save in flight outranks
* everything; a failure outranks the search; and the search outranks a past
* success, otherwise "Municípios salvos." would shadow every later result
* count for the rest of the session.
*/
const statusMessage = isPending
? 'Salvando municípios…'
: feedback?.kind === 'error'
? feedback.message
: suggesting
? resultsMessage(hits.length)
: feedback?.kind === 'saved'
? 'Municípios salvos.'
: ''
/**
* Mounted while measuring so its width is reserved on the last line, but kept
* out of the tab order and the accessibility tree until the overflow it
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
* above it, so a tap opens the full list instead of expanding in place.
*/
const expandToggle =
measuring || hasHiddenChips ? (
<button
type="button"
data-portfolio-toggle
tabIndex={measuring ? -1 : undefined}
aria-hidden={measuring || undefined}
className={cn(
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
measuring && 'invisible pointer-events-none',
)}
onClick={(event) => {
event.stopPropagation()
const next = !showAllChips
setExpanded(next)
// Focus is what keeps the cell open — collapsing must release it.
if (!next) {
setOpen(false)
inputRef.current?.blur()
}
}}
>
{showAllChips ? 'Ver menos' : 'Ver mais…'}
</button>
) : null
const chipBody = (chip: MunicipalityPortfolioChip) => (
<>
{chip.kind === 'territory' ? (
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
) : null}
<span className="truncate">{chip.label}</span>
{chip.kind === 'territory' ? (
<span className="shrink-0 text-xs tabular-nums opacity-70">
{chip.municipalityIds.length}
</span>
) : null}
</>
)
/**
* The remove button is a sibling of the chip link (never nested in it) and sits
* absolutely on its corner, so revealing it on hover does not resize the chip
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
* is permanently visible at a real tap size.
*/
const inlineChip = (chip: MunicipalityPortfolioChip) => {
const floorReason = removalFloorReason(chip)
return (
<span
key={chip.key}
data-portfolio-chip
className="group/chip relative inline-flex max-w-full"
>
{chip.kind === 'municipality' ? (
<Badge variant="secondary" className="max-w-full font-normal" asChild>
<Link
href={`/campanha/municipios/${chip.slug}`}
className="truncate underline-offset-4 hover:underline"
>
{chip.label}
</Link>
</Badge>
) : (
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<button
type="button"
data-chip-remove
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
className={cn(
// `border-muted-foreground/70`: the default 12%-alpha `border` token
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
"before:absolute before:-inset-1 before:content-['']",
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
floorReason
? 'cursor-not-allowed'
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
)}
onClick={(event) => {
event.stopPropagation()
attemptRemove(chip)
}}
>
<XIcon className="size-2.5" aria-hidden="true" />
</button>
</span>
)
}
const suggestionOption = (
hit: MunicipalityPortfolioSearchHit,
index: number,
surface: SuggestionSurface,
) => {
const capped = additionCapReason(hitMunicipalityIds(hit)) !== null
const active = index === activeIndex
return (
<button
key={hit.key}
id={optionId(surface, index)}
type="button"
role="option"
aria-selected={active}
aria-disabled={capped || undefined}
tabIndex={-1}
className={cn(
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
active && 'bg-muted',
capped ? 'opacity-60' : 'hover:bg-muted',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => {
activeFromKeyboard.current = false
setActiveIndex(index)
}}
onClick={() => pickHit(hit)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{hit.label}</span>
<span className="block text-xs text-muted-foreground">
{hitDescription(hit)}
{capped ? ' · excede o limite' : ''}
</span>
</span>
</button>
)
}
/**
* The listbox stays mounted while empty (the input's `aria-controls` must
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
* reader would count it as a selectable option.
*/
const suggestionList = (surface: SuggestionSurface) => (
<>
<div role="listbox" id={listboxId(surface)} aria-label={SUGGESTIONS_LABEL}>
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
</div>
{hits.length === 0 ? (
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
) : null}
</>
)
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
return (
<div
ref={rootRef}
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
// the write is fire-and-forget, and a cell that never reacts reads as
// "nothing happened" until the next navigation proves otherwise.
aria-busy={isPending || undefined}
// Same box in both pointer modes so nothing reflows when the row is touched.
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
// Reached only on a fine pointer: the coarse overlay below covers the cell
// and stops the click, so the pointer policy stays in the media queries.
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('[data-chip-remove]') || target.closest('a')) return
inputRef.current?.focus()
setOpen(true)
}}
>
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
ref={chipRowRef}
className={cn(
'flex min-h-8 flex-wrap items-center gap-1.5',
clamping && 'max-h-18 overflow-hidden',
isPending && 'opacity-60 transition-opacity',
)}
>
{chips.length === 0 && !searching ? (
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
) : null}
{visibleChips.map(inlineChip)}
{expandToggle}
<input
ref={inputRef}
value={query}
role="combobox"
aria-expanded={inlineSuggesting}
aria-controls={listboxId('inline')}
aria-autocomplete="list"
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => {
if (!rootRef.current?.contains(document.activeElement)) {
closeSuggestions()
}
}, 120)
}}
onKeyDown={onSearchKeyDown}
placeholder={chips.length ? 'Adicionar…' : SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
/>
</div>
</PopoverAnchor>
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
<PopoverContent
align="start"
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
// The input owns focus the whole time — the list is a suggestion
// surface, not a dialog, and must never steal or restore focus.
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{suggestionList('inline')}
</PopoverContent>
</Popover>
{/*
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
* table cannot be asked to tell "open the município" from "delete it". The
* links it covers come back inside the Drawer.
*
* `variant` is hardcoded because the container takes its policy from the
* call site (B42) — and here the policy is the `pointer:` media query on
* the trigger, not the viewport, so a touch laptop gets this sheet and a
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
* undoes the shared class's hover: this trigger is an invisible pane over
* the whole cell, and tinting it would tint the chips it covers.
*/}
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={`Editar municípios de ${ownerName}`}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other two in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.kind === 'municipality' ? (
<Link
href={`/campanha/municipios/${chip.slug}`}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
</>
</CampaignCellEditOverlay>
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}
</p>
</div>
)
}

View file

@ -13,6 +13,11 @@ const PopoverTrigger = ({ ...props }: React.ComponentProps<typeof PopoverPrimiti
<PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
)
/** Positions the content against an element other than the trigger. */
const PopoverAnchor = ({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) => (
<PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
)
const PopoverContent = ({
className,
align = 'center',
@ -33,4 +38,4 @@ const PopoverContent = ({
</PopoverPrimitive.Portal>
)
export { Popover, PopoverContent, PopoverTrigger }
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View file

@ -1,248 +0,0 @@
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { citiesForTseZone } from '@/lib/bahiaTseZones'
import {
municipalityCatalog,
municipalityCatalogEntriesForCity,
type MunicipalityCatalogEntry,
} from '@/lib/municipalityCatalog'
import { matchesAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
export type AdvisorMunicipalityIndexEntry = {
id: number
name: string
slug: string
region: BahiaIdentityTerritory
city: string
zoneNumber: number | null
}
export type AdvisorPortfolioChip =
| {
kind: 'territory'
key: string
label: string
territory: BahiaIdentityTerritory
municipalityIds: number[]
}
| {
kind: 'municipality'
key: string
label: string
municipalityId: number
slug: string
}
export type AdvisorPortfolioSearchHit =
| {
kind: 'municipality'
key: string
label: string
municipalityId: number
}
| {
kind: 'territory'
key: string
label: string
territory: BahiaIdentityTerritory
municipalityIds: number[]
count: number
}
| {
kind: 'zone'
key: string
label: string
zoneNumber: number
municipalityIds: number[]
count: number
}
const catalogEntriesForTerritory = (
territory: BahiaIdentityTerritory,
): readonly MunicipalityCatalogEntry[] =>
municipalityCatalog.filter((entry) => entry.region === territory)
export const catalogEntriesForTseZone = (
zoneNumber: number,
): readonly MunicipalityCatalogEntry[] => {
const entries: MunicipalityCatalogEntry[] = []
for (const city of citiesForTseZone(zoneNumber)) {
const cityEntries = municipalityCatalogEntriesForCity(city)
const zoneScoped = cityEntries.filter(
(entry) => entry.kind === 'zona' && entry.zoneNumber === zoneNumber,
)
if (zoneScoped.length > 0) {
entries.push(...zoneScoped)
continue
}
const whole = cityEntries.find((entry) => entry.kind === 'municipio')
if (whole) entries.push(whole)
}
return entries
}
const indexBySlug = (
entries: readonly AdvisorMunicipalityIndexEntry[],
): Map<string, AdvisorMunicipalityIndexEntry> =>
new Map(entries.map((entry) => [entry.slug, entry]))
const municipalityIdsForTerritory = (
territory: BahiaIdentityTerritory,
bySlug: Map<string, AdvisorMunicipalityIndexEntry>,
): number[] => {
const ids: number[] = []
for (const entry of catalogEntriesForTerritory(territory)) {
const resolved = bySlug.get(entry.slug)
if (resolved) ids.push(resolved.id)
}
return ids
}
const municipalityIdsForTseZone = (
zoneNumber: number,
bySlug: Map<string, AdvisorMunicipalityIndexEntry>,
): number[] => {
const ids: number[] = []
for (const entry of catalogEntriesForTseZone(zoneNumber)) {
const resolved = bySlug.get(entry.slug)
if (resolved) ids.push(resolved.id)
}
return ids
}
/** Collapse complete territory memberships into a single territory chip. */
export const buildAdvisorPortfolioChips = (
assignedMunicipalities: readonly { id: number; name: string; slug: string }[],
index: readonly AdvisorMunicipalityIndexEntry[],
): AdvisorPortfolioChip[] => {
const byId = new Map(index.map((entry) => [entry.id, entry]))
const assigned = new Map(
assignedMunicipalities.map((municipality) => [municipality.id, municipality]),
)
const remaining = new Set(assigned.keys())
const chips: AdvisorPortfolioChip[] = []
// Prefer the live index's `region` (DB) over catalog→slug resolution so chips
// collapse even when slug casing/alias differs across environments.
for (const territory of bahiaIdentityTerritories) {
const territoryIds = index
.filter((entry) => entry.region === territory)
.map((entry) => entry.id)
if (territoryIds.length === 0) continue
if (!territoryIds.every((id) => remaining.has(id))) continue
chips.push({
kind: 'territory',
key: `territory:${territory}`,
label: territory,
territory,
municipalityIds: territoryIds,
})
for (const id of territoryIds) remaining.delete(id)
}
const leftover = [...remaining]
.map((id) => assigned.get(id))
.filter((municipality): municipality is { id: number; name: string; slug: string } =>
Boolean(municipality),
)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
for (const municipality of leftover) {
const indexed = byId.get(municipality.id)
chips.push({
kind: 'municipality',
key: `municipality:${municipality.id}`,
label: municipality.name,
municipalityId: municipality.id,
slug: indexed?.slug ?? municipality.slug,
})
}
return chips
}
const uniqueZoneNumbers = (): number[] => {
const zones = new Set<number>()
for (const entry of municipalityCatalog) {
for (const zone of entry.tseZones) zones.add(zone)
}
return [...zones].sort((left, right) => left - right)
}
const zoneLabel = (zoneNumber: number): string => `ZE ${zoneNumber}`
const queryLooksLikeZone = (query: string): number | null => {
const normalized = normalizeSearchPhrase(query)
const match = normalized.match(/^(?:ze|zona)?\s*(\d{1,3})$/)
if (!match) return null
const zoneNumber = Number(match[1])
return Number.isInteger(zoneNumber) && zoneNumber > 0 ? zoneNumber : null
}
export const searchAdvisorPortfolio = (
query: string,
index: readonly AdvisorMunicipalityIndexEntry[],
alreadyAssignedIds: ReadonlySet<number>,
limit = 12,
): AdvisorPortfolioSearchHit[] => {
const trimmed = query.trim()
if (!trimmed) return []
const bySlug = indexBySlug(index)
const hits: AdvisorPortfolioSearchHit[] = []
const zoneFromQuery = queryLooksLikeZone(trimmed)
for (const entry of index) {
if (alreadyAssignedIds.has(entry.id)) continue
if (!matchesAtWordStart(entry.name, trimmed)) continue
hits.push({
kind: 'municipality',
key: `municipality:${entry.id}`,
label: entry.name,
municipalityId: entry.id,
})
if (hits.length >= limit) return hits
}
for (const territory of bahiaIdentityTerritories) {
if (!matchesAtWordStart(territory, trimmed)) continue
const municipalityIds = municipalityIdsForTerritory(territory, bySlug).filter(
(id) => !alreadyAssignedIds.has(id),
)
if (municipalityIds.length === 0) continue
hits.push({
kind: 'territory',
key: `territory:${territory}`,
label: territory,
territory,
municipalityIds,
count: municipalityIds.length,
})
if (hits.length >= limit) return hits
}
const zonesToScan =
zoneFromQuery !== null
? [zoneFromQuery]
: uniqueZoneNumbers().filter((zoneNumber) =>
matchesAtWordStart(zoneLabel(zoneNumber), trimmed),
)
for (const zoneNumber of zonesToScan) {
const municipalityIds = municipalityIdsForTseZone(zoneNumber, bySlug).filter(
(id) => !alreadyAssignedIds.has(id),
)
if (municipalityIds.length === 0) continue
hits.push({
kind: 'zone',
key: `zone:${zoneNumber}`,
label: zoneLabel(zoneNumber),
zoneNumber,
municipalityIds,
count: municipalityIds.length,
})
if (hits.length >= limit) return hits
}
return hits
}

View file

@ -0,0 +1,16 @@
/**
* Client-safe half of `CampaignHoverTooltip`. These must NOT live in that
* `'use client'` module: `CampaignTableHead` is a server component and renders
* the tooltip for a column `description`, and a `'use client'` module turns
* every export into a client reference that the server cannot call.
*/
/** Dotted-underline affordance for any label wrapped in a `CampaignHoverTooltip`. */
export const campaignHoverExplanationClassName =
'underline decoration-dotted decoration-muted-foreground/70 underline-offset-2'
/** Maps a left/center/right layout align to the tooltip `align` it should open toward. */
export const campaignHoverTooltipAlign = (
align: 'left' | 'center' | 'right',
): 'start' | 'center' | 'end' =>
align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'

View file

@ -0,0 +1,43 @@
import {
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
MAX_LEADERSHIP_MUNICIPALITIES,
} from '@/lib/schemas/leadership'
/**
* Returns the next municipality-id list after adding or removing a set of
* municipalities, plus the ids the write actually touches: `added` (what the
* caller scope-checks) and `changed` (what it revalidates — a requested id that
* was already in the desired state should not bust its route's cache). `null`
* when the leadership is already in the desired state
* (no write needed). Sibling of `nextStateDeputyIdsAfterMembership`, with two
* differences that come from the relation itself, not from taste: the change may
* carry a whole território/ZE at once (a set, not one id), and
* `leadership.municipalities` has a **floor of one** — the same invariant
* `requireAtLeastOneMunicipality` enforces in the collection, refused here too
* so the client cannot reach a state the collection would reject.
*/
export const nextMunicipalityIdsAfterLeadershipMembership = (
currentMunicipalityIDs: readonly number[],
municipalityIDs: readonly number[],
assigned: boolean,
): { next: number[]; added: number[]; changed: number[] } | null => {
const current = new Set(currentMunicipalityIDs)
const requested = new Set(municipalityIDs)
if (assigned) {
const added = [...requested].filter((id) => !current.has(id))
if (added.length === 0) return null
const next = [...currentMunicipalityIDs, ...added]
if (next.length > MAX_LEADERSHIP_MUNICIPALITIES) {
throw new Error(LEADERSHIP_MUNICIPALITY_CAP_MESSAGE)
}
return { next, added, changed: added }
}
const next = currentMunicipalityIDs.filter((id) => !requested.has(id))
if (next.length === currentMunicipalityIDs.length) return null
if (next.length === 0) throw new Error(LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE)
return { next, added: [], changed: currentMunicipalityIDs.filter((id) => requested.has(id)) }
}

View file

@ -0,0 +1,297 @@
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { citiesForTseZone } from '@/lib/bahiaTseZones'
import {
municipalityCatalog,
municipalityCatalogEntriesForCity,
type MunicipalityCatalogEntry,
} from '@/lib/municipalityCatalog'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
/**
* Pure catalog logic behind every municipality relation edited by chips
* (advisor carteira, leadership municipalities): group a set of assigned
* municipalities into chips (collapsing a complete identity territory into one)
* and search the catalog by município, território de identidade or zona
* eleitoral. Nothing here is advisor-specific.
*/
export type MunicipalityPortfolioIndexEntry = {
id: number
name: string
slug: string
region: BahiaIdentityTerritory
}
export type MunicipalityPortfolioChip =
| {
kind: 'territory'
key: string
label: string
territory: BahiaIdentityTerritory
municipalityIds: number[]
}
| {
kind: 'municipality'
key: string
label: string
municipalityId: number
slug: string
}
export type MunicipalityPortfolioSearchHit =
| {
kind: 'municipality'
key: string
label: string
municipalityId: number
}
| {
kind: 'territory'
key: string
label: string
territory: BahiaIdentityTerritory
municipalityIds: number[]
count: number
}
| {
kind: 'zone'
key: string
label: string
zoneNumber: number
municipalityIds: number[]
count: number
}
export const catalogEntriesForTseZone = (
zoneNumber: number,
): readonly MunicipalityCatalogEntry[] => {
const entries: MunicipalityCatalogEntry[] = []
for (const city of citiesForTseZone(zoneNumber)) {
const cityEntries = municipalityCatalogEntriesForCity(city)
const zoneScoped = cityEntries.filter(
(entry) => entry.kind === 'zona' && entry.zoneNumber === zoneNumber,
)
if (zoneScoped.length > 0) {
entries.push(...zoneScoped)
continue
}
const whole = cityEntries.find((entry) => entry.kind === 'municipio')
if (whole) entries.push(whole)
}
return entries
}
type PortfolioIndexDerivations = {
byId: Map<number, MunicipalityPortfolioIndexEntry>
bySlug: Map<string, MunicipalityPortfolioIndexEntry>
idsByTerritory: Map<BahiaIdentityTerritory, number[]>
normalizedNames: Map<number, string>
}
/**
* Everything derivable from the index alone, computed once per index array.
*
* The index is one shared reference for the whole table (the RSC builds it once
* and every row gets the same array), so a `WeakMap` keyed on it turns work that
* was O(rows × 27 × 435) into O(435) — and survives across renders, unlike a
* `useMemo` inside a row. It is dropped when the array is.
*/
const derivationsByIndex = new WeakMap<
readonly MunicipalityPortfolioIndexEntry[],
PortfolioIndexDerivations
>()
const portfolioIndexDerivations = (
index: readonly MunicipalityPortfolioIndexEntry[],
): PortfolioIndexDerivations => {
const cached = derivationsByIndex.get(index)
if (cached) return cached
const derived: PortfolioIndexDerivations = {
byId: new Map(),
bySlug: new Map(),
idsByTerritory: new Map(),
normalizedNames: new Map(),
}
for (const entry of index) {
derived.byId.set(entry.id, entry)
derived.bySlug.set(entry.slug, entry)
derived.normalizedNames.set(entry.id, normalizeSearchPhrase(entry.name))
const territoryIds = derived.idsByTerritory.get(entry.region)
if (territoryIds) territoryIds.push(entry.id)
else derived.idsByTerritory.set(entry.region, [entry.id])
}
derivationsByIndex.set(index, derived)
return derived
}
const municipalityIdsForTseZone = (
zoneNumber: number,
bySlug: Map<string, MunicipalityPortfolioIndexEntry>,
): number[] => {
const ids: number[] = []
for (const entry of catalogEntriesForTseZone(zoneNumber)) {
const resolved = bySlug.get(entry.slug)
if (resolved) ids.push(resolved.id)
}
return ids
}
/**
* Collapse complete territory memberships into a single territory chip.
*
* Labels and slugs come from `index` — the complete catalog by construction — so
* an id it does not know cannot be rendered (no name, no link) and is dropped
* rather than shown as an anonymous chip.
*/
export const buildMunicipalityPortfolioChips = (
assignedMunicipalityIds: readonly number[],
index: readonly MunicipalityPortfolioIndexEntry[],
): MunicipalityPortfolioChip[] => {
const { byId, idsByTerritory } = portfolioIndexDerivations(index)
const remaining = new Set(assignedMunicipalityIds)
const chips: MunicipalityPortfolioChip[] = []
// Prefer the live index's `region` (DB) over catalog→slug resolution so chips
// collapse even when slug casing/alias differs across environments.
for (const territory of bahiaIdentityTerritories) {
const territoryIds = idsByTerritory.get(territory)
if (!territoryIds || territoryIds.length === 0) continue
// A território never fits in fewer ids than it has; the typical leadership
// holds 1–3 and the smallest TI ~10, so this skips all 27 without scanning.
if (territoryIds.length > remaining.size) continue
if (!territoryIds.every((id) => remaining.has(id))) continue
chips.push({
kind: 'territory',
key: `territory:${territory}`,
label: territory,
territory,
// Copied out of the derivation: that array is memoized per index and
// shared by every row of the table, so handing it to a caller would turn
// any later sort/splice on a chip into corruption of the whole page.
municipalityIds: [...territoryIds],
})
for (const id of territoryIds) remaining.delete(id)
}
const leftover = [...remaining]
.map((id) => byId.get(id))
.filter((entry): entry is MunicipalityPortfolioIndexEntry => entry !== undefined)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
for (const entry of leftover) {
chips.push({
kind: 'municipality',
key: `municipality:${entry.id}`,
label: entry.name,
municipalityId: entry.id,
slug: entry.slug,
})
}
return chips
}
const zoneLabel = (zoneNumber: number): string => `ZE ${zoneNumber}`
/** Pure function of a frozen table: the 199 distinct ZE, with labels pre-normalized. */
const NORMALIZED_ZONE_LABELS: readonly { zoneNumber: number; normalizedLabel: string }[] = (() => {
const zones = new Set<number>()
for (const entry of municipalityCatalog) {
for (const zone of entry.tseZones) zones.add(zone)
}
return [...zones]
.sort((left, right) => left - right)
.map((zoneNumber) => ({
zoneNumber,
normalizedLabel: normalizeSearchPhrase(zoneLabel(zoneNumber)),
}))
})()
const NORMALIZED_TERRITORIES: readonly { territory: BahiaIdentityTerritory; normalized: string }[] =
bahiaIdentityTerritories.map((territory) => ({
territory,
normalized: normalizeSearchPhrase(territory),
}))
const queryLooksLikeZone = (normalizedQuery: string): number | null => {
const match = normalizedQuery.match(/^(?:ze|zona)?\s*(\d{1,3})$/)
if (!match) return null
const zoneNumber = Number(match[1])
return Number.isInteger(zoneNumber) && zoneNumber > 0 ? zoneNumber : null
}
export const searchMunicipalityPortfolio = (
query: string,
index: readonly MunicipalityPortfolioIndexEntry[],
alreadyAssignedIds: ReadonlySet<number>,
limit = 12,
): MunicipalityPortfolioSearchHit[] => {
const trimmed = query.trim()
if (!trimmed) return []
// Normalize the query ONCE: it used to be re-normalized inside the matcher for
// each of ~660 candidates (435 names + 27 TI + up to 199 ZE labels).
const normalizedQuery = normalizeSearchPhrase(trimmed)
const { bySlug, idsByTerritory, normalizedNames } = portfolioIndexDerivations(index)
const hits: MunicipalityPortfolioSearchHit[] = []
const zoneFromQuery = queryLooksLikeZone(normalizedQuery)
for (const entry of index) {
if (alreadyAssignedIds.has(entry.id)) continue
if (!matchesNormalizedAtWordStart(normalizedNames.get(entry.id) ?? '', normalizedQuery))
continue
hits.push({
kind: 'municipality',
key: `municipality:${entry.id}`,
label: entry.name,
municipalityId: entry.id,
})
if (hits.length >= limit) return hits
}
for (const { territory, normalized } of NORMALIZED_TERRITORIES) {
if (!matchesNormalizedAtWordStart(normalized, normalizedQuery)) continue
// Same source as the chip collapse above (the live index's `region`), so a
// território cannot be offered under one rule and fail to collapse under another.
const municipalityIds = (idsByTerritory.get(territory) ?? []).filter(
(id) => !alreadyAssignedIds.has(id),
)
if (municipalityIds.length === 0) continue
hits.push({
kind: 'territory',
key: `territory:${territory}`,
label: territory,
territory,
municipalityIds,
count: municipalityIds.length,
})
if (hits.length >= limit) return hits
}
const zonesToScan =
zoneFromQuery !== null
? [zoneFromQuery]
: NORMALIZED_ZONE_LABELS.filter((zone) =>
matchesNormalizedAtWordStart(zone.normalizedLabel, normalizedQuery),
).map((zone) => zone.zoneNumber)
for (const zoneNumber of zonesToScan) {
const municipalityIds = municipalityIdsForTseZone(zoneNumber, bySlug).filter(
(id) => !alreadyAssignedIds.has(id),
)
if (municipalityIds.length === 0) continue
hits.push({
kind: 'zone',
key: `zone:${zoneNumber}`,
label: zoneLabel(zoneNumber),
zoneNumber,
municipalityIds,
count: municipalityIds.length,
})
if (hits.length >= limit) return hits
}
return hits
}

View file

@ -62,12 +62,6 @@ export const advisorProfileUpdateSchema = z
{ message: 'Informe ao menos um campo para atualizar.' },
)
export const advisorMunicipalityMembershipSchema = z.object({
advisorId: positiveRelationshipId,
municipalityId: positiveRelationshipId,
assigned: z.boolean(),
})
export const advisorMunicipalitiesBatchSchema = z.object({
advisorId: positiveRelationshipId,
municipalityIds: z.array(positiveRelationshipId).min(1).max(435),
@ -80,6 +74,5 @@ export const advisorPasswordResetSchema = z.object({
export type AdvisorCreateInput = z.input<typeof advisorCreateSchema>
export type AdvisorProfileUpdateInput = z.input<typeof advisorProfileUpdateSchema>
export type AdvisorMunicipalityMembershipInput = z.input<typeof advisorMunicipalityMembershipSchema>
export type AdvisorMunicipalitiesBatchInput = z.input<typeof advisorMunicipalitiesBatchSchema>
export type AdvisorPasswordResetInput = z.input<typeof advisorPasswordResetSchema>

View file

@ -39,9 +39,18 @@ export const MAX_LEADERSHIP_MUNICIPALITIES = 30
const MAX_LEADERSHIP_ORGANIZATIONS = 20
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
export const LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE =
'Vincule a liderança a pelo menos um município.'
export const LEADERSHIP_MUNICIPALITY_CAP_MESSAGE = `Cada liderança aceita no máximo ${MAX_LEADERSHIP_MUNICIPALITIES} municípios.`
/** Thrown by the actions and matched verbatim by every route's `safeMessages`. */
export const LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE =
'Você só pode vincular lideranças aos municípios que assessora.'
const municipalitiesArraySchema = z
.array(positiveRelationshipId)
.min(1, 'Vincule a liderança a pelo menos um município.')
.min(1, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE)
.max(MAX_LEADERSHIP_MUNICIPALITIES)
.transform((ids) => [...new Set(ids)])
@ -94,3 +103,23 @@ export const leadershipStateDeputyMembershipSchema = z.object({
export type LeadershipStateDeputyMembershipInput = z.input<
typeof leadershipStateDeputyMembershipSchema
>
/**
* Delta writes for the "Municípios" column of `/campanha/liderancas` (B34).
* The batch variant carries a whole território/ZE, so the cap is the schema's
* own ceiling; the floor of one município is enforced against the *resulting*
* array by `nextMunicipalityIdsAfterLeadershipMembership`, not here.
*/
export const leadershipMunicipalitiesMembershipSchema = z.object({
leadershipId: positiveRelationshipId,
municipalityIds: z
.array(positiveRelationshipId)
.min(1)
.max(MAX_LEADERSHIP_MUNICIPALITIES)
.transform((ids) => [...new Set(ids)]),
assigned: z.boolean(),
})
export type LeadershipMunicipalitiesMembershipInput = z.input<
typeof leadershipMunicipalitiesMembershipSchema
>

View file

@ -7,11 +7,29 @@ export const normalizeSearchPhrase = (value: string): string =>
.replace(/\s+/g, ' ')
.trim()
/**
* Both sides already normalized. Callers that filter a fixed list against one
* query should normalize the query once and cache the labels, instead of paying
* an NFD pass plus three Unicode-property regexes per candidate.
*/
export const matchesNormalizedAtWordStart = (
normalizedLabel: string,
normalizedQuery: string,
): boolean => {
if (!normalizedQuery) return true
// `normalizeSearchPhrase` emits exactly `words.join(' ')` — alphanumeric runs,
// single-spaced, trimmed — so a word starts at position 0 and nowhere but
// after a space. Splitting the label and re-joining every suffix said the same
// thing while allocating an array and a string per word, per candidate.
return (
normalizedLabel.startsWith(normalizedQuery) || normalizedLabel.includes(` ${normalizedQuery}`)
)
}
export const matchesAtWordStart = (label: string, query: string): boolean => {
// Normalizing the query first: an empty one matches everything, and callers
// filter their whole option list with it every time a picker opens.
const normalizedQuery = normalizeSearchPhrase(query)
if (!normalizedQuery) return true
const normalizedLabel = normalizeSearchPhrase(label)
return normalizedLabel
.split(/[^\p{Letter}\p{Number}]+/u)
.some((_, index, words) => words.slice(index).join(' ').startsWith(normalizedQuery))
return matchesNormalizedAtWordStart(normalizeSearchPhrase(label), normalizedQuery)
}

View file

@ -2,8 +2,6 @@ import 'server-only'
import type { Payload } from 'payload'
import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
buildListHref,
firstValue,
@ -97,33 +95,6 @@ const municipalitiesByAdvisorIds = async (
return byAdvisor
}
/**
* Full municipality index for client-side portfolio search (município / TI / ZE).
* Intentional admin bypass after the unrestricted route gate.
*/
export const loadAdvisorMunicipalityIndex = async (
payload: Payload,
): Promise<AdvisorMunicipalityIndexEntry[]> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
sort: 'name',
select: { name: true, slug: true, region: true, city: true, zoneNumber: true },
overrideAccess: true,
})
return result.docs.map((municipality) => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
region: municipality.region as BahiaIdentityTerritory,
city: municipality.city,
zoneNumber: municipality.zoneNumber ?? null,
}))
}
/**
* Lists advisor accounts for the unrestricted management UI.
*

View file

@ -26,7 +26,13 @@ export type LeadershipRowViewModel = {
email: string | null
supportStatus: SupportStatus | null
sector: string | null
municipalityNames: string[]
/**
* Ids only: the "Municípios" cell renders chips through
* `buildMunicipalityPortfolioChips` over the shared portfolio index, which
* already carries every name/slug and is what collapses a whole território
* into one chip. Resolving names here too would be a second source of truth.
*/
municipalityIDs: number[]
organizationNames: string[]
stateDeputies: StateDeputySummary[]
hasAppAccess: boolean
@ -48,16 +54,15 @@ const contactSummary = (
return { id: Number(contact), name: 'Contato', phone: null, email: null }
}
const namesForIds = async (
const organizationNamesForIds = async (
payload: Payload,
collection: 'municipality' | 'organization',
ids: number[],
): Promise<Map<number, string>> => {
if (ids.length === 0) return new Map()
// Intentional admin bypass: display-name resolution for rows the actor
// already passed row-level access on.
const result = await payload.find({
collection,
collection: 'organization',
where: { id: { in: ids } },
depth: 0,
limit: 0,
@ -72,14 +77,9 @@ const toLeadershipRows = async (
payload: Payload,
docs: Leadership[],
): Promise<LeadershipRowViewModel[]> => {
const municipalityIDs = new Set<number>()
const organizationIDs = new Set<number>()
const stateDeputyIDs = new Set<number>()
for (const doc of docs) {
for (const municipality of doc.municipalities ?? []) {
const id = relationshipId(municipality)
if (id !== null) municipalityIDs.add(id)
}
for (const organization of doc.organizations ?? []) {
const id = relationshipId(organization)
if (id !== null) organizationIDs.add(id)
@ -90,9 +90,8 @@ const toLeadershipRows = async (
}
}
const [municipalityNames, organizationNames, stateDeputySummaries] = await Promise.all([
namesForIds(payload, 'municipality', [...municipalityIDs]),
namesForIds(payload, 'organization', [...organizationIDs]),
const [organizationNames, stateDeputySummaries] = await Promise.all([
organizationNamesForIds(payload, [...organizationIDs]),
loadStateDeputySummaries(payload, [...stateDeputyIDs]),
])
const stateDeputyById = new Map(stateDeputySummaries.map((summary) => [summary.id, summary]))
@ -107,10 +106,9 @@ const toLeadershipRows = async (
email: contact.email,
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
sector: doc.sector ?? null,
municipalityNames: (doc.municipalities ?? [])
municipalityIDs: (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
.map((id) => municipalityNames.get(id) ?? 'Município'),
.filter((id): id is number => id !== null),
organizationNames: (doc.organizations ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)

View file

@ -0,0 +1,40 @@
import 'server-only'
import type { Payload } from 'payload'
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
/**
* Full municipality index for client-side portfolio search (município / TI / ZE),
* shared by every surface that edits a municipality relation by chips
* (`/campanha/assessores`, `/campanha/liderancas`).
*
* Intentional admin bypass: read-only reference data (name/slug/geography) whose
* grouping into territory chips is only correct against the complete catalog —
* scoping it would break the "covers the whole TI" collapse for advisors. The
* route gate has already asserted staff; what an actor may *add* is restricted
* separately by the caller.
*/
export const loadMunicipalityPortfolioIndex = async (
payload: Payload,
): Promise<MunicipalityPortfolioIndexEntry[]> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
// User-visible: `searchMunicipalityPortfolio` walks this array in order and
// stops at `limit`, so the suggestion list is alphabetical only because this is.
sort: 'name',
select: { name: true, slug: true, region: true },
overrideAccess: true,
})
return result.docs.map((municipality) => ({
id: municipality.id,
name: municipality.name,
slug: municipality.slug,
region: municipality.region as BahiaIdentityTerritory,
}))
}

View file

@ -1,8 +1,9 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* `/campanha/liderancas` journeys. First e2e spec for this route — starts
* with the B32 support-status quick edit (Popover + auto-save, no toggle).
* `/campanha/liderancas` journeys: the B32 support-status quick edit (Popover +
* auto-save, no toggle) and the B34 municipality chips (inline on a fine
* pointer — no "Salvar", the "×" only on hover).
*/
test.describe('campaign leaderships list', () => {
@ -77,4 +78,115 @@ test.describe('campaign leaderships list', () => {
'Engajado',
)
})
test('coordinator adds and removes municipality chips in the cell (B34)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Carteira'),
email: `${fixtures.value('coordinator-municipalities')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const linked = await fixtures.claimMunicipality()
const added = await fixtures.claimMunicipality()
const keyboardQuery = await fixtures.claimMunicipality()
const contactName = fixtures.value('Liderança Municípios')
const contact = await campaign.payload.create({
collection: 'contact',
data: {
name: contactName,
phone: fixtures.phone(),
state: 'BA',
city: linked.name,
},
depth: 0,
})
const leadership = await campaign.payload.create({
collection: 'leadership',
data: { contact: contact.id, municipalities: [linked.id], supportStatus: 'a_abordar' },
depth: 0,
})
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
// The chip is still a link to the município — editing did not cost the
// navigation the column had before.
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveAttribute(
'href',
`/campanha/municipios/${linked.slug}`,
)
// Floor of one: the only chip cannot be removed.
await expect(
page.getByRole('button', { name: new RegExp(`não é possível remover ${linked.name}$`) }),
).toBeDisabled()
// Server actions POST to the page URL, so the response filter is the route
// itself — the optimistic chip renders before the write lands.
const persisted = () =>
page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('/campanha/liderancas') &&
response.ok(),
)
const search = page.getByRole('combobox', {
name: 'Buscar município, território de identidade ou zona eleitoral',
})
await search.fill(added.name)
const suggestion = page
.getByRole('option')
.filter({ has: page.getByText(added.name, { exact: true }) })
.first()
await expect(suggestion).toBeVisible()
await Promise.all([persisted(), suggestion.click()])
await expect(page.getByRole('link', { name: added.name, exact: true })).toBeVisible()
// No "Salvar" anywhere in the cell: the write already happened.
await expect(page.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
// Keyboard path (ARIA combobox): the first hit is pre-selected, so typing
// and pressing Enter adds without ever touching the mouse.
await search.fill(keyboardQuery.name)
const firstOption = page.getByRole('option').first()
await expect(firstOption).toHaveAttribute('aria-selected', 'true')
await expect(search).toHaveAttribute('aria-expanded', 'true')
const activeId = await search.getAttribute('aria-activedescendant')
expect(activeId).toBe(await firstOption.getAttribute('id'))
// Whatever the catalog ranks first — the query may prefix-match siblings.
const keyboardAdded = (await firstOption.innerText()).split('\n')[0]!.trim()
await Promise.all([persisted(), search.press('Enter')])
await expect(page.getByRole('link', { name: keyboardAdded, exact: true })).toBeVisible()
await Promise.all([
persisted(),
page.getByRole('button', { name: `Remover ${linked.name}`, exact: true }).click(),
])
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveCount(0)
await page.reload()
await expect(page.getByRole('link', { name: added.name, exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: keyboardAdded, exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: linked.name, exact: true })).toHaveCount(0)
const stored = await campaign.payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
})
expect(stored.municipalities).toHaveLength(2)
expect(stored.municipalities).toContain(added.id)
expect(stored.municipalities).not.toContain(linked.id)
})
})

View file

@ -8,7 +8,6 @@ import {
createAdvisorRecord,
sendAdvisorPasswordResetRecord,
setAdvisorMunicipalitiesBatchRecord,
setAdvisorMunicipalityMembershipRecord,
updateAdvisorProfileRecord,
} from '@/app/(campaign)/campanha/actions/advisor'
import { assignMunicipalityAdvisorsRecord } from '@/app/(campaign)/campanha/actions/municipality'
@ -24,6 +23,19 @@ const campaignFixtures = installCampaignFixtures({
},
})
const advisorIdsOf = async (municipalityID: number): Promise<number[]> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { advisors: true },
overrideAccess: true,
})
return (municipality.advisors ?? []).map((entry) =>
typeof entry === 'number' ? entry : entry.id,
)
}
describe('campaign advisor management (B19)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
@ -54,15 +66,13 @@ describe('campaign advisor management (B19)', () => {
expect(stored.email).toBe(email)
expect(stored.role).toBe('advisor')
const assigned = await setAdvisorMunicipalityMembershipRecord(payload, actor, {
await setAdvisorMunicipalitiesBatchRecord(payload, actor, {
advisorId: created.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
expect(
assigned.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id)),
).toContain(created.id)
expect(await advisorIdsOf(municipality.id)).toContain(created.id)
const updated = await updateAdvisorProfileRecord(payload, actor, {
id: created.id,
@ -94,9 +104,9 @@ describe('campaign advisor management (B19)', () => {
).rejects.toThrow(/coordenação geral ou o candidato/i)
await expect(
setAdvisorMunicipalityMembershipRecord(payload, advisor, {
setAdvisorMunicipalitiesBatchRecord(payload, advisor, {
advisorId: target.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: true,
}),
).rejects.toThrow(/coordenação geral ou o candidato/i)
@ -160,33 +170,31 @@ describe('campaign advisor management (B19)', () => {
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
advisorId: advisor.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: true,
})
fixtures.touchMunicipality(municipality.id)
const again = await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
advisorId: advisor.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: true,
})
expect(again.advisors?.map((entry) => (typeof entry === 'number' ? entry : entry.id))).toEqual([
advisor.id,
])
expect(await advisorIdsOf(municipality.id)).toEqual([advisor.id])
await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
advisorId: advisor.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: false,
})
const cleared = await setAdvisorMunicipalityMembershipRecord(payload, coordinator, {
await setAdvisorMunicipalitiesBatchRecord(payload, coordinator, {
advisorId: advisor.id,
municipalityId: municipality.id,
municipalityIds: [municipality.id],
assigned: false,
})
expect(cleared.advisors ?? []).toEqual([])
expect(await advisorIdsOf(municipality.id)).toEqual([])
})
it('assigns and removes multiple municipalities in one batch', async () => {

View file

@ -32,7 +32,7 @@ beforeAll(async () => {
})
describe('loadLeadershipListPageData', () => {
it('returns marker-scoped rows with municipality names for staff', async () => {
it('returns marker-scoped rows with municipality ids for staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
@ -49,7 +49,7 @@ describe('loadLeadershipListPageData', () => {
expect(result.rows).toHaveLength(1)
expect(result.rows[0]).toMatchObject({
name: contact.name,
municipalityNames: [municipality.name],
municipalityIDs: [municipality.id],
hasAppAccess: false,
})
})

View file

@ -0,0 +1,215 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { setLeadershipMunicipalitiesMembershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const municipalityIds = (leadership: {
municipalities?: (number | { id: number })[] | null
}): number[] =>
(leadership.municipalities ?? []).map((entry) => (typeof entry === 'number' ? entry : entry.id))
describe('setLeadershipMunicipalitiesMembershipRecord (B34)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it.each(['coordinator', 'candidate'] as const)(
'lets a %s add and remove municipalities by delta',
async (role) => {
const fixtures = campaignFixtures()
const actor = await fixtures.createCampaignUser(role)
const home = await fixtures.getMunicipality()
const added = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [home],
})
const { leadership: assigned, municipalitySlugs } =
await setLeadershipMunicipalitiesMembershipRecord(payload, actor, {
leadershipId: leadership.id,
municipalityIds: [added.id],
assigned: true,
})
expect(municipalityIds(assigned)).toEqual([home.id, added.id])
expect(municipalitySlugs).toEqual([added.slug])
const { leadership: removed } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
actor,
{
leadershipId: leadership.id,
municipalityIds: [added.id],
assigned: false,
},
)
expect(municipalityIds(removed)).toEqual([home.id])
},
)
it('adds a whole batch (território / zona) in one write', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const home = await fixtures.getMunicipality()
const first = await fixtures.getMunicipality()
const second = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({ contact, municipalities: [home] })
const { leadership: assigned, municipalitySlugs } =
await setLeadershipMunicipalitiesMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
municipalityIds: [first.id, second.id],
assigned: true,
})
expect(municipalityIds(assigned)).toEqual([home.id, first.id, second.id])
expect(municipalitySlugs).toEqual(expect.arrayContaining([first.slug, second.slug]))
})
it('refuses to remove the last municipality of a leadership', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await expect(
setLeadershipMunicipalitiesMembershipRecord(payload, coordinator, {
leadershipId: leadership.id,
municipalityIds: [municipality.id],
assigned: false,
}),
).rejects.toThrow(/pelo menos um município/i)
const unchanged = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
})
expect(municipalityIds(unchanged)).toEqual([municipality.id])
})
it('lets an advisor add a municipality they administer', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const home = await fixtures.getMunicipality()
const alsoAdministered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(home, [advisor])
await fixtures.assignMunicipalityAdvisors(alsoAdministered, [advisor])
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({ contact, municipalities: [home] })
const { leadership: assigned } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
advisor,
{
leadershipId: leadership.id,
municipalityIds: [alsoAdministered.id],
assigned: true,
},
)
expect(municipalityIds(assigned)).toEqual([home.id, alsoAdministered.id])
})
it('denies an advisor adding a municipality outside their portfolio, but lets them remove one', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
const outside = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
const contact = await fixtures.createContact()
// Cross-boundary leadership: the advisor administers only one of its municipalities.
const leadership = await fixtures.createLeadership({
contact,
municipalities: [administered, outside],
})
const stranger = await fixtures.getMunicipality()
await expect(
setLeadershipMunicipalitiesMembershipRecord(payload, advisor, {
leadershipId: leadership.id,
municipalityIds: [stranger.id],
assigned: true,
}),
).rejects.toThrow(/municípios que assessora/i)
// Removal needs no scope beyond the row access that resolved the leadership —
// this is what a whole-array replace would break for a cross-boundary row.
const { leadership: removed } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
advisor,
{
leadershipId: leadership.id,
municipalityIds: [outside.id],
assigned: false,
},
)
expect(municipalityIds(removed)).toEqual([administered.id])
})
it('denies a leader actor', async () => {
const fixtures = campaignFixtures()
const leaderActor = await fixtures.createCampaignUser('leader')
const municipality = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
await expect(
setLeadershipMunicipalitiesMembershipRecord(payload, leaderActor, {
leadershipId: leadership.id,
municipalityIds: [other.id],
assigned: true,
}),
).rejects.toThrow(/coordenação e a assessoria/i)
})
it('is a no-op when the municipalities are already in the desired state', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const absent = await fixtures.getMunicipality()
const contact = await fixtures.createContact()
const leadership = await fixtures.createLeadership({
contact,
municipalities: [municipality],
})
const { municipalitySlugs: afterAdd } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
coordinator,
{ leadershipId: leadership.id, municipalityIds: [municipality.id], assigned: true },
)
// No write happened, so the caller has nothing to revalidate.
expect(afterAdd).toEqual([])
const { municipalitySlugs: afterRemove } = await setLeadershipMunicipalitiesMembershipRecord(
payload,
coordinator,
{ leadershipId: leadership.id, municipalityIds: [absent.id], assigned: false },
)
expect(afterRemove).toEqual([])
})
})

View file

@ -7,6 +7,7 @@ import { MunicipalityListExpectedVotesControl } from '@/components/campaign/muni
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
/**
@ -157,4 +158,39 @@ describe('campaign cell edit overlay', () => {
expect(document.querySelector('[data-slot="drawer-description"]')).toBeNull()
},
)
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and
* cannot join the cases above. It is here for the half it does share — the
* sheet chrome and, above all, `initialFocus`: its own Drawer used to hand
* focus to the first chip, a link that navigates away from the row being
* edited.
*/
it('opens the município portfolio in a Drawer focused on its title', async () => {
render(
createElement(MunicipalityPortfolioCell, {
ownerId: 1,
ownerName: 'Maria Souza',
municipalityIds: [11],
municipalityIndex: [
{ id: 11, name: MUNICIPALITY_NAME, slug: 'feira-de-santana', region: 'Portal do Sertão' },
],
commitAction: async () => ({}),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Editar municípios de Maria Souza' }))
const dialog = await screen.findByRole('dialog')
const title = dialog.querySelector('[data-slot="drawer-title"]')
expect(title?.textContent).toBe('Editar municípios')
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
'Maria Souza',
)
await waitFor(() => expect(document.activeElement).toBe(title))
expect(dialog.textContent).toContain('Fechar')
})
})

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
describe('nextMunicipalityIdsAfterLeadershipMembership', () => {
it('appends only the municipalities that are not linked yet, and reports them as added', () => {
expect(nextMunicipalityIdsAfterLeadershipMembership([10, 20], [20, 30, 40], true)).toEqual({
next: [10, 20, 30, 40],
added: [30, 40],
changed: [30, 40],
})
})
it('removes the requested municipalities, ignores the ones not linked, and adds nothing', () => {
// `changed` excludes 99: it was never linked, so revalidating it would bust a
// route cache for a município this write did not touch.
expect(nextMunicipalityIdsAfterLeadershipMembership([10, 20, 30], [20, 99], false)).toEqual({
next: [10, 30],
added: [],
changed: [20],
})
})
it('returns null when the leadership is already in the desired state', () => {
expect(nextMunicipalityIdsAfterLeadershipMembership([10, 20], [10, 20], true)).toBeNull()
expect(nextMunicipalityIdsAfterLeadershipMembership([10, 20], [30], false)).toBeNull()
})
it('refuses to remove the last municipality', () => {
expect(() => nextMunicipalityIdsAfterLeadershipMembership([10], [10], false)).toThrow(
'pelo menos um município',
)
expect(() => nextMunicipalityIdsAfterLeadershipMembership([10, 20], [10, 20], false)).toThrow(
'pelo menos um município',
)
})
it('refuses a batch that would cross the cap', () => {
const current = Array.from({ length: MAX_LEADERSHIP_MUNICIPALITIES - 1 }, (_, i) => i + 1)
expect(nextMunicipalityIdsAfterLeadershipMembership(current, [900], true)?.next).toHaveLength(
MAX_LEADERSHIP_MUNICIPALITIES,
)
expect(() => nextMunicipalityIdsAfterLeadershipMembership(current, [900, 901], true)).toThrow(
`no máximo ${MAX_LEADERSHIP_MUNICIPALITIES}`,
)
})
})

View file

@ -1,45 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
buildAdvisorPortfolioChips,
catalogEntriesForTseZone,
searchAdvisorPortfolio,
type AdvisorMunicipalityIndexEntry,
} from '@/lib/advisorMunicipalityPortfolio'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
buildMunicipalityPortfolioChips,
catalogEntriesForTseZone,
searchMunicipalityPortfolio,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
const buildIndex = (): AdvisorMunicipalityIndexEntry[] =>
const buildIndex = (): MunicipalityPortfolioIndexEntry[] =>
municipalityCatalog.map((entry, index) => ({
id: index + 1,
name: entry.name,
slug: entry.slug,
region: entry.region,
city: entry.city,
zoneNumber: entry.zoneNumber ?? null,
}))
describe('advisorMunicipalityPortfolio', () => {
/** The index is keyed by slug; `city` lives only in the catalog it is built from. */
const slugsForCities = (cities: readonly string[]): Set<string> =>
new Set(municipalityCatalog.filter((entry) => cities.includes(entry.city)).map((e) => e.slug))
describe('municipalityPortfolio', () => {
it('collapses a full identity territory into a single chip', () => {
const index = buildIndex()
const irece = citiesForTerritory('Irecê')
const assigned = index
.filter((entry) => irece.includes(entry.city))
.map((entry) => ({ id: entry.id, name: entry.name, slug: entry.slug }))
const irece = slugsForCities(citiesForTerritory('Irecê'))
const assignedIds = index.filter((entry) => irece.has(entry.slug)).map((entry) => entry.id)
const chips = buildAdvisorPortfolioChips(assigned, index)
const chips = buildMunicipalityPortfolioChips(assignedIds, index)
expect(chips).toHaveLength(1)
expect(chips[0]).toMatchObject({ kind: 'territory', label: 'Irecê' })
})
it('keeps partial territory membership as municipality chips', () => {
const index = buildIndex()
const one = index.find((entry) => entry.city === 'Irecê')
const ireceSlugs = slugsForCities(['Irecê'])
const one = index.find((entry) => ireceSlugs.has(entry.slug))
expect(one).toBeTruthy()
const chips = buildAdvisorPortfolioChips(
[{ id: one!.id, name: one!.name, slug: one!.slug }],
index,
)
const chips = buildMunicipalityPortfolioChips([one!.id], index)
expect(chips).toEqual([
{
kind: 'municipality',
@ -51,6 +49,11 @@ describe('advisorMunicipalityPortfolio', () => {
])
})
it('drops an id the index does not know (no name, no link to render)', () => {
const index = buildIndex()
expect(buildMunicipalityPortfolioChips([999_999], index)).toEqual([])
})
it('resolves Salvador ZE to the zone municipality entry only', () => {
const entries = catalogEntriesForTseZone(3)
expect(entries).toHaveLength(1)
@ -59,15 +62,15 @@ describe('advisorMunicipalityPortfolio', () => {
it('searches municipalities, territories and zones', () => {
const index = buildIndex()
const municipalityHits = searchAdvisorPortfolio('Feira', index, new Set())
const municipalityHits = searchMunicipalityPortfolio('Feira', index, new Set())
expect(municipalityHits.some((hit) => hit.kind === 'municipality')).toBe(true)
const territoryHits = searchAdvisorPortfolio('Irecê', index, new Set())
const territoryHits = searchMunicipalityPortfolio('Irecê', index, new Set())
expect(territoryHits.some((hit) => hit.kind === 'territory' && hit.label === 'Irecê')).toBe(
true,
)
const zoneHits = searchAdvisorPortfolio('ZE 3', index, new Set())
const zoneHits = searchMunicipalityPortfolio('ZE 3', index, new Set())
expect(zoneHits.some((hit) => hit.kind === 'zone' && hit.zoneNumber === 3)).toBe(true)
})
})

View file

@ -0,0 +1,95 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
type ToastAction = { label: string; onClick: () => void }
const toastActions: ToastAction[] = []
vi.mock('sonner', () => ({
toast: {
success: (_message: string, options?: { action?: ToastAction }) => {
if (options?.action) toastActions.push(options.action)
},
error: () => {},
},
}))
/**
* Two municípios that are the WHOLE of their território, so the chip collapses
* and removing it is a multi-id delta — which is what raises the undo toast.
*/
const INDEX: MunicipalityPortfolioIndexEntry[] = [
{ id: 1, name: 'Feira de Santana', slug: 'feira-de-santana', region: 'Portal do Sertão' },
{ id: 2, name: 'Serrinha', slug: 'serrinha', region: 'Portal do Sertão' },
{ id: 3, name: 'Ilhéus', slug: 'ilheus', region: 'Litoral Sul' },
// Never assigned, and load-bearing: without a second entry in its território,
// Ilhéus alone would BE Litoral Sul and collapse into a território chip.
{ id: 4, name: 'Itabuna', slug: 'itabuna', region: 'Litoral Sul' },
]
const cell = (municipalityIds: number[]) =>
createElement(MunicipalityPortfolioCell, {
ownerId: 7,
ownerName: 'Maria Souza',
municipalityIds,
municipalityIndex: INDEX,
commitAction: async () => ({ status: 'success' as const }),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
})
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(() => {
toastActions.length = 0
cleanup()
})
afterAll(() => vi.unstubAllGlobals())
describe('município portfolio cell — optimistic baseline', () => {
/**
* Regression pin for a bug that has now been written twice: the delta itself
* was already applied functionally, but the fallback the updater takes when
* the optimistic value is null read the `municipalityIds` of the render that
* created the closure. The toast's "Desfazer" is the one caller that outlives
* its render, so pressing it after a later edit rebuilt the row from a set
* that predated that edit — and because the reconcile effect only clears on an
* exact match, the row stayed wrong through every later revalidation.
*/
it('undoes a removal against the current set, not the one its render captured', async () => {
const { rerender } = render(cell([1, 2]))
// 1. Drop the território (ids 1 and 2) — a multi-id delta, so it toasts.
fireEvent.click(screen.getByRole('button', { name: 'Remover Portal do Sertão — 2 municípios' }))
await waitFor(() => expect(toastActions).toHaveLength(1))
// 2. The write landed and the server re-rendered the row empty.
rerender(cell([]))
// 3. A LATER edit the undo closure knows nothing about.
rerender(cell([3]))
expect(screen.getByText('Ilhéus')).toBeTruthy()
// 4. Undo. It must ADD the território back, never rewind the row to step 1.
await act(async () => {
toastActions[0]?.onClick()
})
await waitFor(() => expect(screen.getByText('Portal do Sertão')).toBeTruthy())
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
})