Deliver B37: editable município chips on the dobradinhas list.
The "Municípios" column of /campanha/dobradinhas reuses MunicipalityPortfolioCell unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single transactional batch action (setStateDeputyMunicipalitiesBatch): per-município advisory lock, all-or-nothing on a partial-scope batch instead of a silent partial apply, no floor (a dobradinha can hold zero municípios). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and both were fixed instead of deferred. First: MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell had grown the same chip-editing machine twice — optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split — with incompatible contracts. This was the 3rd call site B34+ was waiting for, so RelationChipCell is now the single engine and both cells shrank to thin domain wrappers (chip/ search building, FormData, copy — nothing interaction-specific left in either). LeadershipStateDeputyRelationCell also gained an ownerName prop and its first dedicated test suite, which it shipped without before this. Second: an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, failing silently at the document level. MunicipalityPortfolioCell now filters the visible chips by addableIds instead of only gating what can be added, so an advisor sees and can only touch municípios they administer. No migration, collection, or Consent. Docs updated: plan as-built, roadmap, AGENTS.md, project notebook.
This commit is contained in:
parent
fcd258e1d7
commit
da4ca2c713
19 changed files with 1733 additions and 1083 deletions
|
|
@ -233,4 +233,5 @@ 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).
|
||||
- **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) seguiu com Popover próprio até o **B37 ✓** generalizar os dois num motor comum. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
|
||||
- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md).
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-27):** **B8+** Escala e DRY pós-B8 F2 — the four `/simplify` debts of the ZE polygons, and the headline finding changed owner. The plan called the target "ground resolution implemented via quantization digits"; measuring the committed artifact re-snapped to coarser grids showed quantization alone barely cuts (~25 m → 90% of the original vertices, and **even on the state mesh's ~103 m grid the zones stayed at 203 points/feature**, ~9× the sibling). The lever is the **absolute Visvalingam threshold**, so the policy was inverted: absolute weight in **m² of ground** is the policy, digits are derived per topology. See the ground-scale policy bullet under "Campaign map geometries" for the numbers that matter; the outcome is the zone mesh at **574 arc points / 30.2 per feature / 7,687 bytes** (from 9,120 / 480 / 72,310), `bahia-municipalities` **byte-identical**, and `bahia-identity-territories` off by one byte because its grid is now derived from its bbox (102.730 → 102.822 m). `zoneNumber` left the artifact and the type. The other three phases: `featureMapKey` is now exercised on real features of all three committed meshes, each inside the loop that already sweeps it whole (417 `codarea` + 27 `code` + 19 `slug`) — the `/simplify` caught the first version asserting the city inside the zone loop, which covered **one** municipal feature nineteen times because the 19 ZE share Salvador's code — and the synthetic precedence test died (the invariant is real — see the Map bullet); a zone-chunk failure degrades instead of killing the map (with the reason logged, since a ChunkLoadError and a corrupt artifact are not the same event), and the approximation caveat left the `!comparisonActive` gate for a paragraph conditioned on the new bundle field `hasZoneMunicipalities`, published by the loader from `kind === 'zona'` — the `/simplify` rejected deriving it on the client from the key's shape (a second encoding of `mapKeyForMunicipality`, in negative space) and rejected gating on `zoneBreakdown`, which the zone-list fill-in deletes; and the delivery's orphans are gone — two accessors deleted, `MunicipalityMapNavigation` collapsed to `string | undefined`, `MunicipalitiesByMapKey` to `Record<string, string>` (435 dead strings out of every Início RSC payload), `polygonRingsOf` exported, geometry helpers retyped on `PolygonalFeature`. **One planned deletion was rejected by measurement:** `city`/`zoneNumber` on `MunicipalityZoneNeighborhoodEntry` **stay** — no runtime reads them, but both feed the rows hashed by `canonicalEvidenceRows` and are columns of the RA 02/2017 provenance fixture (an independent transcription), so deleting them would weaken the verification for nothing. No migration, collection, action or `Consent`; e2e still 436 paths / 435 interactive. Plano: [`docs/plans/escala-dry-pos-b8f2.md`](docs/plans/escala-dry-pos-b8f2.md).
|
||||
|
||||
**Recently resolved (2026-07-28):** **B37** Chips editáveis de municípios na lista de dobradinhas — the "Municípios" column of `/campanha/dobradinhas` was `row.municipalityCount`, a dead number; it now uses the same `MunicipalityPortfolioCell` (B34 ✓) unchanged, writing the inverted side of the relation (`municipality.stateDeputies`) through a single batch action (`setStateDeputyMunicipalitiesBatch`, transactional, per-município advisory lock, **all-or-nothing** on a partial-scope batch), with no floor (a dobradinha can hold zero municípios) and no `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` already scopes the advisor). `stateDeputiesArraySchema`'s cap moved 20 → 435 to fit a whole território batch from the município side. **The post-implementation critique found two P1s, and the answer was to generalize immediately instead of registering a fourth "deferred with trigger":** `MunicipalityPortfolioCell` and `LeadershipStateDeputyRelationCell` (B31 ✓/B36 ✓) had grown the same interaction machine (optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split) twice, with incompatible interfaces — this item **was** the 3rd call site B34+ was waiting for, so `src/components/campaign/shared/RelationChipCell.tsx` (813 lines) became the single engine and both cells shrank to thin, domain-specific wrappers (chip/search building, `FormData`, copy — nothing interaction-specific left in either). This closes B34+'s `useChipRowOverflow` extraction trigger ahead of schedule: the ~105 duplicated lines it named are gone, not just deduplicated into a helper. `LeadershipStateDeputyRelationCell` also gained an `ownerName` prop (for the Drawer description and `aria-label`s, matching the município cell) and its first dedicated test suite — it shipped with zero coverage of its own before this. **The second P1** — an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, and the removal silently no-oped at the document level with no way for the cell to explain why — was closed by **hiding** the chip instead of only explaining the failure: `MunicipalityPortfolioCell` now filters `municipalityIds` by `addableIds` when present (`visibleIds`), so an advisor sees and can only touch municípios they administer; staff without `addableIds` still sees and can remove every link. Accepted trade-off: in a rare portfolio-reassignment race, the client-side floor could refuse a removal the server would actually accept — acceptable because the server remains the source of truth and the explicit ask was to hide, not merely explain. Gate: `tsc`/`lint`/`format:check`/`check:cycles` clean, unit 651/651, int 452/452 (full suites, not just touched files), Aikido 0 findings on edited first-party files; `knip` blocked by the pre-existing, already-ledgered `payload.config.ts` load failure (P3 in `TECH-DEBT.md`), not a regression of this delivery. No migration, collection, or `Consent`. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](docs/plans/chips-municipios-lista-dobradinhas.md).
|
||||
|
||||
**Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md).
|
||||
|
||||
**Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,32 @@
|
|||
# Chips editáveis de municípios na lista de dobradinhas (paridade com Assessores)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Status: entregue (2026-07-28)
|
||||
Atualizado em: 2026-07-28
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B37**)
|
||||
Impeccable: B — encaixe na coluna "Municípios" de `/campanha/dobradinhas`; sem rota nova
|
||||
Appetite: ~0,75–1 dia eng — 1 componente de célula novo (Popover + Command), 2 server actions + schema + helper puro, ajuste no loader da lista; sem migration. (Menor que o B34 análogo: sem piso mínimo, sem `assertMunicipalitiesWithinScope`, lock por município em vez de por documento.)
|
||||
Responsável: —
|
||||
|
||||
## Entregue (2026-07-28) — as-built
|
||||
|
||||
**A célula é a mesma peça do B34, sem cópia.** Este item não construiu `StateDeputyMunicipalitiesCell` nem um provider novo — plugou `shared/MunicipalityPortfolioCell` (já promovida pelo B34 ✓) direto na coluna "Municípios" de `/campanha/dobradinhas`, com as costuras que o B34 já desenhou para isso: sem `minItems` (uma dobradinha pode ficar sem município — o piso deste plano nunca existiu no schema, e o comentário permanece correto), sem `maxItems` do lado da dobradinha (o teto que existe é por município — `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`, inalterado), `addableIds` só quando o ator é `advisor`. O container herda **integralmente** a decisão do B34: sempre editável em `pointer-fine` (chips + "×" no hover/foco + input de busca na própria linha), Drawer em `pointer-coarse` — nenhuma decisão de container nova foi tomada aqui, e o "conflito com o pedido literal" registrado abaixo já estava fechado antes deste item começar (ver B34 ✓ e a nota B34/B36/B37 em `codebase-map.mdc`).
|
||||
|
||||
**Generalização feita nesta entrega, não adiada para um "3º call site" futuro.** Este plano registrava a extração de um `RelationshipChipEditor` agnóstico em "Adiado com gatilho", esperando B31+B34+este landarem primeiro. Na crítica pós-implementação (`/simplify` + `/impeccable`) ficou visível que **este item já era o 3º call site**: `MunicipalityPortfolioCell` (município) e `LeadershipStateDeputyRelationCell` (dobradinha↔liderança, B31 ✓/B36 ✓) tinham interfaces incompatíveis para o mesmo padrão (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) — P1 do critique. A resposta foi generalizar imediatamente em vez de registrar um quarto "adiado com gatilho": `src/components/campaign/shared/RelationChipCell.tsx` (813 linhas) nasceu como a máquina de interação única, e `MunicipalityPortfolioCell` (816 → ~180 linhas) e `LeadershipStateDeputyRelationCell` (450 → ~185 linhas) foram reduzidos a wrappers finos — chips/busca/`FormData`/copy por domínio, nada de interação duplicada. `LeadershipStateDeputyRelationCell` ganhou `ownerName` (para o Drawer e os `aria-label`) e perdeu sua própria implementação de Popover+Command, sem mudar contrato para os dois call sites existentes (`liderancas/page.tsx`, `dobradinhas/page.tsx`) além de passar o novo prop.
|
||||
|
||||
**Segundo P1 do critique, também resolvido nesta entrega:** um assessor via — e tentava remover — chips de municípios fora do seu escopo, e a remoção falhava em silêncio no nível do documento (`canUpdateMunicipality` recusa, mas a célula não sabia explicar por quê). `MunicipalityPortfolioCell` agora filtra `municipalityIds` por `addableIds` quando presente (`visibleIds`), então um assessor só vê — e só pode tentar remover — municípios que administra; staff sem `addableIds` continua vendo e removendo todos os vínculos. Trade-off aceito: em uma janela rara de reatribuição de carteira, um piso client-side poderia recusar uma remoção que o servidor aceitaria — aceitável porque o servidor segue sendo a fonte de verdade e o pedido explícito era esconder, não apenas explicar.
|
||||
|
||||
**Escrita:** `setStateDeputyMunicipalitiesBatch` — uma única action de lote (não a dupla delta+lote do plano), transacional, com lock por município (`municipality-state-deputies:{id}`, um por município tocado, ordenado) e leitura+escrita de `municipality.stateDeputies` com `overrideAccess: false` (o acesso real é `canUpdateMunicipality`; sem `assertMunicipalitiesWithinScope`, como planejado). **Falha em lote é tudo-ou-nada**: se qualquer município do lote está fora do escopo do assessor ou não existe, a transação inteira é revertida com uma mensagem única — decisão explícita desta sessão (rejeita a alternativa "aplica o que pode, ignora o resto", que deixaria o assessor sem saber quais municípios silenciosamente falharam). `nextStateDeputyMunicipalityIdsAfterMembership` (em `src/lib/municipalityStateDeputyMembership.ts`, puro) espelha `nextAdvisorIdsAfterMembership`.
|
||||
|
||||
**Cap de 20 → 435 em `stateDeputiesArraySchema`** (`src/lib/schemas/municipality.ts`): o teto original de 20 dobradinhas por município era um vestígio de outra era do dado; com a coluna agora permitindo lote de território/ZE (até 435 municípios de uma vez do lado do dobradinha), o teto do lado do município precisava cobrir o universo inteiro em vez de truncar silenciosamente um lote grande. `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` foi ajustado e reexportado como mensagem segura da nova action.
|
||||
|
||||
**Loader:** `StateDeputyRowViewModel.municipalityCount` (número) → `municipalityIDs: number[]` — não o `{ id, name, slug }[]` do plano. Mesma razão do B34: a célula já recebe o catálogo completo (435 municípios) via `municipalityIndex`, então nome/slug/território resolvem no cliente e o loader não repete strings por linha.
|
||||
|
||||
**Sem provider de contexto** (mesma decisão do B34): `municipalityIndex` e `addableIds` viajam como props da factory de colunas que o RSC monta uma vez por tabela.
|
||||
|
||||
**Testes:** unit da lib pura (`municipalityStateDeputyMembership.unit.spec.ts`), int da action de lote (`campaignStateDeputyMunicipalityMembership.int.spec.ts` — escopo do assessor, teto, lock, tudo-ou-nada, no-op idempotente), e uma nova suite unit dedicada a `LeadershipStateDeputyRelationCell` (`leadershipStateDeputyRelationCell.unit.spec.ts`) que não existia antes desta entrega — o componente tinha zero cobertura própria.
|
||||
|
||||
**Gate:** `tsc`/`lint`/`format:check`/`check:cycles` limpos; unit 651/651, int 452/452 (suítes completas, não só os arquivos tocados); Aikido 0 findings nos arquivos de primeira parte editados; `knip` bloqueado pela limitação pré-existente e já ledgerada de não conseguir carregar `payload.config.ts` (P3 em `TECH-DEBT.md`) — não uma regressão desta entrega.
|
||||
|
||||
## ⚠️ Conflito com o pedido literal — leia antes de implementar
|
||||
|
||||
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,14 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipStateDeputyMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { requiredFormBoolean, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { MAX_LEADERSHIP_STATE_DEPUTIES } from '@/lib/schemas/leadership'
|
||||
import { STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES } from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
|
|
@ -30,3 +36,26 @@ export const setLeadershipStateDeputyMembershipFormAction = async (
|
|||
safeMessages,
|
||||
genericMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Add or remove municipalities in the "Municípios" column of
|
||||
* `/campanha/dobradinhas` (B37) — one chip or a whole território/ZE. `ownerId`
|
||||
* is the field name `MunicipalityPortfolioCell` sends, so the same cell
|
||||
* serves lideranças, assessores and dobradinhas.
|
||||
*/
|
||||
export const setStateDeputyMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setStateDeputyMunicipalitiesBatch({
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import {
|
|||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters'
|
||||
import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead'
|
||||
|
|
@ -27,11 +32,13 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipalityPortfolioIndex'
|
||||
import {
|
||||
loadStateDeputyListPageData,
|
||||
type StateDeputyRowViewModel,
|
||||
|
|
@ -50,7 +57,10 @@ import {
|
|||
type StateDeputyListState,
|
||||
} from '@/utilities/stateDeputyListUrl'
|
||||
|
||||
import { setLeadershipStateDeputyMembershipFormAction } from './formActions'
|
||||
import {
|
||||
setLeadershipStateDeputyMembershipFormAction,
|
||||
setStateDeputyMunicipalitiesFormAction,
|
||||
} from './formActions'
|
||||
|
||||
type StateDeputiesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
@ -86,6 +96,8 @@ const stateDeputyColumns = (
|
|||
partyFilterOptions: StateDeputyFilterOption[],
|
||||
hasNoPartyOption: boolean,
|
||||
leadershipOptions: RelationCellOption[],
|
||||
municipalityIndex: MunicipalityPortfolioIndexEntry[],
|
||||
addableMunicipalityIds: ReadonlySet<number> | undefined,
|
||||
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -118,8 +130,24 @@ const stateDeputyColumns = (
|
|||
{
|
||||
id: 'municipalities',
|
||||
label: 'Municípios',
|
||||
cellClassName: 'tabular-nums',
|
||||
cell: (row) => row.municipalityCount,
|
||||
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.">
|
||||
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 } : {})}
|
||||
commitAction={setStateDeputyMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios da dobradinha"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios."
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'leaderships',
|
||||
|
|
@ -129,6 +157,7 @@ const stateDeputyColumns = (
|
|||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromStateDeputy"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
items={row.leaderships.map((leadership) => ({
|
||||
id: leadership.id,
|
||||
label: leadership.name,
|
||||
|
|
@ -150,9 +179,16 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
if (!user) redirect('/campanha/login')
|
||||
if (!isCampaignStaff(user)) redirect('/campanha')
|
||||
|
||||
const [{ rows, totalDocs, totalPages, filterFacets }, leadershipOptions] = await Promise.all([
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
leadershipOptions,
|
||||
municipalityIndex,
|
||||
administeredIds,
|
||||
] = await Promise.all([
|
||||
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
|
||||
loadLeadershipOptions(payload, user),
|
||||
loadMunicipalityPortfolioIndex(payload),
|
||||
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
|
||||
])
|
||||
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
|
|
@ -175,6 +211,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
href: `/campanha/liderancas/${option.id}`,
|
||||
},
|
||||
})),
|
||||
municipalityIndex,
|
||||
administeredIds ? new Set(administeredIds) : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -183,8 +221,8 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças na própria lista e
|
||||
municípios nas fichas correspondentes.
|
||||
Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto
|
||||
na lista.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="min-h-11">
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ const leadershipColumns = ({
|
|||
<LeadershipStateDeputyRelationCell
|
||||
direction="fromLeadership"
|
||||
fixedId={row.id}
|
||||
ownerName={row.name}
|
||||
items={row.stateDeputies.map((deputy) => ({
|
||||
id: deputy.id,
|
||||
label: deputy.name,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import {
|
||||
STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE,
|
||||
stateDeputyCreateSchema,
|
||||
stateDeputyMunicipalitiesBatchSchema,
|
||||
stateDeputyUpdateSchema,
|
||||
type StateDeputyCreateInput,
|
||||
type StateDeputyMunicipalitiesBatchInput,
|
||||
type StateDeputyUpdateInput,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import { runStaffEntityMutation, type StaffEntityPolicy } from '@/utilities/campaignEntityActions'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import { uniqueRelationshipIds } from '@/utilities/relationship'
|
||||
|
||||
const stateDeputyPolicy: StaffEntityPolicy = {
|
||||
staffMessage: 'Somente a coordenação e a assessoria gerenciam dobradinhas.',
|
||||
|
|
@ -63,3 +72,125 @@ export const updateStateDeputy = async (input: StateDeputyUpdateInput) => {
|
|||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateStateDeputyRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
const revalidateStateDeputyMunicipalityPaths = (
|
||||
stateDeputySlug: string | undefined,
|
||||
municipalitySlugs: readonly string[],
|
||||
) => {
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
if (stateDeputySlug) {
|
||||
revalidatePath(`/campanha/dobradinhas/${stateDeputySlug}`, 'page')
|
||||
}
|
||||
revalidateMunicipalityListPaths({ scope: 'list' })
|
||||
for (const slug of municipalitySlugs) {
|
||||
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
|
||||
* adds or removes this `stateDeputy` on `municipality.stateDeputies` for a
|
||||
* batch of municipalities (one chip, or a whole território/ZE). Mirrors
|
||||
* `setAdvisorMunicipalitiesBatchRecord`: many municípios get touched, not one
|
||||
* owning document. Unlike that twin, this write does NOT bypass access —
|
||||
* `canUpdateMunicipality` already scopes an advisor to administered
|
||||
* municípios at the document level, and `stateDeputies`' field access
|
||||
* (`canManageCampaignStaffField`) is staff-wide, so `overrideAccess: false`
|
||||
* on both the read and the write is what refuses an out-of-scope município.
|
||||
*/
|
||||
export const setStateDeputyMunicipalitiesBatchRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: StateDeputyMunicipalitiesBatchInput,
|
||||
) => {
|
||||
const { stateDeputyId, municipalityIds, assigned } =
|
||||
stateDeputyMunicipalitiesBatchSchema.parse(input)
|
||||
const uniqueMunicipalityIds = [...new Set(municipalityIds)].sort((left, right) => left - right)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(
|
||||
payload,
|
||||
actor,
|
||||
STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE,
|
||||
req,
|
||||
)
|
||||
|
||||
await acquireTextAdvisoryLocks(
|
||||
payload,
|
||||
req,
|
||||
uniqueMunicipalityIds.map((id) => `municipality-state-deputies:${id}`),
|
||||
)
|
||||
|
||||
// Intentional admin bypass: only used to resolve the slug of the
|
||||
// touched dobradinha for a targeted revalidate; existence is otherwise
|
||||
// enforced by Payload's relationship validation on `update` (same
|
||||
// precedent as `setLeadershipStateDeputyMembershipRecord`, B31).
|
||||
const stateDeputySlug = (
|
||||
await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: stateDeputyId,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
).slug
|
||||
|
||||
// Every município actually changed, not just the last one — same bug
|
||||
// class the B34 twin fixed: a território chip can touch up to 435
|
||||
// municípios in one call, and revalidating only the last would leave
|
||||
// the rest of the detail pages stale.
|
||||
const changedSlugs: string[] = []
|
||||
|
||||
for (const municipalityId of uniqueMunicipalityIds) {
|
||||
// Row + field access verify the município is in the actor's scope
|
||||
// (`canUpdateMunicipality` for advisors, `canManageCampaignStaffField`
|
||||
// for the field) — an out-of-scope município throws here.
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true, slug: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentStateDeputyIDs = uniqueRelationshipIds(municipality.stateDeputies)
|
||||
const nextStateDeputyIDs = nextStateDeputyIdsAfterMunicipalityMembership(
|
||||
currentStateDeputyIDs,
|
||||
stateDeputyId,
|
||||
assigned,
|
||||
)
|
||||
if (nextStateDeputyIDs === null) continue
|
||||
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { stateDeputies: nextStateDeputyIDs },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
|
||||
}
|
||||
|
||||
return { stateDeputySlug, slugs: changedSlugs }
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar os municípios da dobradinha.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setStateDeputyMunicipalitiesBatch = async (
|
||||
input: StateDeputyMunicipalitiesBatchInput,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const result = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, input)
|
||||
if (result.slugs.length > 0) {
|
||||
revalidateStateDeputyMunicipalityPaths(result.stateDeputySlug, result.slugs)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon, XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
|
|
@ -32,6 +29,8 @@ type LeadershipStateDeputyRelationDirection = 'fromLeadership' | 'fromStateDeput
|
|||
type LeadershipStateDeputyRelationCellProps = {
|
||||
direction: LeadershipStateDeputyRelationDirection
|
||||
fixedId: number
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
|
||||
ownerName: string
|
||||
items: RelationCellItem[]
|
||||
options: RelationCellOption[]
|
||||
membershipAction: (
|
||||
|
|
@ -42,351 +41,145 @@ type LeadershipStateDeputyRelationCellProps = {
|
|||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
const COLLAPSED_CHIP_ROWS = 3
|
||||
/** Matches `gap-1` on the rest chip row. */
|
||||
const CHIP_GAP_PX = 4
|
||||
|
||||
const COPY = {
|
||||
fromLeadership: {
|
||||
editLabel: 'Editar dobradinhas',
|
||||
title: 'Atribuir dobradinhas',
|
||||
description: 'Deputados estaduais em dobradinha com esta liderança.',
|
||||
searchPlaceholder: 'Buscar deputado estadual…',
|
||||
searchAriaLabel: 'Buscar deputado estadual',
|
||||
saving: 'Salvando dobradinhas.',
|
||||
savingAria: 'Salvando dobradinhas',
|
||||
saveError: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
fromStateDeputy: {
|
||||
editLabel: 'Editar lideranças',
|
||||
title: 'Atribuir lideranças',
|
||||
description: 'Lideranças em dobradinha com este deputado estadual.',
|
||||
searchPlaceholder: 'Buscar liderança…',
|
||||
searchAriaLabel: 'Buscar liderança',
|
||||
saving: 'Salvando lideranças.',
|
||||
savingAria: 'Salvando lideranças',
|
||||
saveError: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const itemLabel = (item: RelationCellItem): string =>
|
||||
item.party ? `${item.label} (${item.party})` : item.label
|
||||
|
||||
const DIRECTION_COPY: Record<
|
||||
LeadershipStateDeputyRelationDirection,
|
||||
{
|
||||
drawerTitle: string
|
||||
triggerVerb: string
|
||||
emptyDrawerMessage: string
|
||||
searchPlaceholder: string
|
||||
searchLabel: string
|
||||
suggestionsLabel: string
|
||||
savingMessage: string
|
||||
savedMessage: string
|
||||
removedMessage: (count: number) => string
|
||||
updateErrorMessage: string
|
||||
}
|
||||
> = {
|
||||
fromLeadership: {
|
||||
drawerTitle: 'Dobradinhas da liderança',
|
||||
triggerVerb: 'dobradinhas',
|
||||
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
|
||||
searchPlaceholder: 'Buscar deputado estadual…',
|
||||
searchLabel: 'Buscar deputado estadual',
|
||||
suggestionsLabel: 'Sugestões de deputados estaduais',
|
||||
savingMessage: 'Salvando dobradinhas.',
|
||||
savedMessage: 'Dobradinhas salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Dobradinha removida.' : `${count} dobradinhas removidas.`,
|
||||
updateErrorMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
},
|
||||
fromStateDeputy: {
|
||||
drawerTitle: 'Lideranças da dobradinha',
|
||||
triggerVerb: 'lideranças',
|
||||
emptyDrawerMessage: 'Nenhuma liderança vinculada.',
|
||||
searchPlaceholder: 'Buscar liderança…',
|
||||
searchLabel: 'Buscar liderança',
|
||||
suggestionsLabel: 'Sugestões de lideranças',
|
||||
savingMessage: 'Salvando lideranças.',
|
||||
savedMessage: 'Lideranças salvas.',
|
||||
removedMessage: (count) =>
|
||||
count === 1 ? 'Liderança removida.' : `${count} lideranças removidas.`,
|
||||
updateErrorMessage: 'Não foi possível atualizar as lideranças. Tente novamente.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional cell for the `leadership.stateDeputies` edge.
|
||||
* Both directions write `leadershipId`/`stateDeputyId`/`assigned` through the
|
||||
* same membership action.
|
||||
* Bidirectional `leadership.stateDeputies` edge, on the same `RelationChipCell`
|
||||
* that serves município portfolios (B37). Both directions write
|
||||
* `leadershipId`/`stateDeputyId`/`assigned` through the same membership action;
|
||||
* every chip here is a single item — no batch, no floor/ceiling.
|
||||
*/
|
||||
export const LeadershipStateDeputyRelationCell = ({
|
||||
direction,
|
||||
fixedId,
|
||||
ownerName,
|
||||
items,
|
||||
options,
|
||||
membershipAction,
|
||||
measureOverflow = true,
|
||||
}: LeadershipStateDeputyRelationCellProps) => {
|
||||
const copy = COPY[direction]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [current, setCurrent] = useState<RelationCellItem[]>(items)
|
||||
const [query, setQuery] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const lastPropsRef = useRef(items)
|
||||
const copy = DIRECTION_COPY[direction]
|
||||
|
||||
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 chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const editRef = useRef<HTMLButtonElement>(null)
|
||||
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
|
||||
// Additions resolve through `options` (the addable catalog), same asymmetry
|
||||
// as the município cell's `optionById`: only what can still be added needs to
|
||||
// be looked up there, and a removal always has its item in `items` already.
|
||||
const optionItemById = useMemo(
|
||||
() => new Map(options.map((option) => [option.id, option.item])),
|
||||
[options],
|
||||
)
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
const propIds = items.map((item) => item.id)
|
||||
if (
|
||||
sameIdSet(
|
||||
propIds,
|
||||
lastPropsRef.current.map((item) => item.id),
|
||||
)
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = items
|
||||
setCurrent(items)
|
||||
}, [items])
|
||||
|
||||
const currentIds = useMemo(() => new Set(current.map((item) => item.id)), [current])
|
||||
const optionById = useMemo(() => new Map(options.map((option) => [option.id, option])), [options])
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!open) return []
|
||||
return options.filter(
|
||||
(option) => !currentIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
|
||||
)
|
||||
}, [open, options, currentIds, query])
|
||||
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
const chipsKey = current.map((item) => item.id).join('|')
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
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()
|
||||
}, [measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow || visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const firstChip = chipRects[0]
|
||||
if (!firstChip) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
const chipHeight = firstChip.height
|
||||
setCollapsedHeight(chipHeight * COLLAPSED_CHIP_ROWS + CHIP_GAP_PX * (COLLAPSED_CHIP_ROWS - 1))
|
||||
|
||||
const lastVisibleTop = firstChip.top + (chipHeight + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// Reserve room on the last visible line for "Ver mais…" (and the sibling pencil).
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const pencilWidth = editRef.current?.getBoundingClientRect().width ?? 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + pencilWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (!trailing || trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
const toggle = (itemId: number, assigned: boolean) => {
|
||||
// The removed summary is captured up front (not re-derived from the
|
||||
// catalog) so a failed removal can be undone even for an item that has
|
||||
// since dropped out of `options` — same asymmetry as the `optionById`
|
||||
// lookup below, which only needs to resolve *additions*.
|
||||
const removedItem = assigned ? undefined : current.find((item) => item.id === itemId)
|
||||
if (assigned && !optionById.has(itemId)) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setCurrent((previous) => {
|
||||
if (assigned) {
|
||||
if (previous.some((item) => item.id === itemId)) return previous
|
||||
const option = optionById.get(itemId)
|
||||
if (!option) return previous
|
||||
return [...previous, option.item]
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]): RelationChip[] => {
|
||||
const chips: RelationChip[] = []
|
||||
for (const id of ids) {
|
||||
const item = itemById.get(id) ?? optionItemById.get(id)
|
||||
if (!item) continue
|
||||
chips.push({ key: String(id), label: itemLabel(item), href: item.href, ids: [id] })
|
||||
}
|
||||
return previous.filter((item) => item.id !== itemId)
|
||||
})
|
||||
setQuery('')
|
||||
return chips
|
||||
},
|
||||
[itemById, optionItemById],
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
if (direction === 'fromLeadership') {
|
||||
formData.set('leadershipId', String(fixedId))
|
||||
formData.set('stateDeputyId', String(itemId))
|
||||
} else {
|
||||
formData.set('leadershipId', String(itemId))
|
||||
formData.set('stateDeputyId', String(fixedId))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>): RelationSearchHit[] =>
|
||||
options
|
||||
.filter(
|
||||
(option) => !assignedIds.has(option.id) && matchesAtWordStart(option.searchLabel, query),
|
||||
)
|
||||
.map((option) => ({ key: String(option.id), label: option.searchLabel, ids: [option.id] })),
|
||||
[options],
|
||||
)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await membershipAction({}, formData)
|
||||
if (result.status === 'success') return
|
||||
// Undo only this delta — not the whole cell back to `lastPropsRef` —
|
||||
// so a failed toggle can't wipe an earlier toggle from the same batch
|
||||
// that already saved successfully.
|
||||
setCurrent((previous) =>
|
||||
assigned
|
||||
? previous.filter((item) => item.id !== itemId)
|
||||
: removedItem
|
||||
? [...previous, removedItem]
|
||||
: previous,
|
||||
)
|
||||
const message = result.message ?? copy.saveError
|
||||
setErrorMessage(message)
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const itemId = changedIds[0]
|
||||
const formData = new FormData()
|
||||
if (direction === 'fromLeadership') {
|
||||
formData.set('leadershipId', String(fixedId))
|
||||
formData.set('stateDeputyId', String(itemId))
|
||||
} else {
|
||||
formData.set('leadershipId', String(itemId))
|
||||
formData.set('stateDeputyId', String(fixedId))
|
||||
}
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[direction, fixedId],
|
||||
)
|
||||
|
||||
const statusMessage = errorMessage ?? (isPending ? copy.saving : '')
|
||||
|
||||
const showAllChips = !measureOverflow || expanded
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < current.length
|
||||
const visibleItems = (() => {
|
||||
if (!measureOverflow || showAllChips || measuring || visibleChipCount === null) return current
|
||||
return current.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.
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
const chipRowStyle =
|
||||
clamping && collapsedHeight !== null ? { maxHeight: collapsedHeight } : undefined
|
||||
|
||||
// Mounted while measuring so width is reserved; hidden until overflow is known.
|
||||
const expandToggle =
|
||||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||||
<button
|
||||
type="button"
|
||||
data-relation-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setExpanded((previous) => !previous)
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
const chipCellCopy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: copy.searchPlaceholder,
|
||||
searchLabel: copy.searchLabel,
|
||||
suggestionsLabel: copy.suggestionsLabel,
|
||||
emptyDrawerMessage: copy.emptyDrawerMessage,
|
||||
savingMessage: copy.savingMessage,
|
||||
savedMessage: copy.savedMessage,
|
||||
removedMessage: copy.removedMessage,
|
||||
}),
|
||||
[copy],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1',
|
||||
measureOverflow && current.length > 0 ? 'min-w-0 flex-1' : undefined,
|
||||
clamping && 'overflow-hidden',
|
||||
)}
|
||||
style={chipRowStyle}
|
||||
>
|
||||
{current.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : (
|
||||
visibleItems.map((item) => (
|
||||
<Badge key={item.id} variant="outline" asChild>
|
||||
<Link href={item.href} data-relation-chip className="max-w-full truncate">
|
||||
{itemLabel(item)}
|
||||
</Link>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{expandToggle}
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
ref={editRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={copy.editLabel}
|
||||
className={cn(
|
||||
'inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
open ? 'bg-muted/60 text-foreground' : undefined,
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 p-0">
|
||||
<div className="relative flex flex-col gap-2 p-3 pb-0">
|
||||
<p className="text-sm font-medium">{copy.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{copy.description}</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
|
||||
aria-label={copy.savingAria}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{current.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
||||
{current.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={`Remover ${item.label}`}
|
||||
onClick={() => toggle(item.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{itemLabel(item)}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
aria-label={copy.searchAriaLabel}
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${direction}-${option.id}`}
|
||||
onSelect={() => toggle(option.id, true)}
|
||||
>
|
||||
<span className="truncate">{option.searchLabel}</span>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
* Outside the Popover on purpose (B32+ F4): a region that unmounts with
|
||||
* the overlay announces nothing once the overlay closes, and closing is
|
||||
* what commits here. This cell still hand-rolls its trigger and popover —
|
||||
* migrating it onto `CampaignCellEditOverlay` is B31's extraction trigger.
|
||||
*/}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<RelationChipCell
|
||||
ownerId={fixedId}
|
||||
ownerName={ownerName}
|
||||
ids={items.map((item) => item.id)}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={membershipAction}
|
||||
drawerTitle={copy.drawerTitle}
|
||||
triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`}
|
||||
updateErrorMessage={copy.updateErrorMessage}
|
||||
copy={chipCellCopy}
|
||||
measureOverflow={measureOverflow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,14 @@
|
|||
'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 { MapPinIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import {
|
||||
RelationChipCell,
|
||||
type RelationChip,
|
||||
type RelationChipCellCopy,
|
||||
type RelationSearchHit,
|
||||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import {
|
||||
buildMunicipalityPortfolioChips,
|
||||
searchMunicipalityPortfolio,
|
||||
|
|
@ -26,44 +16,37 @@ import {
|
|||
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 toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip =>
|
||||
chip.kind === 'territory'
|
||||
? {
|
||||
key: chip.key,
|
||||
label: chip.label,
|
||||
icon: <MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />,
|
||||
trailingLabel: String(chip.municipalityIds.length),
|
||||
ids: chip.municipalityIds,
|
||||
removalSuffix: ` — ${chip.municipalityIds.length} municípios`,
|
||||
}
|
||||
: {
|
||||
key: chip.key,
|
||||
label: chip.label,
|
||||
href: `/campanha/municipios/${chip.slug}`,
|
||||
ids: [chip.municipalityId],
|
||||
}
|
||||
|
||||
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'
|
||||
const toRelationHit = (hit: MunicipalityPortfolioSearchHit): RelationSearchHit => ({
|
||||
key: hit.key,
|
||||
label: hit.label,
|
||||
description: hitDescription(hit),
|
||||
ids: hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds,
|
||||
})
|
||||
|
||||
type MunicipalityPortfolioCellProps = {
|
||||
/** `null` in draft mode (a row that does not exist yet). */
|
||||
|
|
@ -80,12 +63,7 @@ type MunicipalityPortfolioCellProps = {
|
|||
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.
|
||||
*/
|
||||
/** Ceiling the relation enforces server-side. */
|
||||
maxItems?: number
|
||||
/** Draft mode (new row): keep changes local and report them upward. */
|
||||
draft?: boolean
|
||||
|
|
@ -99,6 +77,12 @@ type MunicipalityPortfolioCellProps = {
|
|||
updateErrorMessage: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Município specialization of `RelationChipCell`: territory/ZE chips collapse a
|
||||
* complete membership into one batch chip, and every município chip links to
|
||||
* its detail page. All of the interaction machine (optimistic delta, undo,
|
||||
* combobox, floor/ceiling, pointer-fine/coarse split) lives in the shared cell.
|
||||
*/
|
||||
export const MunicipalityPortfolioCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
|
|
@ -113,60 +97,19 @@ export const MunicipalityPortfolioCell = ({
|
|||
drawerTitle,
|
||||
updateErrorMessage,
|
||||
}: MunicipalityPortfolioCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [optimistic, setOptimistic] = useState<number[] | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
/** `null` means "render every chip so the next effect can measure them". */
|
||||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||||
const [measureToken, setMeasureToken] = useState(0)
|
||||
const lastRowWidth = useRef(0)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [feedback, setFeedback] = useState<
|
||||
{ kind: 'saved' } | { kind: 'error'; message: string } | null
|
||||
>(null)
|
||||
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
/** Only keyboard movement should scroll: the mouse is already where it points. */
|
||||
const activeFromKeyboard = useRef(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
const baseId = useId()
|
||||
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
|
||||
const optionId = useCallback(
|
||||
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
|
||||
[baseId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOptimistic((current) => {
|
||||
if (!current) return null
|
||||
return sameIdSet(current, municipalityIds) ? null : current
|
||||
})
|
||||
}, [municipalityIds])
|
||||
|
||||
const effectiveIds = optimistic ?? municipalityIds
|
||||
|
||||
/**
|
||||
* The baseline a deferred delta falls back to, which cannot be the render's
|
||||
* `municipalityIds`: the toast's "Desfazer" outlives the render that created
|
||||
* it, and by the time it fires the optimistic value has usually reconciled to
|
||||
* null — so the functional updater takes the fallback branch and would rebuild
|
||||
* the row from a set that predates every edit made since. That divergence is
|
||||
* permanent, because the reconcile effect above only clears on an exact match.
|
||||
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
|
||||
* `municipality.advisors`, so a município this relation holds outside that
|
||||
* scope cannot be removed either — the write would silently no-op at the
|
||||
* document level with no way for the cell to explain why. Rather than let
|
||||
* that failure surface, the chip for it never renders for the advisor at
|
||||
* all; staff (`addableIds` undefined) still see and can remove every link.
|
||||
*/
|
||||
const latestIds = useRef(municipalityIds)
|
||||
useEffect(() => {
|
||||
latestIds.current = effectiveIds
|
||||
}, [effectiveIds])
|
||||
|
||||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||||
const chips = useMemo(
|
||||
() => buildMunicipalityPortfolioChips(effectiveIds, municipalityIndex),
|
||||
[effectiveIds, municipalityIndex],
|
||||
const visibleIds = useMemo(
|
||||
() => (addableIds ? municipalityIds.filter((id) => addableIds.has(id)) : municipalityIds),
|
||||
[municipalityIds, addableIds],
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -179,635 +122,62 @@ export const MunicipalityPortfolioCell = ({
|
|||
: 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 buildChips = useCallback(
|
||||
(ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip),
|
||||
[municipalityIndex],
|
||||
)
|
||||
|
||||
const hitsKey = hits.map((hit) => hit.key).join('|')
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [hitsKey])
|
||||
|
||||
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
|
||||
useEffect(() => {
|
||||
if (!activeFromKeyboard.current) return
|
||||
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [activeIndex, activeSurface, optionId])
|
||||
|
||||
/**
|
||||
* The token is what makes a stale measurement recoverable: an invalidation
|
||||
* batched with a measurement would otherwise settle back on `null` without
|
||||
* changing any dependency, leaving the cell unmeasured forever.
|
||||
*/
|
||||
const invalidateMeasurement = useCallback(() => {
|
||||
setVisibleChipCount(null)
|
||||
setMeasureToken((token) => token + 1)
|
||||
}, [])
|
||||
|
||||
// Keyed by content, not array identity: a re-render that rebuilds the same
|
||||
// chips must not throw away a measurement that is still valid. The remove
|
||||
// button is absolutely positioned, so revealing it never changes a chip's box.
|
||||
const chipsKey = chips.map((chip) => chip.key).join('|')
|
||||
useEffect(() => {
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||||
lastRowWidth.current = row.clientWidth
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (row.clientWidth === lastRowWidth.current) return
|
||||
lastRowWidth.current = row.clientWidth
|
||||
invalidateMeasurement()
|
||||
})
|
||||
observer.observe(row)
|
||||
return () => observer.disconnect()
|
||||
}, [invalidateMeasurement])
|
||||
|
||||
/**
|
||||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||||
* three visible rows — and the room the toggle needs on the third one — is
|
||||
* read from the real layout instead of estimated from label lengths.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-portfolio-chip]')]
|
||||
if (chipElements.length === 0) {
|
||||
setVisibleChipCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||||
const lastVisibleTop =
|
||||
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||||
|
||||
// The toggle — and the search input — must share the last visible line,
|
||||
// otherwise they wrap into a line nobody asked for. The input's box is read
|
||||
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
|
||||
// leftover of the row, which would reserve the whole line. A zero-width
|
||||
// rect means the input is `display: none` (coarse pointer), where the row
|
||||
// has no trailing control to make room for at all.
|
||||
const toggleWidth =
|
||||
row.querySelector<HTMLElement>('[data-portfolio-toggle]')?.getBoundingClientRect().width ?? 0
|
||||
const inputElement = inputRef.current
|
||||
const inputWidth =
|
||||
inputElement && inputElement.getBoundingClientRect().width > 0
|
||||
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
|
||||
: 0
|
||||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||||
while (fitting > 0 && fitting < chipElements.length) {
|
||||
const trailing = chipRects[fitting - 1]
|
||||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||||
fitting -= 1
|
||||
}
|
||||
|
||||
setVisibleChipCount(fitting)
|
||||
}, [measureToken, visibleChipCount])
|
||||
|
||||
/**
|
||||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||||
* each compute from the set their own render captured and drop the other's work.
|
||||
*/
|
||||
const commit = (changedIds: number[], assigned: boolean) => {
|
||||
if (changedIds.length === 0) return
|
||||
|
||||
if (draft) {
|
||||
// Advanced synchronously as well: the parent holds the draft in state, so
|
||||
// two toggles batched into one tick would otherwise both read the prop of
|
||||
// the render they were queued from and the second would drop the first.
|
||||
const next = withDelta(latestIds.current, changedIds, assigned)
|
||||
latestIds.current = next
|
||||
onDraftChange?.(next)
|
||||
} else {
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
|
||||
}
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setFeedback(null)
|
||||
|
||||
if (draft) return
|
||||
if (ownerId === null) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('ownerId', String(ownerId))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
for (const id of changedIds) formData.append('municipalityIds', String(id))
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await commitAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
setFeedback({ kind: 'saved' })
|
||||
// A território/ZE removal drops up to 30 links in one tap, and the row
|
||||
// it left behind carries no trace of what was there. Single removals
|
||||
// are one search away from being restored and get no toast.
|
||||
if (!assigned && changedIds.length > 1) {
|
||||
toast.success(`${changedIds.length} municípios removidos.`, {
|
||||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Undo only THIS delta: reverting to the server baseline would also wipe
|
||||
// a sibling toggle from the same burst that already saved.
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
|
||||
const message = result.message ?? updateErrorMessage
|
||||
setFeedback({ kind: 'error', message })
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const chipMunicipalityIds = (chip: MunicipalityPortfolioChip): number[] =>
|
||||
chip.kind === 'territory' ? chip.municipalityIds : [chip.municipalityId]
|
||||
|
||||
/** The relation's floor, refused here so the server never has to say no. */
|
||||
const removalFloorReason = (chip: MunicipalityPortfolioChip): string | null =>
|
||||
assignedIds.size - chipMunicipalityIds(chip).length < minItems
|
||||
? `${ownerName} precisa de pelo menos ${minItems === 1 ? 'um município' : `${minItems} municípios`}`
|
||||
: null
|
||||
|
||||
const additionCapReason = (additions: number[]): string | null =>
|
||||
maxItems !== undefined && assignedIds.size + additions.length > maxItems
|
||||
? `${ownerName} aceita no máximo ${maxItems} municípios.`
|
||||
: null
|
||||
|
||||
/** A batch chip says how much it removes, in the label a screen reader reads. */
|
||||
const chipScopeLabel = (chip: MunicipalityPortfolioChip): string =>
|
||||
chip.kind === 'territory'
|
||||
? `${chip.label} — ${chip.municipalityIds.length} municípios`
|
||||
: chip.label
|
||||
|
||||
const chipRemoveLabel = (chip: MunicipalityPortfolioChip, floorReason: string | null): string =>
|
||||
floorReason
|
||||
? `${floorReason} — não é possível remover ${chip.label}`
|
||||
: `Remover ${chipScopeLabel(chip)}`
|
||||
|
||||
/**
|
||||
* Refusals explain themselves on attempt instead of going silent: the control
|
||||
* keeps `aria-disabled` (so it stays in the tab order and can still be
|
||||
* activated) rather than `disabled`, which would hide the only copy of the
|
||||
* reason from every keyboard user.
|
||||
*/
|
||||
const attemptRemove = (chip: MunicipalityPortfolioChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
if (floorReason) {
|
||||
toast.error(`${floorReason}.`)
|
||||
return
|
||||
}
|
||||
commit(chipMunicipalityIds(chip), false)
|
||||
}
|
||||
|
||||
const hitMunicipalityIds = (hit: MunicipalityPortfolioSearchHit): number[] =>
|
||||
hit.kind === 'municipality' ? [hit.municipalityId] : hit.municipalityIds
|
||||
|
||||
const pickHit = (hit: MunicipalityPortfolioSearchHit) => {
|
||||
const additions = hitMunicipalityIds(hit)
|
||||
const capReason = additionCapReason(additions)
|
||||
if (capReason) {
|
||||
toast.error(capReason)
|
||||
return
|
||||
}
|
||||
commit(additions, true)
|
||||
}
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
/**
|
||||
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
|
||||
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
|
||||
* abandons the search. Without it the suggestions are mouse-only.
|
||||
*/
|
||||
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Home' || event.key === 'End') {
|
||||
if (hits.length === 0) return
|
||||
event.preventDefault()
|
||||
activeFromKeyboard.current = true
|
||||
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const hit = hits[activeIndex]
|
||||
if (!hit) return
|
||||
event.preventDefault()
|
||||
pickHit(hit)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (drawerOpen) {
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
closeSuggestions()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||||
const showAllChips = expanded || open
|
||||
const measuring = visibleChipCount === null
|
||||
const hasHiddenChips = visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips = showAllChips || measuring ? chips : chips.slice(0, visibleChipCount)
|
||||
/**
|
||||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||||
* trimmed the row is three lines tall on its own, and capping it again would
|
||||
* clip whatever shares the last line (the search input is taller than a chip).
|
||||
*/
|
||||
const clamping = measuring && !showAllChips
|
||||
|
||||
/**
|
||||
* One region, four things to say, in this order: a save in flight outranks
|
||||
* everything; a failure outranks the search; and the search outranks a past
|
||||
* success, otherwise "Municípios salvos." would shadow every later result
|
||||
* count for the rest of the session.
|
||||
*/
|
||||
const statusMessage = isPending
|
||||
? 'Salvando municípios…'
|
||||
: feedback?.kind === 'error'
|
||||
? feedback.message
|
||||
: suggesting
|
||||
? resultsMessage(hits.length)
|
||||
: feedback?.kind === 'saved'
|
||||
? 'Municípios salvos.'
|
||||
: ''
|
||||
|
||||
/**
|
||||
* Mounted while measuring so its width is reserved on the last line, but kept
|
||||
* out of the tab order and the accessibility tree until the overflow it
|
||||
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
|
||||
* above it, so a tap opens the full list instead of expanding in place.
|
||||
*/
|
||||
const expandToggle =
|
||||
measuring || hasHiddenChips ? (
|
||||
<button
|
||||
type="button"
|
||||
data-portfolio-toggle
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
|
||||
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = !showAllChips
|
||||
setExpanded(next)
|
||||
// Focus is what keeps the cell open — collapsing must release it.
|
||||
if (!next) {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const chipBody = (chip: MunicipalityPortfolioChip) => (
|
||||
<>
|
||||
{chip.kind === 'territory' ? (
|
||||
<MapPinIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
{chip.kind === 'territory' ? (
|
||||
<span className="shrink-0 text-xs tabular-nums opacity-70">
|
||||
{chip.municipalityIds.length}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
const searchHits = useCallback(
|
||||
(query: string, assignedIds: ReadonlySet<number>) =>
|
||||
searchMunicipalityPortfolio(query, searchIndex, assignedIds).map(toRelationHit),
|
||||
[searchIndex],
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
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))
|
||||
return formData
|
||||
},
|
||||
[ownerId],
|
||||
)
|
||||
|
||||
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
|
||||
const copy = useMemo<RelationChipCellCopy>(
|
||||
() => ({
|
||||
searchPlaceholder: 'Buscar município, território ou ZE…',
|
||||
searchLabel: 'Buscar município, território de identidade ou zona eleitoral',
|
||||
suggestionsLabel: 'Sugestões de municípios',
|
||||
emptyDrawerMessage: 'Nenhum município vinculado.',
|
||||
savingMessage: 'Salvando municípios…',
|
||||
savedMessage: 'Municípios salvos.',
|
||||
removedMessage: (count) => `${count} municípios removidos.`,
|
||||
floorMessage: (min) =>
|
||||
`${ownerName} precisa de pelo menos ${min === 1 ? 'um município' : `${min} municípios`}`,
|
||||
capMessage: (max) => `${ownerName} aceita no máximo ${max} municípios.`,
|
||||
}),
|
||||
[ownerName],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
|
||||
// the write is fire-and-forget, and a cell that never reacts reads as
|
||||
// "nothing happened" until the next navigation proves otherwise.
|
||||
aria-busy={isPending || undefined}
|
||||
// Same box in both pointer modes so nothing reflows when the row is touched.
|
||||
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
|
||||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||||
// and stops the click, so the pointer policy stays in the media queries.
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-chip-remove]') || target.closest('a')) return
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={chipRowRef}
|
||||
className={cn(
|
||||
'flex min-h-8 flex-wrap items-center gap-1.5',
|
||||
clamping && 'max-h-18 overflow-hidden',
|
||||
isPending && 'opacity-60 transition-opacity',
|
||||
)}
|
||||
>
|
||||
{chips.length === 0 && !searching ? (
|
||||
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
|
||||
) : null}
|
||||
{visibleChips.map(inlineChip)}
|
||||
{expandToggle}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={inlineSuggesting}
|
||||
aria-controls={listboxId('inline')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
if (!rootRef.current?.contains(document.activeElement)) {
|
||||
closeSuggestions()
|
||||
}
|
||||
}, 120)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : SEARCH_PLACEHOLDER}
|
||||
aria-label={SEARCH_LABEL}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
|
||||
// The input owns focus the whole time — the list is a suggestion
|
||||
// surface, not a dialog, and must never steal or restore focus.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{suggestionList('inline')}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/*
|
||||
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
|
||||
* table cannot be asked to tell "open the município" from "delete it". The
|
||||
* links it covers come back inside the Drawer.
|
||||
*
|
||||
* `variant` is hardcoded because the container takes its policy from the
|
||||
* call site (B42) — and here the policy is the `pointer:` media query on
|
||||
* the trigger, not the viewport, so a touch laptop gets this sheet and a
|
||||
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
|
||||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||||
* the whole cell, and tinting it would tint the chips it covers.
|
||||
*/}
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={`Editar municípios de ${ownerName}`}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other two in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>
|
||||
{chips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{chips.map((chip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<li
|
||||
key={chip.key}
|
||||
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
|
||||
>
|
||||
{chip.kind === 'municipality' ? (
|
||||
<Link
|
||||
href={`/campanha/municipios/${chip.slug}`}
|
||||
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
|
||||
>
|
||||
<Badge variant="secondary" className="min-w-0 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
|
||||
aria-disabled={floorReason !== null || undefined}
|
||||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||||
onClick={() => attemptRemove(chip)}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Input
|
||||
value={query}
|
||||
role="combobox"
|
||||
aria-expanded={drawerSuggesting}
|
||||
aria-controls={listboxId('drawer')}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value)
|
||||
setFeedback(null)
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={SEARCH_PLACEHOLDER}
|
||||
aria-label={SEARCH_LABEL}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
|
||||
</>
|
||||
</CampaignCellEditOverlay>
|
||||
|
||||
{/*
|
||||
* This cell keeps its own region rather than the shell's `statusMessage`:
|
||||
* the shell mounts that one from the first open of the overlay, and on a
|
||||
* fine pointer this cell's editor IS the cell — the Drawer never opens,
|
||||
* so nothing would ever be announced.
|
||||
*/}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
<RelationChipCell
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
ids={visibleIds}
|
||||
buildChips={buildChips}
|
||||
searchHits={searchHits}
|
||||
minItems={minItems}
|
||||
maxItems={maxItems}
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
drawerTitle={drawerTitle}
|
||||
triggerLabel={`Editar municípios de ${ownerName}`}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
copy={copy}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
813
src/components/campaign/shared/RelationChipCell.tsx
Normal file
813
src/components/campaign/shared/RelationChipCell.tsx
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import { 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 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'
|
||||
|
||||
export type RelationChip = {
|
||||
key: string
|
||||
label: string
|
||||
/** Omit for a chip that is not a link (e.g. a batch/territory chip). */
|
||||
href?: string
|
||||
/** Rendered before the label, e.g. a territory pin. */
|
||||
icon?: ReactNode
|
||||
/** Rendered after the label as a small muted count. */
|
||||
trailingLabel?: string
|
||||
/** Ids removing (or re-adding, on undo) this chip touches — usually one. */
|
||||
ids: number[]
|
||||
/** Appended to the label in the remove control's aria-label, e.g. ` — 2 municípios`. */
|
||||
removalSuffix?: string
|
||||
}
|
||||
|
||||
export type RelationSearchHit = {
|
||||
key: string
|
||||
label: string
|
||||
/** Second line under the suggestion. Omit for a flat, single-line list. */
|
||||
description?: string
|
||||
ids: number[]
|
||||
}
|
||||
|
||||
export type RelationChipCellCopy = {
|
||||
searchPlaceholder: string
|
||||
/** Shown once the cell already has chips. Defaults to "Adicionar…". */
|
||||
addPlaceholder?: string
|
||||
searchLabel: string
|
||||
suggestionsLabel: string
|
||||
emptyDrawerMessage: string
|
||||
savingMessage: string
|
||||
savedMessage: string
|
||||
removedMessage: (count: number) => string
|
||||
/** Only invoked when `minItems` is actually configured. */
|
||||
floorMessage?: (min: number) => string
|
||||
/** Only invoked when `maxItems` is actually configured. */
|
||||
capMessage?: (max: number) => string
|
||||
}
|
||||
|
||||
type RelationChipCellProps = {
|
||||
/** `null` in draft mode (a row that does not exist yet). */
|
||||
ownerId: number | null
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer description. */
|
||||
ownerName: string
|
||||
ids: number[]
|
||||
buildChips: (ids: number[]) => RelationChip[]
|
||||
searchHits: (query: string, assignedIds: ReadonlySet<number>) => RelationSearchHit[]
|
||||
/** Floor the relation enforces server-side. */
|
||||
minItems?: number
|
||||
/**
|
||||
* Ceiling the relation enforces server-side. Checked here so a batch 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?: (ids: number[]) => void
|
||||
buildFormData: (changedIds: number[], assigned: boolean) => FormData
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
drawerTitle: string
|
||||
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
|
||||
triggerLabel: string
|
||||
updateErrorMessage: string
|
||||
copy: RelationChipCellCopy
|
||||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||||
measureOverflow?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared interaction machine behind every bounded-set relation edited by chips
|
||||
* (município portfolios, leadership↔dobradinha membership): optimistic delta
|
||||
* commits that survive concurrent toggles, an undo toast scoped to exactly what
|
||||
* changed, an ARIA 1.2 combobox for search/suggestions, a floor/ceiling refused
|
||||
* with a spoken reason before the optimistic apply, and a pointer-fine inline
|
||||
* editor / pointer-coarse Drawer split. What differs between relations — chip
|
||||
* content, search, the write's field names, copy — is entirely prop-driven;
|
||||
* nothing domain-specific lives here.
|
||||
*/
|
||||
export const RelationChipCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
ids,
|
||||
buildChips,
|
||||
searchHits,
|
||||
minItems = 0,
|
||||
maxItems,
|
||||
draft = false,
|
||||
onDraftChange,
|
||||
buildFormData,
|
||||
commitAction,
|
||||
drawerTitle,
|
||||
triggerLabel,
|
||||
updateErrorMessage,
|
||||
copy,
|
||||
measureOverflow = true,
|
||||
}: RelationChipCellProps) => {
|
||||
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, ids) ? null : current
|
||||
})
|
||||
}, [ids])
|
||||
|
||||
const effectiveIds = optimistic ?? ids
|
||||
|
||||
/**
|
||||
* The baseline a deferred delta falls back to, which cannot be the render's
|
||||
* `ids`: the toast's "Desfazer" outlives the render that created it, and by
|
||||
* the time it fires the optimistic value has usually reconciled to null — so
|
||||
* the functional updater takes the fallback branch and would rebuild the row
|
||||
* from a set that predates every edit made since. That divergence is
|
||||
* permanent, because the reconcile effect above only clears on an exact match.
|
||||
*/
|
||||
const latestIds = useRef(ids)
|
||||
useEffect(() => {
|
||||
latestIds.current = effectiveIds
|
||||
}, [effectiveIds])
|
||||
|
||||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||||
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
|
||||
|
||||
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 ? searchHits(query, assignedIds) : []),
|
||||
[suggesting, query, searchHits, 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(() => {
|
||||
if (!measureOverflow) return
|
||||
invalidateMeasurement()
|
||||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureOverflow) return
|
||||
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()
|
||||
}, [measureOverflow, 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 (!measureOverflow) return
|
||||
if (visibleChipCount !== null) return
|
||||
const row = chipRowRef.current
|
||||
if (!row) return
|
||||
|
||||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-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-relation-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)
|
||||
}, [measureOverflow, measureToken, visibleChipCount])
|
||||
|
||||
/**
|
||||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||||
* each compute from the set their own render captured and drop the other's work.
|
||||
*/
|
||||
const commit = (changedIds: number[], assigned: boolean) => {
|
||||
if (changedIds.length === 0) return
|
||||
|
||||
if (draft) {
|
||||
// Advanced synchronously as well: the parent holds the draft in state, so
|
||||
// two toggles batched into one tick would otherwise both read the prop of
|
||||
// the render they were queued from and the second would drop the first.
|
||||
const next = withDelta(latestIds.current, changedIds, assigned)
|
||||
latestIds.current = next
|
||||
onDraftChange?.(next)
|
||||
} else {
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
|
||||
}
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setFeedback(null)
|
||||
|
||||
if (draft) return
|
||||
if (ownerId === null) return
|
||||
|
||||
const formData = buildFormData(changedIds, assigned)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await commitAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
setFeedback({ kind: 'saved' })
|
||||
// A batch removal drops several 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(copy.removedMessage(changedIds.length), {
|
||||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Undo only THIS delta: reverting to the server baseline would also wipe
|
||||
// a sibling toggle from the same burst that already saved.
|
||||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
|
||||
const message = result.message ?? updateErrorMessage
|
||||
setFeedback({ kind: 'error', message })
|
||||
toast.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
/** The relation's floor, refused here so the server never has to say no. */
|
||||
const removalFloorReason = (chip: RelationChip): string | null =>
|
||||
assignedIds.size - chip.ids.length < minItems
|
||||
? (copy.floorMessage ?? ((min) => `${ownerName} precisa de pelo menos ${min}.`))(minItems)
|
||||
: null
|
||||
|
||||
const additionCapReason = (additions: number[]): string | null =>
|
||||
maxItems !== undefined && assignedIds.size + additions.length > maxItems
|
||||
? (copy.capMessage ?? ((max) => `${ownerName} aceita no máximo ${max}.`))(maxItems)
|
||||
: null
|
||||
|
||||
const chipRemoveLabel = (chip: RelationChip, floorReason: string | null): string =>
|
||||
floorReason
|
||||
? `${floorReason} — não é possível remover ${chip.label}`
|
||||
: `Remover ${chip.label}${chip.removalSuffix ?? ''}`
|
||||
|
||||
/**
|
||||
* 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: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
if (floorReason) {
|
||||
toast.error(`${floorReason}.`)
|
||||
return
|
||||
}
|
||||
commit(chip.ids, false)
|
||||
}
|
||||
|
||||
const pickHit = (hit: RelationSearchHit) => {
|
||||
const capReason = additionCapReason(hit.ids)
|
||||
if (capReason) {
|
||||
toast.error(capReason)
|
||||
return
|
||||
}
|
||||
commit(hit.ids, 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 = !measureOverflow || expanded || open
|
||||
const measuring = measureOverflow && visibleChipCount === null
|
||||
const hasHiddenChips =
|
||||
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
|
||||
const visibleChips =
|
||||
!measureOverflow || showAllChips || measuring || visibleChipCount === null
|
||||
? 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 the saved message would shadow every later result count
|
||||
* for the rest of the session.
|
||||
*/
|
||||
const statusMessage = isPending
|
||||
? copy.savingMessage
|
||||
: feedback?.kind === 'error'
|
||||
? feedback.message
|
||||
: suggesting
|
||||
? resultsMessage(hits.length)
|
||||
: feedback?.kind === 'saved'
|
||||
? copy.savedMessage
|
||||
: ''
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||||
<button
|
||||
type="button"
|
||||
data-relation-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: RelationChip) => (
|
||||
<>
|
||||
{chip.icon}
|
||||
<span className="truncate">{chip.label}</span>
|
||||
{chip.trailingLabel !== undefined ? (
|
||||
<span className="shrink-0 text-xs tabular-nums opacity-70">{chip.trailingLabel}</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: RelationChip) => {
|
||||
const floorReason = removalFloorReason(chip)
|
||||
return (
|
||||
<span
|
||||
key={chip.key}
|
||||
data-relation-chip
|
||||
className="group/chip relative inline-flex max-w-full"
|
||||
>
|
||||
{chip.href ? (
|
||||
<Badge variant="secondary" className="max-w-full font-normal" asChild>
|
||||
<Link href={chip.href} className="truncate underline-offset-4 hover:underline">
|
||||
{chipBody(chip)}
|
||||
</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: RelationSearchHit, index: number, surface: SuggestionSurface) => {
|
||||
const capped = additionCapReason(hit.ids) !== 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>
|
||||
{hit.description ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{hit.description}
|
||||
{capped ? ' · excede o limite' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</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={copy.suggestionsLabel}>
|
||||
{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 ? (copy.addPlaceholder ?? 'Adicionar…') : copy.searchPlaceholder
|
||||
}
|
||||
aria-label={copy.searchLabel}
|
||||
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 entity" from "delete it". The
|
||||
* links it covers come back inside the Drawer.
|
||||
*
|
||||
* `variant` is hardcoded because the container takes its policy from the
|
||||
* call site (B42) — and here the policy is the `pointer:` media query on
|
||||
* the trigger, not the viewport, so a touch laptop gets this sheet and a
|
||||
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
|
||||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||||
* the whole cell, and tinting it would tint the chips it covers.
|
||||
*/}
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={drawerOpen}
|
||||
// The container's trigger lets the click bubble to the cell root, which
|
||||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||||
// would surface a portaled listbox on a touch device the moment the
|
||||
// sheet closed. Clearing the search on close settles both.
|
||||
//
|
||||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||||
// pointer had typed into the inline input.
|
||||
onOpenChange={(next) => {
|
||||
setDrawerOpen(next)
|
||||
if (!next && drawerOpen) closeSuggestions()
|
||||
}}
|
||||
title={drawerTitle}
|
||||
description={ownerName}
|
||||
trigger={null}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||||
triggerBusy={isPending}
|
||||
sheetBodyClassName="gap-3"
|
||||
>
|
||||
{/*
|
||||
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
|
||||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||||
* allocation it saved was measured and is not a cost — the closed portal
|
||||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||||
*/}
|
||||
<>
|
||||
{chips.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</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.href ? (
|
||||
<Link
|
||||
href={chip.href}
|
||||
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={copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
|
||||
</>
|
||||
</CampaignCellEditOverlay>
|
||||
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/lib/municipalityStateDeputyMembership.ts
Normal file
30
src/lib/municipalityStateDeputyMembership.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
MAX_STATE_DEPUTIES_PER_MUNICIPALITY,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
} from '@/lib/schemas/municipality'
|
||||
|
||||
/**
|
||||
* Returns the next state-deputy-id list for a município after applying one
|
||||
* membership change, or `null` when the município is already in the desired
|
||||
* state (no write needed). Mirrors `nextAdvisorIdsAfterMembership` — the pure
|
||||
* delta calculator for the other side of a relation edited by chip+batch
|
||||
* (B37's `municipality.stateDeputies`, edited from the "Municípios" column of
|
||||
* `/campanha/dobradinhas`, which writes the município side of the relation
|
||||
* a `stateDeputy` chip in a batch touches).
|
||||
*/
|
||||
export const nextStateDeputyIdsAfterMunicipalityMembership = (
|
||||
currentStateDeputyIDs: readonly number[],
|
||||
stateDeputyId: number,
|
||||
assigned: boolean,
|
||||
): number[] | null => {
|
||||
const alreadyAssigned = currentStateDeputyIDs.includes(stateDeputyId)
|
||||
if (assigned === alreadyAssigned) return null
|
||||
|
||||
if (assigned) {
|
||||
if (currentStateDeputyIDs.length >= MAX_STATE_DEPUTIES_PER_MUNICIPALITY) {
|
||||
throw new Error(MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE)
|
||||
}
|
||||
return [...currentStateDeputyIDs, stateDeputyId]
|
||||
}
|
||||
return currentStateDeputyIDs.filter((id) => id !== stateDeputyId)
|
||||
}
|
||||
|
|
@ -7,9 +7,21 @@ import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge'
|
|||
export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const
|
||||
export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number]
|
||||
|
||||
/**
|
||||
* Anti-payload bound, not a domain rule: `municipality.stateDeputies` has no
|
||||
* product-level ceiling (a município can be dobrado with any number of
|
||||
* deputados), and the catalog itself caps out at 435. Named so the chip-batch
|
||||
* write (B37) and this whole-array strategy update stay on the same number —
|
||||
* they parse through the same schema, and a batch write that pushed past a
|
||||
* lower bound would leave the strategy ficha unsavable on its next submit.
|
||||
*/
|
||||
export const MAX_STATE_DEPUTIES_PER_MUNICIPALITY = 435
|
||||
|
||||
export const MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE = `Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.`
|
||||
|
||||
const stateDeputiesArraySchema = z
|
||||
.array(positiveRelationshipId)
|
||||
.max(20)
|
||||
.max(MAX_STATE_DEPUTIES_PER_MUNICIPALITY)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
|
||||
export const municipalityStrategyUpdateSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE } from '@/lib/schemas/municipality'
|
||||
import {
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -18,5 +19,34 @@ export const stateDeputyUpdateSchema = z.object({
|
|||
notes: trimmedNullableText(4000),
|
||||
})
|
||||
|
||||
export const STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE =
|
||||
'Somente a coordenação e a assessoria gerenciam dobradinhas.'
|
||||
|
||||
/**
|
||||
* Delta write for the "Municípios" column of `/campanha/dobradinhas` (B37) —
|
||||
* adds or removes a set of municipalities (one chip, or a whole território/ZE)
|
||||
* on `municipality.stateDeputies` — the inverted side of the relation this
|
||||
* `stateDeputy` sits on. `municipalityIds.max(435)` is the universe of
|
||||
* municípios in one batch call (a whole território), not the per-município
|
||||
* cap on dobradinhas, which lives in `MAX_STATE_DEPUTIES_PER_MUNICIPALITY`.
|
||||
*/
|
||||
export const stateDeputyMunicipalitiesBatchSchema = z.object({
|
||||
stateDeputyId: positiveRelationshipId,
|
||||
municipalityIds: z
|
||||
.array(positiveRelationshipId)
|
||||
.min(1)
|
||||
.max(435)
|
||||
.transform((ids) => [...new Set(ids)]),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES = [
|
||||
STATE_DEPUTY_MUNICIPALITIES_STAFF_MESSAGE,
|
||||
MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE,
|
||||
] as const
|
||||
|
||||
export type StateDeputyCreateInput = z.input<typeof stateDeputyCreateSchema>
|
||||
export type StateDeputyUpdateInput = z.input<typeof stateDeputyUpdateSchema>
|
||||
export type StateDeputyMunicipalitiesBatchInput = z.input<
|
||||
typeof stateDeputyMunicipalitiesBatchSchema
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type StateDeputyRowViewModel = {
|
|||
name: string
|
||||
slug: string
|
||||
party: string | null
|
||||
municipalityCount: number
|
||||
municipalityIDs: number[]
|
||||
leaderships: LeadershipRelationSummary[]
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +98,14 @@ export const loadStateDeputyListPageData = async (
|
|||
])
|
||||
|
||||
const stateDeputyIDs = result.docs.map((doc) => doc.id)
|
||||
const municipalityCounts = new Map<number, number>()
|
||||
const municipalityIDsByDeputy = new Map<number, number[]>()
|
||||
const leadershipsByDeputy = new Map<number, LeadershipRelationSummary[]>()
|
||||
|
||||
if (stateDeputyIDs.length) {
|
||||
const [municipalities, leaderships] = await Promise.all([
|
||||
// Intentional admin bypass: the chips render outside an advisor's scope
|
||||
// on purpose (B34 precedent) — an advisor must be able to see and undo
|
||||
// a município link even outside their own portfolio.
|
||||
payload.find({
|
||||
collection: 'municipality',
|
||||
where: { stateDeputies: { in: stateDeputyIDs } },
|
||||
|
|
@ -130,7 +133,9 @@ export const loadStateDeputyListPageData = async (
|
|||
for (const deputy of municipality.stateDeputies ?? []) {
|
||||
const id = relationshipId(deputy)
|
||||
if (id !== null && stateDeputyIDs.includes(id)) {
|
||||
municipalityCounts.set(id, (municipalityCounts.get(id) ?? 0) + 1)
|
||||
const list = municipalityIDsByDeputy.get(id) ?? []
|
||||
list.push(municipality.id)
|
||||
municipalityIDsByDeputy.set(id, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +167,7 @@ export const loadStateDeputyListPageData = async (
|
|||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
party: doc.party ?? null,
|
||||
municipalityCount: municipalityCounts.get(doc.id) ?? 0,
|
||||
municipalityIDs: municipalityIDsByDeputy.get(doc.id) ?? [],
|
||||
leaderships: leadershipsByDeputy.get(doc.id) ?? [],
|
||||
})),
|
||||
totalDocs: result.totalDocs,
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ describe('loadStateDeputyListPageData', () => {
|
|||
expect(result.rows[0]).toMatchObject({
|
||||
slug: stateDeputy.slug,
|
||||
party: 'PT',
|
||||
municipalityCount: 0,
|
||||
municipalityIDs: [],
|
||||
leaderships: [],
|
||||
})
|
||||
})
|
||||
|
|
|
|||
185
tests/int/campaignStateDeputyMunicipalityMembership.int.spec.ts
Normal file
185
tests/int/campaignStateDeputyMunicipalityMembership.int.spec.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setStateDeputyMunicipalitiesBatchRecord } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const stateDeputyIdsOf = async (municipalityID: number): Promise<number[]> => {
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityID,
|
||||
depth: 0,
|
||||
select: { stateDeputies: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
return (municipality.stateDeputies ?? []).map((entry) =>
|
||||
typeof entry === 'number' ? entry : entry.id,
|
||||
)
|
||||
}
|
||||
|
||||
describe('setStateDeputyMunicipalitiesBatchRecord (B37)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it.each(['coordinator', 'candidate'] as const)(
|
||||
'lets a %s add and remove a municipality by delta',
|
||||
async (role) => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser(role)
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs: addedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, actor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
expect(addedSlugs).toEqual([municipality.slug])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
|
||||
|
||||
const { slugs: removedSlugs } = await setStateDeputyMunicipalitiesBatchRecord(
|
||||
payload,
|
||||
actor,
|
||||
{
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: false,
|
||||
},
|
||||
)
|
||||
expect(removedSlugs).toEqual([municipality.slug])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('lets an advisor add a state deputy to a municipality they administer', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [administered.id],
|
||||
assigned: true,
|
||||
})
|
||||
|
||||
expect(slugs).toEqual([administered.slug])
|
||||
expect(await stateDeputyIdsOf(administered.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('denies an advisor writing to a municipality outside their portfolio', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const outside = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, advisor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [outside.id],
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(await stateDeputyIdsOf(outside.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('denies a leader actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leaderActor = await fixtures.createCampaignUser('leader')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await expect(
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, leaderActor, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
).rejects.toThrow(/coordenação e a assessoria/i)
|
||||
})
|
||||
|
||||
it('adds a whole batch (território / zona) in one write, touching every município', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [first.id, second.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(first.id)
|
||||
fixtures.touchMunicipality(second.id)
|
||||
|
||||
expect(slugs.sort()).toEqual([first.slug, second.slug].sort())
|
||||
expect(await stateDeputyIdsOf(first.id)).toEqual([stateDeputy.id])
|
||||
expect(await stateDeputyIdsOf(second.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('is idempotent — a no-op write touches nothing and returns no slugs to revalidate', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const stateDeputy = await fixtures.createStateDeputy()
|
||||
|
||||
await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const { slugs } = await setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: stateDeputy.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
})
|
||||
expect(slugs).toEqual([])
|
||||
expect(await stateDeputyIdsOf(municipality.id)).toEqual([stateDeputy.id])
|
||||
})
|
||||
|
||||
it('serializes a concurrent delta on the same municipality through the advisory lock', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const first = await fixtures.createStateDeputy()
|
||||
const second = await fixtures.createStateDeputy()
|
||||
|
||||
await Promise.all([
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: first.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
setStateDeputyMunicipalitiesBatchRecord(payload, coordinator, {
|
||||
stateDeputyId: second.id,
|
||||
municipalityIds: [municipality.id],
|
||||
assigned: true,
|
||||
}),
|
||||
])
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect((await stateDeputyIdsOf(municipality.id)).sort((a, b) => a - b)).toEqual(
|
||||
[first.id, second.id].sort((a, b) => a - b),
|
||||
)
|
||||
})
|
||||
})
|
||||
157
tests/unit/leadershipStateDeputyRelationCell.unit.spec.ts
Normal file
157
tests/unit/leadershipStateDeputyRelationCell.unit.spec.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LeadershipStateDeputyRelationCell,
|
||||
type RelationCellItem,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/LeadershipStateDeputyRelationCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* B37: this cell used to have its own Popover+Command implementation and zero
|
||||
* coverage. It is now a thin wrapper over `RelationChipCell` — the same engine
|
||||
* `MunicipalityPortfolioCell` uses — so these pins cover what changed for it:
|
||||
* the write's field names per `direction`, the party suffix, and the success
|
||||
* feedback the old Popover never gave (no toast, but the live region says so).
|
||||
*/
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const OPTIONS: RelationCellOption[] = [
|
||||
{
|
||||
id: 10,
|
||||
searchLabel: 'Ana Bastos',
|
||||
item: { id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
searchLabel: 'Beto Lima',
|
||||
item: { id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
|
||||
},
|
||||
]
|
||||
|
||||
const items: RelationCellItem[] = [
|
||||
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
]
|
||||
|
||||
const cell = (
|
||||
overrides: Partial<{
|
||||
direction: 'fromLeadership' | 'fromStateDeputy'
|
||||
items: RelationCellItem[]
|
||||
membershipAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}> = {},
|
||||
) =>
|
||||
createElement(LeadershipStateDeputyRelationCell, {
|
||||
direction: overrides.direction ?? 'fromLeadership',
|
||||
fixedId: 7,
|
||||
ownerName: 'Maria Souza',
|
||||
items: overrides.items ?? items,
|
||||
options: OPTIONS,
|
||||
membershipAction: overrides.membershipAction ?? (async () => ({ status: 'success' as const })),
|
||||
measureOverflow: false,
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('leadership ↔ state deputy relation cell', () => {
|
||||
it('renders each item as a linked chip, with the party suffix when present', () => {
|
||||
render(
|
||||
cell({
|
||||
items: [
|
||||
{ id: 10, label: 'Ana Bastos', href: '/campanha/dobradinhas/10' },
|
||||
{ id: 11, label: 'Beto Lima', href: '/campanha/dobradinhas/11', party: 'PT' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ana Bastos')).toBeTruthy()
|
||||
expect(screen.getByText('Beto Lima (PT)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends leadershipId/stateDeputyId/assigned=false, matching `fromLeadership`', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (_state: CampaignFormActionState, _formData: FormData) =>
|
||||
({ status: 'success' as const }) satisfies CampaignFormActionState,
|
||||
)
|
||||
render(cell({ direction: 'fromLeadership', membershipAction }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
|
||||
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
|
||||
const formData = membershipAction.mock.calls[0]?.[1] as FormData
|
||||
expect(formData.get('leadershipId')).toBe('7')
|
||||
expect(formData.get('stateDeputyId')).toBe('10')
|
||||
expect(formData.get('assigned')).toBe('false')
|
||||
})
|
||||
|
||||
it('swaps the field the fixed id fills for `fromStateDeputy`', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (_state: CampaignFormActionState, _formData: FormData) =>
|
||||
({ status: 'success' as const }) satisfies CampaignFormActionState,
|
||||
)
|
||||
render(cell({ direction: 'fromStateDeputy', membershipAction }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
|
||||
await waitFor(() => expect(membershipAction).toHaveBeenCalledTimes(1))
|
||||
const formData = membershipAction.mock.calls[0]?.[1] as FormData
|
||||
expect(formData.get('stateDeputyId')).toBe('7')
|
||||
expect(formData.get('leadershipId')).toBe('10')
|
||||
})
|
||||
|
||||
it('adds a searched option and reports the live region as saved', async () => {
|
||||
render(cell())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Editar dobradinhas de Maria Souza' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const searchInput = within(dialog).getByRole('combobox', { name: 'Buscar deputado estadual' })
|
||||
fireEvent.change(searchInput, { target: { value: 'Beto' } })
|
||||
|
||||
fireEvent.click(await within(dialog).findByRole('option', { name: /Beto Lima/ }))
|
||||
|
||||
await waitFor(() => expect(within(dialog).getByText('Beto Lima (PT)')).toBeTruthy())
|
||||
await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Dobradinhas salvas.'))
|
||||
})
|
||||
|
||||
it('shows the empty dash when there are no items yet', () => {
|
||||
render(cell({ items: [] }))
|
||||
expect(screen.getByText('—')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('undoes a failed removal without reverting a sibling change from the same render', async () => {
|
||||
const membershipAction = vi.fn(
|
||||
async (
|
||||
_state: CampaignFormActionState,
|
||||
_formData: FormData,
|
||||
): Promise<CampaignFormActionState> => ({
|
||||
message: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
|
||||
}),
|
||||
)
|
||||
render(cell({ membershipAction }))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remover Ana Bastos' }))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Ana Bastos')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
33
tests/unit/municipalityStateDeputyMembership.unit.spec.ts
Normal file
33
tests/unit/municipalityStateDeputyMembership.unit.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import { MAX_STATE_DEPUTIES_PER_MUNICIPALITY } from '@/lib/schemas/municipality'
|
||||
|
||||
describe('nextStateDeputyIdsAfterMunicipalityMembership', () => {
|
||||
it('adds a state deputy not yet present', () => {
|
||||
expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 3, true)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('removes a state deputy present in the list', () => {
|
||||
expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2, 3], 2, false)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('is a no-op (returns null) when already in the desired state', () => {
|
||||
expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 2, true)).toBeNull()
|
||||
expect(nextStateDeputyIdsAfterMunicipalityMembership([1, 2], 3, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('can go to zero', () => {
|
||||
expect(nextStateDeputyIdsAfterMunicipalityMembership([1], 1, false)).toEqual([])
|
||||
})
|
||||
|
||||
it('throws once the municipality is at the state-deputy cap', () => {
|
||||
const atCap = Array.from(
|
||||
{ length: MAX_STATE_DEPUTIES_PER_MUNICIPALITY },
|
||||
(_, index) => index + 1,
|
||||
)
|
||||
expect(() => nextStateDeputyIdsAfterMunicipalityMembership(atCap, 999, true)).toThrow(
|
||||
`Cada município aceita no máximo ${MAX_STATE_DEPUTIES_PER_MUNICIPALITY} dobradinhas.`,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue