mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
B34: editable município chips on the leaderships list.
The "Municípios" column was dead text; it is now chips that navigate to the município, with a territory chip when a leadership covers a whole TI, and it is editable in place. What picks inline vs. Drawer is the `pointer:` media query, not viewport width: on a fine pointer the editor is always mounted (the "×" sits at the chip's corner, revealed on hover/focus) and on a coarse pointer a tap anywhere in the cell opens a Drawer with 44 px targets. `AdvisorMunicipalityCell` is promoted to `shared/MunicipalityPortfolioCell` and /campanha/assessores migrates onto it in the same change, so the second call site is born with the first. One batch action only; the single-id advisor twin is deleted with its int tests migrated. The anti-goal on always-mounted list inputs is amended for chip-based relations on fine pointers, not reopened. Also fixes a latent bug: `CampaignTableHead`'s `description` seam broke any RSC caller, because its two helpers lived in a 'use client' module.
This commit is contained in:
parent
0ee5136b3f
commit
812e3a0bee
43 changed files with 2104 additions and 1051 deletions
|
|
@ -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”
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,27 +4,31 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
|
|||
|
||||
## Debt Ledger
|
||||
|
||||
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
|
||||
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
|
||||
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | open 2026-07-26 (found during B8 F2) | |
|
||||
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
|
||||
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
|
||||
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
|
||||
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
|
||||
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
|
||||
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
|
||||
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
|
||||
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
|
||||
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
|
||||
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | |
|
||||
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
|
||||
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
|
||||
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
|
||||
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. The outage was transient, so treat this row as a watch item — if exit 2 returns, the SCA gap (incl. `shpjs`) reopens | |
|
||||
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
|
||||
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
|
||||
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
|
||||
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | |
|
||||
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
|
||||
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
|
||||
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
|
||||
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
|
||||
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
|
||||
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
|
||||
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | |
|
||||
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
|
||||
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation | B34 |
|
||||
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse` | B34+ |
|
||||
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
|
||||
|
||||
## Smell Inventory
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
73
docs/plans/escala-dry-pos-b34.md
Normal file
73
docs/plans/escala-dry-pos-b34.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# B34+ — Escala e DRY pós-B34
|
||||
|
||||
Fill-in de engenharia com os débitos que sobreviveram ao `/simplify` do **B34 ✓** (chips editáveis de municípios na lista de lideranças). Entrega-mãe: [`chips-municipios-lista-liderancas.md`](chips-municipios-lista-liderancas.md).
|
||||
|
||||
Três achados dos relatórios foram aplicados **no próprio fechamento do B34** e não voltam aqui: campos mortos no índice RSC (`city`/`zoneNumber`), o parser divergente de `FormData` em `/campanha/assessores`, e a mensagem de seleção vazia declarada duas vezes.
|
||||
|
||||
## Já resolvido no fechamento (medido, não estimado)
|
||||
|
||||
O passe de perf localizado que fechou o B34 rendeu, com bancada em `tsx` sobre o catálogo real (435 entradas, tabela de 25 linhas):
|
||||
|
||||
| Caminho | Antes | Depois | Como |
|
||||
| ------------------------------------ | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `buildMunicipalityPortfolioChips` | 4,2 ms | **0,78 ms** | `WeakMap` por array de índice (`portfolioIndexDerivations`) + curto-circuito `territoryIds.length > remaining.size` |
|
||||
| idem, em re-render (índice quente) | 4,2 ms | **0,043 ms** | o `WeakMap` sobrevive entre renders; um `useMemo` por linha não sobreviveria |
|
||||
| `searchMunicipalityPortfolio`, tecla | 4,59 ms | **0,077 ms** | query normalizada **uma vez** + rótulos pré-normalizados (`matchesNormalizedAtWordStart`) |
|
||||
|
||||
Também entraram: `sameIdSet` no lugar do comparador de chaves ordenadas escrito à mão (3º call site do helper), `scrollIntoView` restrito a navegação por teclado (o mouse já está onde aponta — era um layout forçado por opção percorrida), corpo do `Drawer` atrás de `drawerOpen` (era alocado em toda linha, inclusive no ponteiro fino onde nunca abre), e o `revalidatePath` passou a usar o delta real (`changed`) em vez de tudo que foi pedido — remover um município que já não estava vinculado não busta mais a rota dele.
|
||||
|
||||
**Um item do relatório foi rejeitado por medição, e vale registrar porque parecia óbvio:** remover o `sort: 'name'` do loader ("nada depende da ordem do banco") **quebrou a lista de sugestões**. `searchMunicipalityPortfolio` percorre o índice na ordem em que ele chega e para no `limit`, então a ordem alfabética das sugestões era a ordem do `ORDER BY` — sem ele o usuário passa a receber os 12 primeiros casamentos em ordem física de linha. O e2e do B34 pegou (o teste digita um prefixo e aperta Enter no primeiro item), e o `sort` voltou com o motivo escrito ao lado.
|
||||
|
||||
## F1 — Regressão de First Load JS nas duas listas (prioritário)
|
||||
|
||||
**Medido** contra `main` limpo (stash com `-u`, dois `pnpm build` completos, números como o Next reporta — minificados, não gzip):
|
||||
|
||||
| Rota | main | B34 | Δ |
|
||||
| ---------------------- | ------ | ---------- | ---------- |
|
||||
| `/campanha/liderancas` | 246 kB | **304 kB** | **+58 kB** |
|
||||
| `/campanha/assessores` | 253 kB | **295 kB** | **+42 kB** |
|
||||
|
||||
A decomposição importa e ninguém deve re-derivá-la: `/campanha/assessores` **já** importava o catálogo estático da Bahia antes do B34 (via `advisorMunicipalityPortfolio.ts`, o módulo que virou `municipalityPortfolio.ts`), então os +42 kB de lá são a **célula em si** — Drawer, links, o contrato de combobox, a máquina de medição. Os +16 kB restantes em lideranças são o catálogo (`municipalityCatalog` + `bahiaTerritories` + `bahiaTseZones`, ~40 kB min / ~12,9 kB gzip) chegando a uma rota que não o tinha. É exatamente o custo que o **B14 ✓** já havia medido e nomeado (importar um serializador de URL no cliente custou 21 kB por um link).
|
||||
|
||||
**Duas direções, e elas se opõem — escolher, não fazer as duas:**
|
||||
|
||||
- **(a) Catálogo fora do browser.** O índice RSC passa a carregar `tseZones` e a célula deixa de importar `municipalityCatalog`/`bahiaTseZones`. Ganha ~12,9 kB gzip de bundle em duas rotas; **paga** mais payload em todo render _e em todo toggle de chip_ (a action revalida, o RSC re-serializa o índice inteiro).
|
||||
- **(b) Payload mínimo.** O loader devolve só `{ id, slug }` (~13 kB crus contra ~45 kB) e nome/região/cidade saem do catálogo, que já está no bundle. Ganha em toda interação; mantém o bundle como está.
|
||||
|
||||
O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago uma vez e fica em cache imutável, o payload é pago a cada toggle. A ressalva registrada é honesta: `buildMunicipalityPortfolioChips` hoje prefere de propósito o `region` **do banco** à resolução por slug do catálogo, "para os chips colaparem mesmo com divergência de alias entre ambientes". Essa justificativa é fraca — `municipalityCatalog` é snapshot-testado contra o catálogo semeado (`tests/fixtures/municipality-catalog.snapshot.json`), que é precisamente o guard que transforma divergência em teste vermelho — mas foi uma decisão deliberada, então a troca precisa de uma chamada explícita, não de um corte silencioso.
|
||||
|
||||
Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas.
|
||||
|
||||
## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
|
||||
|
||||
`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
|
||||
|
||||
A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro.
|
||||
|
||||
## F3 — Um só delta de membership
|
||||
|
||||
`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
|
||||
|
||||
No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra.
|
||||
|
||||
**Não** estender ao lado do assessor: `setAdvisorMunicipalitiesBatchRecord` percorre a relação **inversa** (`municipality.advisors`), com N locks / N leituras / N updates sob bypass admin. Um helper cobrindo os dois teria de ser parametrizado por "de que lado da aresta eu escrevo", que é a fontanaria genérica que o repo já rejeitou uma vez.
|
||||
|
||||
## F4 — Duas formas de view model para uma célula
|
||||
|
||||
`leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo.
|
||||
|
||||
## F5 — Combobox à mão onde o kit já embrulha um
|
||||
|
||||
São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
|
||||
|
||||
## F6 — Baixos
|
||||
|
||||
- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? <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.
|
||||
- **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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
788
src/components/campaign/shared/MunicipalityPortfolioCell.tsx
Normal file
788
src/components/campaign/shared/MunicipalityPortfolioCell.tsx
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
'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 { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
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
|
||||
|
||||
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) onDraftChange?.(withDelta(effectiveIds, changedIds, assigned))
|
||||
else setOptimistic((current) => withDelta(current ?? municipalityIds, 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 ?? municipalityIds, 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.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-fine:hidden"
|
||||
aria-label={`Editar municípios de ${ownerName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
|
||||
<DrawerContent>
|
||||
{/* Eager JSX: allocated on every render of every row, including the fine
|
||||
* pointer where this never opens. The portal already skips the DOM. */}
|
||||
{drawerOpen ? (
|
||||
<>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{drawerTitle}</DrawerTitle>
|
||||
<DrawerDescription>{ownerName}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pb-2">
|
||||
{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}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose
|
||||
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
|
||||
>
|
||||
Fechar
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
16
src/lib/campaignHoverTooltip.ts
Normal file
16
src/lib/campaignHoverTooltip.ts
Normal 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'
|
||||
43
src/lib/leadershipMunicipalityMembership.ts
Normal file
43
src/lib/leadershipMunicipalityMembership.ts
Normal 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)) }
|
||||
}
|
||||
294
src/lib/municipalityPortfolio.ts
Normal file
294
src/lib/municipalityPortfolio.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
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,
|
||||
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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,20 @@ export const normalizeSearchPhrase = (value: string): string =>
|
|||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
export const matchesAtWordStart = (label: string, query: string): boolean => {
|
||||
const normalizedQuery = normalizeSearchPhrase(query)
|
||||
/**
|
||||
* 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
|
||||
const normalizedLabel = normalizeSearchPhrase(label)
|
||||
return normalizedLabel
|
||||
.split(/[^\p{Letter}\p{Number}]+/u)
|
||||
.some((_, index, words) => words.slice(index).join(' ').startsWith(normalizedQuery))
|
||||
}
|
||||
|
||||
export const matchesAtWordStart = (label: string, query: string): boolean =>
|
||||
matchesNormalizedAtWordStart(normalizeSearchPhrase(label), normalizeSearchPhrase(query))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
40
src/utilities/municipalityPortfolioIndex.ts
Normal file
40
src/utilities/municipalityPortfolioIndex.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
215
tests/int/campaignLeadershipMunicipalityMembership.int.spec.ts
Normal file
215
tests/int/campaignLeadershipMunicipalityMembership.int.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
49
tests/unit/leadershipMunicipalityMembership.unit.spec.ts
Normal file
49
tests/unit/leadershipMunicipalityMembership.unit.spec.ts
Normal 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}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue