E14: engagement levels N0-N4 and the first allocationDecision writer.
`municipality.engagementLevel` (staff-only, no default) records what the campaign decided to invest in a município, next to the E8/E10 metrics that describe what it knows. Moving it is a reallocation decision, so only unrestricted staff may, every move carries a rationale plus the signals that would reverse it, and the write files an `allocationDecision` in the same transaction under an advisory lock taken before the current level is read — otherwise the snapshot's `from` is a guess. The enum gains `movimento`: a level move is neither `aceita` nor `descarta`. No backfill. Null is "Sem nível", and filtering by it is the triage queue. That is also why the list sorts `nivel` in memory rather than in the query: Postgres puts NULLs first on DESC, so a native sort would open "N4 primeiro" on a page of municípios nobody has decided about yet. The filter stays native. The hysteresis rules from research §6.8 are pure and client-safe, so the Popover explains a block before submitting and the server re-runs them anyway; a blocked move answers 409 with typed violations and the override is recorded with exactly the rules it contradicted.
This commit is contained in:
parent
b839b924fb
commit
c7001b75f0
37 changed files with 13782 additions and 40 deletions
File diff suppressed because one or more lines are too long
|
|
@ -72,6 +72,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-27):** **E13** Planejador de presença/giros — visit eligibility as **five conditions ✓/—** (volume, headroom, rede, janela, encaixe) plus three advisory contraindications, each carrying a counter-offer and **never** blocking; the calendar phase (`construcao|consolidacao|ativacao`) changes the suggested "produto da visita". Pure math in `src/utilities/visitEligibility.ts`, cut points in `src/lib/visitPlannerAnchors.ts` (illustrative, on the `territorialClassAnchors.ts` precedent; the phase turns at midnight in Bahia via the new `formatBahiaCivilDate`), candidates grouped by identity territory in `src/utilities/visitPlannerData.ts` (`server-only`). Surfaces: an "Elegibilidade para visita" card on the municipality `OverviewTab`, and a composer at **`/campanha/atividades/giros`** (staff-only, reached from a "Planejar giro" button — no new sidebar entry) that turns TI → âncora/satélites/semente into draft `activity` rows in one transaction. **Three plan premises died in the pre-implementation audit and should not come back:** `kind: 'visita'` never shipped with C12 and is **not** wanted — `activity.deputyPresent` already IS the candidate-presence marker, so a giro is derivable from `deputyPresent` + território + date window and each stop keeps its real kind; `allocationDecision` still has no application write path, so the "não vá" is advisory text and recording an override is **E11**'s first job; and "satélites contíguos" was never possible — `bahiaTerritories.ts` carries membership only (polygon adjacency exists solely inside the build scripts), so **encaixe = same TI**, said in those words in the UI. Two implementation facts worth keeping: the uncaptured field ceiling and the catalog medians moved out of E10's classifier into `municipalityPotential.ts` (memoized), so the card and the "Classe" column cannot compute the same number twice; and `createTourDraftActivitiesRecord` re-reads every stop's município id with `overrideAccess: false` **inside** the transaction and aborts if one is missing, because `activity` create access is a plain staff boolean and Payload cannot express a per-município constraint on create — without it an advisor could seed drafts statewide by posting ids the composer never offered. Caps: 3 satellites, 8 stops. No migration, no collection, no `Consent`, no persisted `tour` entity. Plano: [`docs/plans/planejador-de-giros.md`](docs/plans/planejador-de-giros.md).
|
||||
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
|
||||
**Recently resolved (2026-07-27):** **E14** Níveis de envolvimento N0–N4 — `municipality.engagementLevel` (staff-only select `n0…n4`) records **what the campaign decided to invest** in a município, next to the E8/E10 metrics that describe what it knows. It is also the **first production writer of `allocationDecision`** (C12 shipped 2026-07-24 with access and tests but no caller). Migration `20260727_161752_add_municipality_engagement_level` adds `engagementLevel` (indexed, **no default**), `levelNote` (textarea 2000) and `levelChangedAt` (indexed date, `readOnly`, stamped by a `beforeChange` hook only when the level actually changes — and cleared when the level is), plus the value `movimento` on the `allocationDecision.outcome` enum, because a level move is neither `aceita` nor `descarta` and pretending otherwise would poison E11's triage. **No backfill:** the field is born null on all 435 municípios — stamping N2 would assert a decision nobody made — so null renders "Sem nível" and filtering by it **is** the triage queue. Moving a município is a reallocation decision, so writes are `isCampaignUnrestricted` only (`canManageMunicipalityEngagementLevel` in `utilities/access/municipalities.ts`); the advisor reads it and proposes through a signal. `setMunicipalityEngagementLevelRecord` takes the advisory lock `municipality-engagement-level:{id}` **before** reading the current level (otherwise the snapshot's `from` is a guess under concurrency) and writes both documents in one transaction, with `snapshot = { from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — the values the decision was made on, never the document. The hysteresis rules from research §6.8 live in the pure, client-safe `src/lib/engagementLevel.ts` (two-level jump needs a triangulated shock; three-week protection window after a promotion, against demotion only; one move per calendar month, derivable from `levelChangedAt` alone — no extra query), so the Popover shows the reason before submitting and the server re-runs them anyway. **The route has three states, not two:** `blocked` (409) returns the typed violations instead of collapsing into "could not save", because a blocked move is a decision the coordination may still take — the Popover keeps the draft and offers the override, which is recorded with exactly the rules it contradicted. **Explicit submit, not auto-save** — the exception `campanha-edit-where-you-see` already carves out for flows needing a note/confirmation, which also keeps this control out of the debounce machine **B32+** will audit. On the list: an 11th staff column with a numbered outline badge, plus filter `?level=`, which — unlike E10's derived `classe` — is **native**, because the field is stored and reaches the `where`; "sem nível" cannot ride inside an `in` and becomes an `or` branch with `{ exists: false }`. **The sort is not native**, though `n0…n4` does order lexicographically: Postgres puts NULLs FIRST on DESC, so with no backfill "N4 primeiro" would open on a page of "Sem nível". `nivel` joins `applyDerivedMunicipalitySort` and reuses `sortByNullableValue`, which sinks the absent level in both directions — the same treatment `sem_base` gets in `classe`. New E18 concept `nivel-de-envolvimento`. **Worth knowing:** with `overrideAccess: false` Payload **silently drops** a field the actor cannot write instead of rejecting the update, so the advisor int test asserts the level did **not** change rather than expecting a throw. Plano: [`docs/plans/niveis-de-envolvimento.md`](docs/plans/niveis-de-envolvimento.md).
|
||||
|
||||
**Recently resolved (2026-07-27):** **Build resiliente ao global `metadata` ausente** — `resolveSiteMetadata` in `src/utilities/seo.ts` (chain `global.URL` → `NEXT_PUBLIC_SITE_URL` → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (`layout`, `/abaixo-assinado/[id]`, `/[type]` tree) go through it; convention guard in `codebaseConventions.unit.spec.ts`. Plano: [`docs/plans/build-resiliente-metadata-ausente.md`](docs/plans/build-resiliente-metadata-ausente.md).
|
||||
|
||||
**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md).
|
||||
|
|
@ -159,6 +161,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
|
|||
|
||||
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
|
||||
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
|
||||
- **Engagement level (E14):** `municipality.engagementLevel` (`n0…n4`, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. Only `isCampaignUnrestricted` actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an `allocationDecision` (`outcome: 'movimento'`) in the same transaction under an advisory lock. Hysteresis rules are pure (`src/lib/engagementLevel.ts`) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill.
|
||||
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
|
||||
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
|
||||
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
# E14 — Níveis de envolvimento N0–N4 por município
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
|
||||
Status: entregue (2026-07-27)
|
||||
Atualizado em: 2026-07-27 (entregue: migration `20260727_161752_add_municipality_engagement_level`, action transacional com lock gravando `municipality` + `allocationDecision`, rota JSON com terceiro estado `blocked`, Popover de submit explícito, coluna/sort/filtro na lista, verbete E18 — as-built no fim do arquivo)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E14; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
|
||||
Impeccable: B — campo novo no detalhe/lista do município (Popover B9) + badge staff; sem rota nova
|
||||
Appetite: ~1 dia eng; migration pequena (campos em `municipality`)
|
||||
Impeccable: B — campo novo no detalhe/lista do município (Popover) + badge staff; rota nova só de mutação JSON (`POST /campanha/municipios/engagement-level`)
|
||||
Appetite: ~1,25 dia eng; migration pequena (3 campos em `municipality` + um valor no enum de `allocationDecision.outcome`)
|
||||
Responsável: —
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
- **Vou apresentar dados?** Sim — uma classificação decidida por humano (não derivada), em **badge categórico** de 5 estados + "sem nível", na célula da lista e no card de estratégia do detalhe.
|
||||
- **Que decisão ela serve?** Onde a campanha coloca presença, rede e agenda nas próximas semanas — e o registro de por que mudou.
|
||||
- **Forma escolhida:** pill **outline numerada** ("N3"), rótulo longo e motivo no `cellTooltip` (seam do B23 ✓). A linha já carrega prioridade (vermelho), classe (5 variantes) e tendência (3): o nível não pode competir por cor.
|
||||
- **Anti-goals de dado:** escala de cor de "importância" (vira ranking público — anti-goal 11); qualquer visibilidade para `leader`; gráfico ou série do nível.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 3 e 5) / `DESIGN.md` (register `product`) · controles B9 (`MunicipalityList*Control`).
|
||||
|
|
@ -25,24 +32,22 @@ Relatório §6.8: a escala N0 Monitorar → N1 Presença de mandato → N2 Rede
|
|||
|
||||
## Objetivos
|
||||
|
||||
- `municipality.engagementLevel` (`n0…n4`, default `n2`), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only (access nega `leader` na leitura, mesmo mecanismo de `supportStatus`).
|
||||
- `municipality.engagementLevel` (`n0…n4`, **sem default** — ver decisões), `levelNote` (motivo corrente), `levelChangedAt` (derivado) — todos staff-only na leitura (`canReadCampaignStaffField`, o mesmo mecanismo de `priority`/`politicalTrend`) e **unrestricted** na escrita.
|
||||
- Mudança de nível SEMPRE grava `allocationDecision` (patternId `nivel`, snapshot com nível anterior/novo, motivo, sinais de reversão) — histórico auditável sem versionar `municipality`.
|
||||
- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado).
|
||||
- `priority` legada: mapeada (`alta`→N3 sugerido) e aposentada da UI; campo permanece no schema até limpeza futura (sem migração destrutiva agora).
|
||||
- Consumidores: badge staff no detalhe/lista; E9 usa nível na ordenação (N0/N1 fora do topo do déficit); metas E8 leem nível para "meta mínima".
|
||||
- Validações de movimento no server action (pulo de 2 níveis exige flag "choque triangulado" com nota; aviso de janela de proteção) — avisos bloqueiam por default com override explícito do coordenador (registrado no snapshot).
|
||||
- Ordenação e filtro por nível na lista de municípios (chave `nivel`, param repetível) — o campo é armazenado, então o filtro é `where` do Payload; o sort roda em memória por causa dos nulos (ver as-built).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Nível é staff-only fail-closed** (field access como `supportStatus`; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa).
|
||||
- **Nível é staff-only fail-closed** (field access; nunca em view models de liderança). Vocabulário duplo é requisito de produto. **Rejeitado:** badge público "prioridade" (vaza o mapa de defesa).
|
||||
- **Histórico via `allocationDecision`, não versions em `municipality`** (um mecanismo só para decisões — C12). **Rejeitado:** versions em municipality (custo alto, ruído); array `levelHistory` no doc (cresce sem access próprio).
|
||||
- **`outcome` ganha um terceiro valor (`movimento`)** em vez de o movimento de nível se disfarçar de `aceita`. O enum nasceu binário (`aceita|descarta`) para a triagem de sugestões do E11, com `alternativeReading` obrigatória no descarte; um movimento de nível não é nenhum dos dois. **Rejeitado:** gravar sempre `aceita` (o campo viraria ruído semântico no primeiro consumidor da collection); usar `municipalityUpdate` kind `sinal` (misturaria decisão de alocação com sinal de campo e contaminaria `lastUpdateAt`/frescor).
|
||||
- **Só `coordinator`/`candidate` movem o nível** (é decisão de realocação — rito §6.8); o assessor propõe por sinal. O access de `allocationDecision` já permite assessor no escopo dele, então a restrição vive na field access do campo + no `reloadUnrestrictedActor` da action. **Rejeitado:** assessor com aprovação (workflow multi-etapa é rabbit hole declarado abaixo).
|
||||
- **Sem backfill: o campo nasce nulo, não `n2`.** Carimbar N2 em 435 municípios afirmaria uma decisão que ninguém tomou, e o rito §6.8 é registro ex-ante com motivo. "Sem nível" é um estado honesto, e filtrar por ele **é** a fila de triagem inicial. **Rejeitado:** default N2 no schema (o plano original) — reintroduz o binário que o item existe para substituir, agora com 435 falsos positivos. _(assumido — validar com produto)_
|
||||
- **Submit explícito, não auto-save.** É a exceção que a regra `campanha-edit-where-you-see` já prevê ("flows que precisam de nota/confirmação"): o movimento exige motivo E sinais de reversão, e pode precisar de override. Efeito colateral bom: o controle não vira o 4º call site da máquina de debounce que o **B32+** ainda vai extrair. **Rejeitado:** auto-save por debounce como B24 ✓/B27 ✓/B32 ✓.
|
||||
- **Rebaixar meta ≠ rebaixar município** (K-C): nível e meta são eixos independentes; a UI não acopla os dois automaticamente. **Rejeitado:** auto-derivar nível da cobertura (vira gaming imediato — G4).
|
||||
- **i18n e naming:** `engagementLevel` (`n0|n1|n2|n3|n4`), `levelNote`, `levelChangedAt`; labels pt-BR ("N1 · Presença de mandato" etc.).
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Default N2 para todas ou derivar sugestão inicial dos dados (E8/E10)?** Opções: N2 fixo | sugestão computada com aceite em lote. **Recomendação:** N2 fixo + tela de revisão em lote ordenada por sugestão — decisão continua humana, custo de setup ~1h de reunião. _(validar com produto)_
|
||||
- **Quem pode mudar nível?** Opções: só `coordinator` | `advisor` nos seus municípios com aprovação. **Recomendação:** só `coordinator` (é decisão de realocação — rito §6.8); assessor propõe via nota/sinal.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
|
|
@ -51,41 +56,63 @@ flowchart LR
|
|||
Action["setMunicipalityEngagementLevel<br/>(valida movimento)"]
|
||||
Municipality["municipality.engagementLevel"]
|
||||
Dec["allocationDecision<br/>(motivo + reversão + snapshot)"]
|
||||
Consumers["E9 fila · E8 metas · E11 motor"]
|
||||
ListUrl["Lista: sort e filtro por nível"]
|
||||
UI --> Action --> Municipality
|
||||
Action --> Dec
|
||||
Municipality --> Consumers
|
||||
Municipality --> ListUrl
|
||||
```
|
||||
|
||||
Componentes:
|
||||
Componentes (caminhos conferidos contra o repo em 2026-07-27):
|
||||
|
||||
- **`src/collections/Municipality.ts`**: 3 campos novos com access staff (padrão `politicalTrend`); `priority` sai da UI.
|
||||
- **`src/utilities/municipalityStaffFormActions.ts`** (ou action irmã): `setMunicipalityEngagementLevel` transacional (municipality + allocationDecision com `req`), validações de histerese puras em `src/lib/engagementLevel.ts` (unit-testável).
|
||||
- **UI:** `MunicipalityListLevelControl` no padrão dos `MunicipalityList*Control` (B9); badge no header do detalhe.
|
||||
- **Migration:** `pnpm migrate:create add_municipality_engagement_level`.
|
||||
- **`src/collections/Municipality.ts`**: 3 campos novos; leitura `canReadCampaignStaffField`, escrita `canManageMunicipalityEngagementLevel` (novo, em `src/utilities/access/municipalities.ts`, no molde de `canAssignMunicipalityAdvisors`); hook `beforeChange` carimbando `levelChangedAt`, espelhando `derivePoliticalTrendAudit`.
|
||||
- **`src/collections/AllocationDecision.ts`**: terceiro valor em `outcome`.
|
||||
- **`src/app/(campaign)/campanha/actions/municipality.ts`**: `setMunicipalityEngagementLevel[Record]` transacional (municipality + allocationDecision com `req`), com lock `municipality-engagement-level:{id}` via `acquireTextAdvisoryLocks` (`src/utilities/postgresTransactionLocks.ts`) — o "nível anterior" do snapshot precisa ser verdade sob concorrência.
|
||||
- **`src/lib/engagementLevel.ts`** (novo, puro/client-safe): rótulos, peso ordinal e as violações de histerese (unit-testável).
|
||||
- **Rota:** `POST /campanha/municipios/engagement-level` (`route.ts` + `types.ts`), espelhando `municipios/political-trend/`.
|
||||
- **UI:** `src/components/campaign/municipality/MunicipalityListLevelControl.tsx` (Popover com submit explícito); badge no bloco staff de `MunicipalityStrategyCard.tsx`, ao lado de prioridade/tendência.
|
||||
- **URL:** chave de sort `nivel` e filtro multi estático em `municipalityListUrl.ts` / `municipalityListFilters.ts`; coluna descrita em `municipalityLabels.ts`; verbete no `campaignIntelligenceConcepts.ts` (E18).
|
||||
- **Migration:** `pnpm migrate:create add_municipality_engagement_level`. `ALTER TYPE … ADD VALUE` roda dentro da transação da migration (PG 12+) desde que o valor novo **não seja usado na mesma migration** — não backfillar aqui.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Duras: **C12** (`allocationDecision` existir). Suaves: E8 (metas por nível), E9 (ordenação), E11 (nível no snapshot dos gatilhos).
|
||||
- Reusa: `municipalityStaffFormActions.ts`, `campaignAccess.ts`, padrão de field access de `supportStatus` (`Leadership.ts`).
|
||||
- Duras: **C12 ✓** (`allocationDecision` existe; o E14 é seu primeiro escritor de produção). Suaves: E11 (nível no snapshot dos gatilhos), B23 ✓ (`cellTooltip`), B41 ✓ (scroll horizontal, que é o que faz caber a 12ª coluna).
|
||||
- Reusa: `actions/municipality.ts`, `municipalityStaffEditMessages.ts`, `campaignJsonMutationRoute.ts` + `isSameOriginRequest`, `campaignAccess.ts`, `withPayloadTransaction`, `acquireTextAdvisoryLocks`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **Aposentar `priority` da UI** — o plano original previa isso; a auditoria de 2026-07-27 mostrou 18 arquivos em `src` envolvidos (atalho do Início `dashboardPriorityMunicipalities.ts`, link `?priority=alta&coverage=sem_assessor` do E9, filtro de header, card/form de estratégia, dossiê) **e o seed da planilha `projectionSheetParse.ts`, que escreve o campo**. Vira fill-in próprio; `priority` fica intocado aqui.
|
||||
- **Consumidores a jusante:** nível na ordenação do E9 e "meta mínima" N0/N1 no E8 — mudam semântica travada de `goalCoverage` (`meta = expectedVotes[cenário] ?? suggestedGoal`); item separado.
|
||||
- Sugerir promoções/rebaixamentos automaticamente (E11, padrões K-A/P5); relatório de movimentos (E15); redeployment de brokers (processo humano — relatório K3).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Workflow de aprovação multi-etapa.** Vira mini-Jira; o rito é reunião quinzenal + registro, não máquina de estados. **Mitigação:** action única com validações; nada de status "pendente de aprovação".
|
||||
- **Migrar `priority` destrutivamente agora.** Coluna fica; remoção em migração futura de limpeza pós-estabilização.
|
||||
- **Calibrar as regras de histerese.** Os cortes (salto de 2 níveis, 3 semanas de proteção, 1 movimento por mês) são os do relatório §6.8 e são **ilustrativos** — calibração é **E15**, num objeto só.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Notificação de mudança de nível ao assessor do município.** Gatilho: D2 (sino) entregue.
|
||||
|
||||
## As-built (2026-07-27)
|
||||
|
||||
O que ficou diferente do plano, e o que só se soube implementando:
|
||||
|
||||
- **Migration `20260727_161752_add_municipality_engagement_level`** — `engagementLevel` (select indexado, sem default), `levelNote` (textarea 2000) e `levelChangedAt` (date indexada, `readOnly`, escrita por `canSetCampaignSystemField`), mais o valor `movimento` no enum de `allocationDecision.outcome`. O hook `deriveEngagementLevelAudit` carimba `levelChangedAt` só quando o nível muda **e o zera quando o nível é apagado**: uma data de "registrado em" sem nível não descreve nada, e as regras de histerese a leriam como histórico.
|
||||
- **O `from` do snapshot é verdade sob concorrência** porque a action segura `municipality-engagement-level:{id}` antes de ler o nível atual. Os dois escritos (update do município + `allocationDecision`) vivem na mesma transação, e o snapshot guarda `{ from, to, reversalSignals, triangulatedShock, violations, overridden, previousLevelChangedAt }` — só os valores sobre os quais a decisão foi tomada, nunca o documento.
|
||||
- **A rota tem três estados, não dois.** `blocked` (409) devolve as violações tipadas em vez de virar "não foi possível salvar": um movimento barrado pela histerese não é uma falha para repetir, é uma decisão que a coordenação pode tomar mesmo assim — então o Popover mantém o rascunho e oferece o override no lugar. As mesmas regras rodam no cliente (o módulo é puro) para o coordenador ver o motivo antes de submeter; o servidor as roda de novo de qualquer jeito.
|
||||
- **O filtro é armazenado, então entra no `where`** — ao contrário de `classe` (E10), que é derivada e força o caminho `limit: 0`. "Sem nível" não cabe num `in`, então vira um ramo `or` com `{ engagementLevel: { exists: false } }`; selecionar N0 + sem nível (a fila de triagem) continua sendo uma consulta só. **O sort, não.** `n0…n4` ordena lexicograficamente na ordem ordinal, então parecia nativo — mas o Postgres põe NULL **primeiro** no DESC, e sem backfill "N4 primeiro" entregaria uma página inteira de "Sem nível". Ausência de decisão não é o topo da escada: `nivel` entrou em `applyDerivedMunicipalitySort` com `sortByNullableValue`, que joga nulo para o fim nas duas direções (mesma semântica de `sem_base` em `classe`). Custo: sortear por nível carrega o escopo inteiro — o mesmo que o sort padrão (`deficit`) já faz. Int test pina as duas direções.
|
||||
- **Descoberto no int test:** com `overrideAccess: false`, o Payload **não** rejeita o update inteiro quando o ator não pode escrever o campo — ele descarta o campo em silêncio. O teste do assessor, então, prova que o nível **não mudou**, não que a chamada lançou.
|
||||
- **Banco de desenvolvimento local estava fora do histórico de migrations:** `allocation_decision.outcome` era `varchar` e o tipo `enum_allocation_decision_outcome` não existia (o `teqo_test`, construído só por migrations, tinha ambos). Reparado à mão no `teqo` (criar o tipo + cast da coluna) antes de aplicar esta migration; nada a fazer em produção, que segue o histórico.
|
||||
- **Custo:** `/campanha/municipios` fica em 17,7 kB / 322 kB de First Load JS. Suítes: 64 unit (604 testes) e 53 int (427). Nada novo no bundle do cliente: a revisão de performance conferiu contra o build de produção que todo módulo que a ilha importa já estava nos chunks da rota.
|
||||
- **O que a revisão pós-entrega corrigiu:** (1) um `blocked` vindo do **servidor** — o caso que só existe sob concorrência, quando outra aba moveu o município — virava string no box de erro, sem o checkbox de override ao lado; agora as violações tipadas voltam ao mesmo lugar que as locais, deduplicadas por `id` e presas ao nível para o qual foram levantadas, que é a única forma de o terceiro estado da rota valer para quem mais precisa dele. (2) `formatEngagementLevelLabel` / `EMPTY_ENGAGEMENT_LEVEL_LABEL` mudaram de `municipalityLabels` para `lib/engagementLevel.ts`: importar um valor daquele módulo arrasta o catálogo de 435 entradas e o glossário de conceitos para dentro da ilha (o padrão de 21 kB do B14), hoje inócuo só porque o controle de tendência já mantém a cadeia viva. (3) `isEngagementLevel` saiu de três chamadas onde o tipo gerado pelo Payload já era a união fechada.
|
||||
- **Débito conhecido (não é do E14 sozinho):** `levelNote` entrou em `municipalityListSelect`, e o sort padrão do staff (`deficit`) roda esse select sobre os 435 municípios do escopo para renderizar 25 linhas — no teto de 2000 caracteres são ~870 KB lidos por request. Hoje custa zero (o campo nasce nulo em todos), e `politicalTrend.note` tem exatamente a mesma forma e já estava lá: o conserto é dos dois juntos (ler as notas só para a fatia da página), não de um.
|
||||
- **Fora do escopo, confirmado na entrega:** `priority` continua intocado (18 arquivos + o seed `projectionSheetParse.ts` que escreve o campo), e nem o E8 nem o E9 leem o nível.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Inteligência de campanha, E14) · [plano-mestre](inteligencia-campanha.md) (G7)
|
||||
- `docs/research/relatorio-entrevista-persona-campanha.md` §6.8 (escala, movimento, rito, custo político), K1–K4
|
||||
- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/Leadership.ts` (padrão supportStatus)
|
||||
- `src/utilities/municipalityStaffFormActions.ts`, `src/components/campaign/MunicipalityListTrendControl.tsx` (padrão de controle B9)
|
||||
- `src/collections/Municipality.ts` (priority/politicalTrend/access), `src/collections/AllocationDecision.ts` (C12)
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions.ts`, `src/components/campaign/municipality/MunicipalityListTrendControl.tsx` (padrão de controle na célula)
|
||||
- AGENTS.md — field access fail-closed, transações, migrations
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,68 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { setMunicipalityEngagementLevel } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { EngagementLevelBlockedError } from '@/lib/engagementLevel'
|
||||
import {
|
||||
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
|
||||
municipalityEngagementLevelSchema,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
import {
|
||||
campaignJsonMutationErrorResponse,
|
||||
parseCampaignJsonRequestBody,
|
||||
} from '@/utilities/campaignJsonMutationRoute'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
import type { MunicipalityListEngagementLevelResponse } from './types'
|
||||
|
||||
export type { MunicipalityListEngagementLevelResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** JSON body uses `municipalityId`; the action schema uses `municipality`. */
|
||||
const bodySchema = municipalityEngagementLevelSchema
|
||||
.omit({ municipality: true })
|
||||
.extend({ municipalityId: positiveRelationshipId })
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
): Promise<NextResponse<MunicipalityListEngagementLevelResponse>> {
|
||||
if (!isSameOriginRequest(request)) {
|
||||
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseCampaignJsonRequestBody(request)
|
||||
if (!parsed.ok) return parsed.response
|
||||
|
||||
try {
|
||||
const { municipalityId, ...movement } = bodySchema.parse(parsed.body)
|
||||
const updated = await setMunicipalityEngagementLevel({
|
||||
municipality: municipalityId,
|
||||
...movement,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'success',
|
||||
message: 'Nível de envolvimento registrado.',
|
||||
savedLevel: {
|
||||
level: movement.level,
|
||||
note: updated.levelNote ?? null,
|
||||
changedAt: updated.levelChangedAt ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof EngagementLevelBlockedError) {
|
||||
return NextResponse.json(
|
||||
{ status: 'blocked', message: error.message, violations: error.violations },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
return campaignJsonMutationErrorResponse(error, {
|
||||
// Only the unrestricted-actor message: this action reloads the actor with
|
||||
// its own guard, never through `getFreshStaffActor` (same as `advisors/`).
|
||||
safeMessages: [MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE],
|
||||
genericMessage: 'Não foi possível registrar o nível. Verifique seu acesso e tente novamente.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { EngagementLevel, EngagementLevelViolation } from '@/lib/engagementLevel'
|
||||
|
||||
export type MunicipalityListSavedEngagementLevel = {
|
||||
level: EngagementLevel
|
||||
note: string | null
|
||||
changedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `blocked` is a third state on purpose: a movement held by the stability
|
||||
* rules is not a failure the coordinator should retry, it is a decision they
|
||||
* can take anyway — so the reasons travel back and the control offers the
|
||||
* override in place.
|
||||
*/
|
||||
export type MunicipalityListEngagementLevelResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
savedLevel: MunicipalityListSavedEngagementLevel
|
||||
}
|
||||
| {
|
||||
status: 'blocked'
|
||||
message: string
|
||||
violations: EngagementLevelViolation[]
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
isCampaignCoordinator,
|
||||
isCampaignLeader,
|
||||
isCampaignStaff,
|
||||
isCampaignUnrestricted,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getCampaignScopeLabel } from '@/utilities/municipalityLabels'
|
||||
|
|
@ -48,6 +49,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
if (isCampaignLeader(user)) redirect('/campanha')
|
||||
const isStaffView = isCampaignStaff(user)
|
||||
const isCoordinator = isCampaignCoordinator(user)
|
||||
const canMoveEngagementLevel = isCampaignUnrestricted(user)
|
||||
|
||||
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
|
||||
const {
|
||||
|
|
@ -122,6 +124,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
advisorNamesById={advisorNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
canMoveEngagementLevel={canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
columnFilterOptions={columnFilterOptions}
|
||||
signalFormAction={createMunicipalityListSignalFormAction}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
ENGAGEMENT_LEVEL_PATTERN_ID,
|
||||
EngagementLevelBlockedError,
|
||||
getEngagementLevelViolations,
|
||||
} from '@/lib/engagementLevel'
|
||||
import { nextAdvisorIdsAfterMembership } from '@/lib/municipalityAdvisorMembership'
|
||||
import {
|
||||
MUNICIPALITY_ADVISOR_MEMBERSHIP_UNRESTRICTED_MESSAGE,
|
||||
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
|
||||
municipalityAdvisorMembershipSchema,
|
||||
municipalityAdvisorsAssignmentSchema,
|
||||
municipalityEngagementLevelSchema,
|
||||
municipalityExpectedVotesSchema,
|
||||
municipalityPoliticalTrendSchema,
|
||||
municipalityStrategyUpdateSchema,
|
||||
type MunicipalityAdvisorMembershipInput,
|
||||
type MunicipalityAdvisorsAssignmentInput,
|
||||
type MunicipalityEngagementLevelInput,
|
||||
type MunicipalityExpectedVotesInput,
|
||||
type MunicipalityPoliticalTrendInput,
|
||||
type MunicipalityStrategyUpdateInput,
|
||||
|
|
@ -126,6 +134,105 @@ export const setMunicipalityExpectedVotes = async (input: MunicipalityExpectedVo
|
|||
return setMunicipalityExpectedVotesRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* E14 — moving a município up or down the N0–N4 ladder. Two writes in one
|
||||
* transaction: the município carries the current level, and `allocationDecision`
|
||||
* carries the movement forever (C12's first production writer), so the history
|
||||
* survives the next movement instead of being overwritten by it.
|
||||
*
|
||||
* The lock is what makes the recorded "nível anterior" true: without it two
|
||||
* coordinators moving the same município would each read the same `from` and
|
||||
* write two decisions claiming to start from it.
|
||||
*/
|
||||
export const setMunicipalityEngagementLevelRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: MunicipalityEngagementLevelInput,
|
||||
) => {
|
||||
const { municipality, level, note, reversalSignals, triangulatedShock, override } =
|
||||
municipalityEngagementLevelSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadUnrestrictedActor(
|
||||
payload,
|
||||
actor,
|
||||
MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE,
|
||||
req,
|
||||
)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [
|
||||
`municipality-engagement-level:${municipality}`,
|
||||
])
|
||||
|
||||
const current = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipality,
|
||||
depth: 0,
|
||||
select: { engagementLevel: true, levelChangedAt: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
const from = current.engagementLevel ?? null
|
||||
const violations = getEngagementLevelViolations({
|
||||
from,
|
||||
to: level,
|
||||
levelChangedAt: current.levelChangedAt ?? null,
|
||||
now: new Date(),
|
||||
triangulatedShock,
|
||||
})
|
||||
|
||||
if (violations.length > 0 && !override) throw new EngagementLevelBlockedError(violations)
|
||||
|
||||
const updated = await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality,
|
||||
data: { engagementLevel: level, levelNote: note },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'allocationDecision',
|
||||
data: {
|
||||
municipality,
|
||||
patternId: ENGAGEMENT_LEVEL_PATTERN_ID,
|
||||
outcome: 'movimento',
|
||||
rationale: note,
|
||||
// Only the values the decision was taken on — no document dumps
|
||||
// (the collection caps the serialized snapshot at 16 KB).
|
||||
snapshot: {
|
||||
from,
|
||||
to: level,
|
||||
reversalSignals,
|
||||
triangulatedShock,
|
||||
violations: violations.map((violation) => violation.id),
|
||||
overridden: violations.length > 0,
|
||||
previousLevelChangedAt: current.levelChangedAt ?? null,
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
})
|
||||
|
||||
return updated
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar o movimento de nível.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setMunicipalityEngagementLevel = async (input: MunicipalityEngagementLevelInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return setMunicipalityEngagementLevelRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/** Advisor assignment is unrestricted staff (coordinator + candidate); the hook validates eligibility. */
|
||||
export const assignMunicipalityAdvisorsRecord = async (
|
||||
payload: Payload,
|
||||
|
|
|
|||
|
|
@ -99,9 +99,13 @@ export const AllocationDecision: CollectionConfig = {
|
|||
type: 'select',
|
||||
label: 'Decisão',
|
||||
required: true,
|
||||
// `aceita`/`descarta` is the triage of a SUGGESTED pattern (E11). A level
|
||||
// movement (E14) is neither — it is a decision taken outright — so it
|
||||
// gets its own value instead of masquerading as an acceptance.
|
||||
options: [
|
||||
{ label: 'Aceita', value: 'aceita' },
|
||||
{ label: 'Descarta', value: 'descarta' },
|
||||
{ label: 'Movimento', value: 'movimento' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import type {
|
|||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import {
|
||||
ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
|
||||
engagementLevels,
|
||||
formatEngagementLevelLabel,
|
||||
} from '@/lib/engagementLevel'
|
||||
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
|
||||
import {
|
||||
getVoteEstimateOrderViolation,
|
||||
|
|
@ -17,6 +22,7 @@ import {
|
|||
canDeleteMunicipality,
|
||||
canManageCampaignStaffField,
|
||||
canManageMunicipalityAdvisors,
|
||||
canManageMunicipalityEngagementLevel,
|
||||
canReadCampaignStaffField,
|
||||
canReadMunicipality,
|
||||
canSetCampaignSystemField,
|
||||
|
|
@ -106,6 +112,22 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
|
|||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* E14 — `levelChangedAt` is the clock the movement rules read (protection
|
||||
* window, one movement per month), so it is derived here rather than trusted
|
||||
* from the caller: an admin editing the level in `/admin` has to start the
|
||||
* same clock the server action does.
|
||||
*/
|
||||
const deriveEngagementLevelAudit: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||
if (!data || data.engagementLevel === undefined) return data
|
||||
if (data.engagementLevel === (originalDoc?.engagementLevel ?? null)) return data
|
||||
|
||||
// Clearing the level clears its clock too: "registrado em" with no level is
|
||||
// a date that describes nothing, and the rules would read it as history.
|
||||
data.levelChangedAt = data.engagementLevel ? new Date().toISOString() : null
|
||||
return data
|
||||
}
|
||||
|
||||
const trendSnapshot = (value: unknown) => {
|
||||
const trend = (value ?? {}) as { status?: unknown; note?: unknown }
|
||||
return `${trend.status ?? ''}\u0000${typeof trend.note === 'string' ? trend.note.trim() : ''}`
|
||||
|
|
@ -150,7 +172,7 @@ export const Municipality: CollectionConfig = {
|
|||
},
|
||||
hooks: {
|
||||
beforeValidate: [validateMunicipalityAdvisors, validateExpectedVotes],
|
||||
beforeChange: [derivePoliticalTrendAudit],
|
||||
beforeChange: [derivePoliticalTrendAudit, deriveEngagementLevelAudit],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -302,6 +324,58 @@ export const Municipality: CollectionConfig = {
|
|||
{ label: 'Normal', value: 'normal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'engagementLevel',
|
||||
type: 'select',
|
||||
label: 'Nível de envolvimento',
|
||||
index: true,
|
||||
// No default on purpose: stamping N2 on all 435 municípios would assert a
|
||||
// decision nobody took. Absent means "ainda não decidido", and filtering
|
||||
// for it is the triage queue.
|
||||
access: {
|
||||
read: canReadCampaignStaffField,
|
||||
create: canManageMunicipalityEngagementLevel,
|
||||
update: canManageMunicipalityEngagementLevel,
|
||||
},
|
||||
admin: {
|
||||
description:
|
||||
'Quanto a campanha investe neste município (N0 a N4). Movimento é decisão da coordenação e fica registrado em Decisões de alocação.',
|
||||
},
|
||||
// Single source: the ladder in `lib/engagementLevel` + the shared label table.
|
||||
options: engagementLevels.map((value) => ({
|
||||
label: formatEngagementLevelLabel(value),
|
||||
value,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'levelNote',
|
||||
type: 'textarea',
|
||||
label: 'Motivo do nível',
|
||||
maxLength: ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
|
||||
access: {
|
||||
read: canReadCampaignStaffField,
|
||||
create: canManageMunicipalityEngagementLevel,
|
||||
update: canManageMunicipalityEngagementLevel,
|
||||
},
|
||||
admin: {
|
||||
description: 'Motivo corrente do nível — o histórico vive em Decisões de alocação.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'levelChangedAt',
|
||||
type: 'date',
|
||||
label: 'Nível registrado em',
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Derivado da última mudança de nível.',
|
||||
},
|
||||
access: {
|
||||
read: canReadCampaignStaffField,
|
||||
create: canSetCampaignSystemField,
|
||||
update: canSetCampaignSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'expectedVotes',
|
||||
type: 'group',
|
||||
|
|
|
|||
|
|
@ -190,6 +190,18 @@ export const MunicipalityFilters = ({
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
{showStaffFilters ? (
|
||||
<CampaignMobileMultiFilterField
|
||||
id="municipality-filter-level"
|
||||
label="Nível"
|
||||
emptyLabel="Todos"
|
||||
options={getMunicipalityFilterDefinition('level').options ?? []}
|
||||
selected={state.levels ?? []}
|
||||
onToggle={(value) =>
|
||||
navigateWithSearch(toggleMunicipalityMultiFilterValue(state, 'level', value))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showStaffFilters && advisorFilterOptions.length ? (
|
||||
<CampaignMobileMultiFilterField
|
||||
id="municipality-filter-advisor"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const multiParamByFilter = {
|
|||
advisor: 'advisor',
|
||||
trend: 'trend',
|
||||
class: 'class',
|
||||
level: 'level',
|
||||
kind: null,
|
||||
coverage: null,
|
||||
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import {
|
||||
EMPTY_ENGAGEMENT_LEVEL_LABEL,
|
||||
formatEngagementLevelLabel,
|
||||
type EngagementLevel,
|
||||
} from '@/lib/engagementLevel'
|
||||
|
||||
/**
|
||||
* Deliberately NOT a colour ramp. A five-step scale of importance painted
|
||||
* across a staff list is the "mapa de onde você não vai defender" the research
|
||||
* warns about (anti-goal 11), and this row already spends red on priority,
|
||||
* amber on cold signals and five fills on the E10 class. The ordinal
|
||||
* information is in the numeral; the pill is a hairline. Absence of a level is
|
||||
* not a sixth step, so it reads as flat muted text instead.
|
||||
*/
|
||||
const ENGAGEMENT_LEVEL_BADGE_VARIANT = 'outline' as const
|
||||
const EMPTY_ENGAGEMENT_LEVEL_BADGE_VARIANT = 'ghost' as const
|
||||
|
||||
type MunicipalityLevelBadgeProps = {
|
||||
level: EngagementLevel | null
|
||||
/** The rationale behind the current level, when the caller has it. */
|
||||
note?: string | null
|
||||
/**
|
||||
* In the table the numeral carries it and the long name lives in the
|
||||
* column's `cellTooltip`; elsewhere there is room to spell it out.
|
||||
*/
|
||||
layout: 'table' | 'card'
|
||||
}
|
||||
|
||||
/**
|
||||
* E14 — the read-only face of the ladder, for staff who cannot move it.
|
||||
*
|
||||
* The long name (and the motivo, when given) is always TEXT next to the pill —
|
||||
* `sr-only` in the table, visible in the card — because the B23 `cellTooltip`
|
||||
* contract is that the tooltip only repeats what the cell already says: Radix
|
||||
* mounts tooltip content on open, and the table adds no tab stops. Same shape
|
||||
* as `TerritorialClassReadout` in the neighbouring column.
|
||||
*/
|
||||
export const MunicipalityLevelBadge = ({ level, note, layout }: MunicipalityLevelBadgeProps) => {
|
||||
if (!level) {
|
||||
return (
|
||||
<Badge variant={EMPTY_ENGAGEMENT_LEVEL_BADGE_VARIANT} className="text-muted-foreground">
|
||||
{EMPTY_ENGAGEMENT_LEVEL_LABEL}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const label = formatEngagementLevelLabel(level)
|
||||
|
||||
// The card spells the name out in the pill itself, so only the motivo is
|
||||
// left to say; the table shows the numeral and owes the reader both.
|
||||
if (layout === 'card') {
|
||||
return (
|
||||
<>
|
||||
<Badge variant={ENGAGEMENT_LEVEL_BADGE_VARIANT}>{label}</Badge>
|
||||
{note ? <span className="text-xs text-muted-foreground">{note}</span> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge variant={ENGAGEMENT_LEVEL_BADGE_VARIANT}>{level.toUpperCase()}</Badge>
|
||||
<span className="sr-only">{[label, note].filter(Boolean).join(' — ')}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,9 +7,11 @@ import {
|
|||
MissingAdvisorBadge,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
formatPlacementOrdinal,
|
||||
formatVoteSharePercent,
|
||||
} from '@/lib/electionFormat'
|
||||
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
|
|
@ -85,6 +88,12 @@ export type MunicipalityListProps = {
|
|||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
/**
|
||||
* E14 — moving the ladder is unrestricted staff (coordinator + candidate),
|
||||
* a wider set than `isCoordinator`, so it travels as its own prop instead of
|
||||
* quietly hiding the control from the candidate.
|
||||
*/
|
||||
canMoveEngagementLevel: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
|
|
@ -239,6 +248,7 @@ const municipalityListColumns = ({
|
|||
state,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
canMoveEngagementLevel,
|
||||
columnFilterOptions,
|
||||
advisorNamesById,
|
||||
advisorOptions,
|
||||
|
|
@ -347,6 +357,44 @@ const municipalityListColumns = ({
|
|||
? null
|
||||
: formatTerritorialClassWhy(municipality.territorialClassFactors),
|
||||
},
|
||||
{
|
||||
// Right after "Classe": the pair reads diagnosis then decision.
|
||||
id: 'level',
|
||||
head: (
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="nivel"
|
||||
filterParam="level"
|
||||
description={municipalityColumnDescriptions.nivel}
|
||||
>
|
||||
Nível
|
||||
</MunicipalitySortableHead>
|
||||
),
|
||||
cell: (municipality) =>
|
||||
canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
/>
|
||||
),
|
||||
// The editable cell wraps its own trigger in a tooltip; declaring it
|
||||
// here too would double it up on the same gesture (as in `advisors`).
|
||||
cellTooltip: (municipality) =>
|
||||
canMoveEngagementLevel || !municipality.engagementLevel
|
||||
? null
|
||||
: [formatEngagementLevelLabel(municipality.engagementLevel), municipality.levelNote]
|
||||
.filter(Boolean)
|
||||
.join(' — '),
|
||||
},
|
||||
{
|
||||
id: 'advisors',
|
||||
head: (
|
||||
|
|
@ -595,6 +643,26 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-muted-foreground">Nível</dt>
|
||||
<dd>
|
||||
{props.canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
level={municipality.engagementLevel}
|
||||
levelNote={municipality.levelNote}
|
||||
levelChangedAt={municipality.levelChangedAt}
|
||||
/>
|
||||
) : (
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="card"
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Tendência</dt>
|
||||
<dd>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type {
|
||||
MunicipalityListEngagementLevelResponse,
|
||||
MunicipalityListSavedEngagementLevel,
|
||||
} from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
EMPTY_ENGAGEMENT_LEVEL_LABEL,
|
||||
ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH,
|
||||
engagementLevelRank,
|
||||
engagementLevels,
|
||||
formatEngagementLevelLabel,
|
||||
getEngagementLevelViolations,
|
||||
isEngagementLevel,
|
||||
type EngagementLevel,
|
||||
type EngagementLevelViolation,
|
||||
} from '@/lib/engagementLevel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível registrar o nível. Tente novamente.'
|
||||
|
||||
type MunicipalityListLevelControlProps = {
|
||||
municipalityID: number
|
||||
municipalityName: string
|
||||
level: EngagementLevel | null
|
||||
levelNote: string | null
|
||||
levelChangedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* E14 — unlike the trend and estimate popovers next to it, this one submits
|
||||
* explicitly: a movement carries a motivo AND the signals that would reverse
|
||||
* it, and it may need an override. Auto-saving half of that would file an
|
||||
* incomplete decision under the coordinator's name.
|
||||
*/
|
||||
export const MunicipalityListLevelControl = ({
|
||||
municipalityID,
|
||||
municipalityName,
|
||||
level,
|
||||
levelNote,
|
||||
levelChangedAt,
|
||||
}: MunicipalityListLevelControlProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saved, setSaved] = useState<MunicipalityListSavedEngagementLevel | null>(
|
||||
level ? { level, note: levelNote, changedAt: levelChangedAt } : null,
|
||||
)
|
||||
// Seeded by `resetDraft` on every open; while closed the popover body — and
|
||||
// with it the select — is not mounted.
|
||||
const [draftLevel, setDraftLevel] = useState<EngagementLevel | ''>('')
|
||||
const [note, setNote] = useState('')
|
||||
const [reversalSignals, setReversalSignals] = useState('')
|
||||
const [triangulatedShock, setTriangulatedShock] = useState(false)
|
||||
const [override, setOverride] = useState(false)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
// Violations only the server could know (it re-reads the município under the
|
||||
// lock), kept with the level they were raised for so a changed draft drops them.
|
||||
const [serverBlock, setServerBlock] = useState<{
|
||||
level: EngagementLevel
|
||||
violations: EngagementLevelViolation[]
|
||||
} | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastPropsRef = useRef({ level, levelNote, levelChangedAt })
|
||||
|
||||
// Adopt the server value when it changes from outside (navigation / refresh).
|
||||
useEffect(() => {
|
||||
const last = lastPropsRef.current
|
||||
if (
|
||||
last.level === level &&
|
||||
last.levelNote === levelNote &&
|
||||
last.levelChangedAt === levelChangedAt
|
||||
)
|
||||
return
|
||||
lastPropsRef.current = { level, levelNote, levelChangedAt }
|
||||
setSaved(level ? { level, note: levelNote, changedAt: levelChangedAt } : null)
|
||||
}, [level, levelNote, levelChangedAt])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
const currentLevel = saved?.level ?? null
|
||||
const currentNote = saved?.note ?? null
|
||||
const isMovement = draftLevel !== '' && draftLevel !== currentLevel
|
||||
|
||||
// The rules are pure and client-safe, so the coordinator sees why a movement
|
||||
// is held before submitting it — the server re-runs them either way, and what
|
||||
// it raises on top (a concurrent move this tab has not seen) joins the list
|
||||
// rather than turning into an error string with no override next to it.
|
||||
const localViolations = isMovement
|
||||
? getEngagementLevelViolations({
|
||||
from: currentLevel,
|
||||
to: draftLevel,
|
||||
levelChangedAt: saved?.changedAt ?? null,
|
||||
now: new Date(),
|
||||
triangulatedShock,
|
||||
})
|
||||
: []
|
||||
const violations =
|
||||
serverBlock && serverBlock.level === draftLevel
|
||||
? [
|
||||
...localViolations,
|
||||
...serverBlock.violations.filter(
|
||||
(violation) => !localViolations.some((local) => local.id === violation.id),
|
||||
),
|
||||
]
|
||||
: localViolations
|
||||
|
||||
const isJump =
|
||||
isMovement &&
|
||||
currentLevel !== null &&
|
||||
Math.abs(engagementLevelRank[draftLevel] - engagementLevelRank[currentLevel]) > 1
|
||||
|
||||
const resetDraft = () => {
|
||||
setDraftLevel(currentLevel ?? '')
|
||||
setNote('')
|
||||
setReversalSignals('')
|
||||
setTriangulatedShock(false)
|
||||
setOverride(false)
|
||||
setErrorMessage(null)
|
||||
setServerBlock(null)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) resetDraft()
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!isMovement || isPending) return
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
setIsPending(true)
|
||||
setErrorMessage(null)
|
||||
setServerBlock(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(ENGAGEMENT_LEVEL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
municipalityId: municipalityID,
|
||||
level: draftLevel,
|
||||
note,
|
||||
reversalSignals,
|
||||
triangulatedShock,
|
||||
override,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as MunicipalityListEngagementLevelResponse
|
||||
|
||||
if (payload.status === 'success') {
|
||||
setSaved(payload.savedLevel)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
// A blocked movement keeps the popover open with the draft intact: the
|
||||
// next click is "override", not "type it all again".
|
||||
if (payload.status === 'blocked') {
|
||||
setServerBlock({ level: draftLevel, violations: payload.violations })
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(payload.message)
|
||||
} catch {
|
||||
setErrorMessage(SAVE_ERROR_MESSAGE)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = isMovement && note.trim().length > 0 && reversalSignals.trim().length > 0
|
||||
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<CampaignHoverTooltip
|
||||
content={
|
||||
currentLevel ? (
|
||||
<div className="space-y-1">
|
||||
<p>{formatEngagementLevelLabel(currentLevel)}</p>
|
||||
{currentNote ? <p className="whitespace-pre-wrap">{currentNote}</p> : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
align="start"
|
||||
openOnTouch={false}
|
||||
disabled={open}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(
|
||||
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||||
open ? 'bg-muted/60' : undefined,
|
||||
)}
|
||||
aria-label={`Nível de envolvimento de ${municipalityName}: ${
|
||||
currentLevel ? formatEngagementLevelLabel(currentLevel) : EMPTY_ENGAGEMENT_LEVEL_LABEL
|
||||
}`}
|
||||
>
|
||||
<MunicipalityLevelBadge level={currentLevel} layout="table" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</CampaignHoverTooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-80 p-3"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId('select')}>Nível de envolvimento</FieldLabel>
|
||||
<NativeSelect
|
||||
id={fieldId('select')}
|
||||
value={draftLevel}
|
||||
onChange={(event) =>
|
||||
setDraftLevel(isEngagementLevel(event.target.value) ? event.target.value : '')
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
|
||||
{engagementLevels.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{formatEngagementLevelLabel(option)}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId('note')}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
className="min-h-16 resize-y"
|
||||
placeholder="O que mudou para justificar este nível?"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId('reversal')}
|
||||
value={reversalSignals}
|
||||
onChange={(event) => setReversalSignals(event.target.value)}
|
||||
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
className="min-h-16 resize-y"
|
||||
placeholder="Sinais que fariam rever esta decisão."
|
||||
/>
|
||||
</Field>
|
||||
{isJump ? (
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={fieldId('shock')}
|
||||
checked={triangulatedShock}
|
||||
onCheckedChange={(checked) => setTriangulatedShock(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldId('shock')}>
|
||||
Choque triangulado (dois níveis de uma vez)
|
||||
</FieldLabel>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
) : null}
|
||||
{violations.length > 0 ? (
|
||||
<>
|
||||
<Alert className="py-2">
|
||||
<AlertDescription className="text-xs">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
{violations.map((violation) => (
|
||||
<li key={violation.id}>{violation.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox
|
||||
id={fieldId('override')}
|
||||
checked={override}
|
||||
onCheckedChange={(checked) => setOverride(checked === true)}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldId('override')}>
|
||||
Registrar mesmo assim, ciente das ressalvas
|
||||
</FieldLabel>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canSubmit || isPending || (violations.length > 0 && !override)}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
|
||||
Registrar movimento
|
||||
</Button>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Registrando nível.' : (errorMessage ?? '')}
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { PencilIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -47,6 +48,7 @@ export const MunicipalityStrategyCard = ({
|
|||
) : (
|
||||
<Badge variant="outline">Tendência não registrada</Badge>
|
||||
)}
|
||||
<MunicipalityLevelBadge level={strategy.engagementLevel} layout="card" />
|
||||
</div>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
|
|
@ -59,6 +61,22 @@ export const MunicipalityStrategyCard = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{strategy.engagementLevel && (strategy.levelNote || strategy.levelChangedAt) ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Nível de envolvimento</h3>
|
||||
{strategy.levelNote ? (
|
||||
<p className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||
{strategy.levelNote}
|
||||
</p>
|
||||
) : null}
|
||||
{strategy.levelChangedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registrado em {dateFormatter.format(new Date(strategy.levelChangedAt))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trend.status || trend.note ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-medium">Tendência política</h3>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type CampaignConceptId =
|
|||
| 'roll-off'
|
||||
| 'dominancia-relativa'
|
||||
| 'classe-territorial'
|
||||
| 'nivel-de-envolvimento'
|
||||
| 'quantis-do-mapa'
|
||||
| 'posicao-no-municipio'
|
||||
| 'meta'
|
||||
|
|
@ -184,6 +185,21 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
|
|||
whereItAppears:
|
||||
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, coluna "Classe" da lista de municípios (com filtro e ordenação), e coluna "Classe" em `/campanha/territorios`.',
|
||||
},
|
||||
{
|
||||
id: 'nivel-de-envolvimento',
|
||||
categoryID: 'diagnostico',
|
||||
title: 'Nível de envolvimento (N0–N4)',
|
||||
oneLiner:
|
||||
'Quanto a campanha decidiu investir neste município: N0 monitorar, N1 presença de mandato, N2 rede sem agenda, N3 rede com agenda, N4 investimento pleno.',
|
||||
formula:
|
||||
'Não é calculado: é declarado pela coordenação geral. Todo movimento exige motivo e os sinais que o fariam ser revertido, e fica registrado em Decisões de alocação com o nível anterior, o novo e a data. Três regras seguram a oscilação: pular dois níveis de uma vez só com choque triangulado, não rebaixar um nível decidido há menos de três semanas, e um movimento por mês. A coordenação pode passar por cima delas — o override é gravado com as regras que contrariou.',
|
||||
example:
|
||||
'Um município em N1 que recebeu rede e entrou na agenda de giro sobe para N3; nas três semanas seguintes um pedido de rebaixamento fica bloqueado até a janela de proteção fechar, salvo override justificado.',
|
||||
whyItMatters:
|
||||
'Separa o que a campanha SABE sobre o município (classe, captura, cobertura da meta) do que a campanha DECIDIU fazer nele. Sem esse registro, a alocação de presença vira memória de reunião: ninguém sabe quem prometeu o quê, nem por quê, nem o que faria voltar atrás. Os cortes das regras de estabilidade são ilustrativos até o backtest calibrá-los.',
|
||||
whereItAppears:
|
||||
'Coluna "Nível" da lista de municípios (com filtro, ordenação e edição pela coordenação) e bloco de estratégia no detalhe do município.',
|
||||
},
|
||||
{
|
||||
id: 'elegibilidade-para-visita',
|
||||
categoryID: 'diagnostico',
|
||||
|
|
|
|||
173
src/lib/engagementLevel.ts
Normal file
173
src/lib/engagementLevel.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* E14 — the N0–N4 engagement ladder and the movement rules that guard it.
|
||||
* Pure and client-safe: the collection, the server action, the list URL and
|
||||
* the Popover control all read the ladder from here, and the rules are unit
|
||||
* tested without a database.
|
||||
*
|
||||
* The cuts below are the ones the research report (§6.8) proposes and are
|
||||
* ILLUSTRATIVE — calibrating them against real movement is E15. They live in
|
||||
* one object so recalibration is a one-line diff.
|
||||
*/
|
||||
|
||||
export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const
|
||||
|
||||
/** `allocationDecision.patternId` under which every level movement is filed. */
|
||||
export const ENGAGEMENT_LEVEL_PATTERN_ID = 'nivel'
|
||||
|
||||
/** Cap shared by the field, the schema and the textareas, so they agree. */
|
||||
export const ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH = 2000
|
||||
|
||||
export type EngagementLevel = (typeof engagementLevels)[number]
|
||||
|
||||
/**
|
||||
* Ordinal position on the ladder, for measuring the DISTANCE of a movement —
|
||||
* ordering is Postgres's job (the column sorts lexicographically in this same
|
||||
* order). A table rather than `engagementLevels.indexOf` so the lookup is total
|
||||
* and cannot answer -1.
|
||||
*/
|
||||
export const engagementLevelRank: Record<EngagementLevel, number> = {
|
||||
n0: 0,
|
||||
n1: 1,
|
||||
n2: 2,
|
||||
n3: 3,
|
||||
n4: 4,
|
||||
}
|
||||
|
||||
export const isEngagementLevel = (value: unknown): value is EngagementLevel =>
|
||||
typeof value === 'string' && engagementLevels.includes(value as EngagementLevel)
|
||||
|
||||
/** The ladder as the mesa says it out loud (research §6.8). */
|
||||
const engagementLevelNames: Record<EngagementLevel, string> = {
|
||||
n0: 'Monitorar',
|
||||
n1: 'Presença de mandato',
|
||||
n2: 'Rede sem agenda',
|
||||
n3: 'Rede + agenda',
|
||||
n4: 'Investimento pleno',
|
||||
}
|
||||
|
||||
/**
|
||||
* "N3 · Rede + agenda" — the numeral alone is the badge, this is the tooltip.
|
||||
* Here rather than in `municipalityLabels` so the Popover island can name a
|
||||
* level without importing a module that builds the 435-entry catalog and the
|
||||
* concept glossary at init.
|
||||
*/
|
||||
export const formatEngagementLevelLabel = (level: EngagementLevel): string =>
|
||||
`${level.toUpperCase()} · ${engagementLevelNames[level]}`
|
||||
|
||||
export const EMPTY_ENGAGEMENT_LEVEL_LABEL = 'Sem nível'
|
||||
|
||||
/**
|
||||
* Hysteresis, so the ladder records decisions instead of oscillating with the
|
||||
* mood of the week (research §6.8).
|
||||
*/
|
||||
export const ENGAGEMENT_LEVEL_RULES = {
|
||||
/**
|
||||
* A freshly decided level is protected from being undone for this long.
|
||||
* §6.8 words this as protecting a PROMOTION; guarding any recent decision is
|
||||
* both stricter and derivable from `levelChangedAt` alone — knowing the
|
||||
* direction of the last movement would cost a query the control cannot make.
|
||||
* The override covers the difference; E15 calibrates.
|
||||
*/
|
||||
protectionWindowDays: 21,
|
||||
/** Beyond this distance a single movement needs a triangulated shock. */
|
||||
maxStepsWithoutShock: 1,
|
||||
} as const
|
||||
|
||||
export type EngagementLevelViolationId =
|
||||
| 'salto-de-dois-niveis'
|
||||
| 'janela-de-protecao'
|
||||
| 'dois-movimentos-no-mes'
|
||||
|
||||
export type EngagementLevelViolation = {
|
||||
id: EngagementLevelViolationId
|
||||
/** Rendered to the coordinator as the reason the movement is being held. */
|
||||
message: string
|
||||
}
|
||||
|
||||
export type EngagementLevelMovementInput = {
|
||||
from: EngagementLevel | null
|
||||
to: EngagementLevel
|
||||
/** When the current level was recorded; null when the município never had one. */
|
||||
levelChangedAt: string | null
|
||||
now: Date
|
||||
/** The coordinator declared a triangulated shock, which licenses a 2-level jump. */
|
||||
triangulatedShock: boolean
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS
|
||||
|
||||
const isSameCalendarMonth = (left: Date, right: Date): boolean =>
|
||||
left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth()
|
||||
|
||||
/**
|
||||
* The movement rules, as a list rather than a boolean: the control shows the
|
||||
* coordinator every reason at once, and the accepted override is stored with
|
||||
* exactly the reasons it overrode.
|
||||
*
|
||||
* "Two movements in a month" needs no history query — the movement being made
|
||||
* is at least the second one of the month precisely when the previous one
|
||||
* landed in the same calendar month.
|
||||
*/
|
||||
export const getEngagementLevelViolations = ({
|
||||
from,
|
||||
to,
|
||||
levelChangedAt,
|
||||
now,
|
||||
triangulatedShock,
|
||||
}: EngagementLevelMovementInput): EngagementLevelViolation[] => {
|
||||
// The first level a município ever gets is a starting point, not a movement:
|
||||
// there is nothing to oscillate against yet.
|
||||
if (from === null || from === to) return []
|
||||
|
||||
const violations: EngagementLevelViolation[] = []
|
||||
const steps = Math.abs(engagementLevelRank[to] - engagementLevelRank[from])
|
||||
const isPromotion = engagementLevelRank[to] > engagementLevelRank[from]
|
||||
|
||||
if (steps > ENGAGEMENT_LEVEL_RULES.maxStepsWithoutShock && !triangulatedShock) {
|
||||
violations.push({
|
||||
id: 'salto-de-dois-niveis',
|
||||
message: `Movimento de ${steps} níveis de uma vez. Só com choque triangulado.`,
|
||||
})
|
||||
}
|
||||
|
||||
const changedAt = levelChangedAt ? new Date(levelChangedAt) : null
|
||||
const hasChangedAt = changedAt !== null && !Number.isNaN(changedAt.getTime())
|
||||
|
||||
if (hasChangedAt) {
|
||||
const daysSinceChange = daysBetween(changedAt, now)
|
||||
|
||||
if (!isPromotion && daysSinceChange < ENGAGEMENT_LEVEL_RULES.protectionWindowDays) {
|
||||
violations.push({
|
||||
id: 'janela-de-protecao',
|
||||
message: `O nível atual tem menos de ${ENGAGEMENT_LEVEL_RULES.protectionWindowDays} dias. Rebaixar agora desfaz uma decisão dentro da janela de proteção.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (isSameCalendarMonth(changedAt, now)) {
|
||||
violations.push({
|
||||
id: 'dois-movimentos-no-mes',
|
||||
message: 'Este município já mudou de nível neste mês.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the server action when a movement breaks a rule and the
|
||||
* coordinator has not overridden it. It carries the reasons so the route can
|
||||
* answer with them and the control can offer the override in place, instead of
|
||||
* collapsing everything into one opaque "não foi possível salvar".
|
||||
*/
|
||||
export class EngagementLevelBlockedError extends Error {
|
||||
readonly violations: EngagementLevelViolation[]
|
||||
|
||||
constructor(violations: EngagementLevelViolation[]) {
|
||||
super('O movimento contraria as regras de estabilidade do nível.')
|
||||
this.name = 'EngagementLevelBlockedError'
|
||||
this.violations = violations
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH, engagementLevels } from '@/lib/engagementLevel'
|
||||
import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives'
|
||||
import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge'
|
||||
|
||||
|
|
@ -62,6 +63,25 @@ export const MUNICIPALITY_ADVISOR_MEMBERSHIP_SAFE_MESSAGES = [
|
|||
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
|
||||
] as const
|
||||
|
||||
export const MUNICIPALITY_ENGAGEMENT_LEVEL_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação geral ou o candidato move o nível de envolvimento.'
|
||||
|
||||
/**
|
||||
* E14 — a movement is never just the new level: the motivo and the signals
|
||||
* that would reverse it are what make the decision auditable later, so both
|
||||
* are required by the schema rather than by the form alone.
|
||||
*/
|
||||
export const municipalityEngagementLevelSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
level: z.enum(engagementLevels),
|
||||
note: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
|
||||
reversalSignals: z.string().trim().min(1).max(ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH),
|
||||
/** Licenses a two-level jump (research §6.8). */
|
||||
triangulatedShock: z.boolean().default(false),
|
||||
/** Accepts the listed violations knowingly; recorded in the decision snapshot. */
|
||||
override: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const municipalityExpectedVotesSchema = z.object({
|
||||
municipality: positiveRelationshipId,
|
||||
expectedVotes: voteEstimateScenarioFieldsSchema,
|
||||
|
|
@ -74,3 +94,4 @@ export type MunicipalityAdvisorsAssignmentInput = z.input<
|
|||
>
|
||||
export type MunicipalityAdvisorMembershipInput = z.input<typeof municipalityAdvisorMembershipSchema>
|
||||
export type MunicipalityExpectedVotesInput = z.input<typeof municipalityExpectedVotesSchema>
|
||||
export type MunicipalityEngagementLevelInput = z.input<typeof municipalityEngagementLevelSchema>
|
||||
|
|
|
|||
12001
src/migrations/20260727_161752_add_municipality_engagement_level.json
Normal file
12001
src/migrations/20260727_161752_add_municipality_engagement_level.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,26 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_municipality_engagement_level" AS ENUM('n0', 'n1', 'n2', 'n3', 'n4');
|
||||
ALTER TYPE "public"."enum_allocation_decision_outcome" ADD VALUE 'movimento';
|
||||
ALTER TABLE "municipality" ADD COLUMN "engagement_level" "enum_municipality_engagement_level";
|
||||
ALTER TABLE "municipality" ADD COLUMN "level_note" varchar;
|
||||
ALTER TABLE "municipality" ADD COLUMN "level_changed_at" timestamp(3) with time zone;
|
||||
CREATE INDEX "municipality_engagement_level_idx" ON "municipality" USING btree ("engagement_level");
|
||||
CREATE INDEX "municipality_level_changed_at_idx" ON "municipality" USING btree ("level_changed_at");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE text;
|
||||
DROP TYPE "public"."enum_allocation_decision_outcome";
|
||||
CREATE TYPE "public"."enum_allocation_decision_outcome" AS ENUM('aceita', 'descarta');
|
||||
ALTER TABLE "allocation_decision" ALTER COLUMN "outcome" SET DATA TYPE "public"."enum_allocation_decision_outcome" USING "outcome"::"public"."enum_allocation_decision_outcome";
|
||||
DROP INDEX "municipality_engagement_level_idx";
|
||||
DROP INDEX "municipality_level_changed_at_idx";
|
||||
ALTER TABLE "municipality" DROP COLUMN "engagement_level";
|
||||
ALTER TABLE "municipality" DROP COLUMN "level_note";
|
||||
ALTER TABLE "municipality" DROP COLUMN "level_changed_at";
|
||||
DROP TYPE "public"."enum_municipality_engagement_level";`)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import * as migration_20260725_022155_add_municipality_budget_notes from './2026
|
|||
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key';
|
||||
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity';
|
||||
import * as migration_20260727_032523_add_users_roles from './20260727_032523_add_users_roles';
|
||||
import * as migration_20260727_161752_add_municipality_engagement_level from './20260727_161752_add_municipality_engagement_level';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -198,4 +199,9 @@ export const migrations = [
|
|||
down: migration_20260727_032523_add_users_roles.down,
|
||||
name: '20260727_032523_add_users_roles',
|
||||
},
|
||||
{
|
||||
up: migration_20260727_161752_add_municipality_engagement_level.up,
|
||||
down: migration_20260727_161752_add_municipality_engagement_level.down,
|
||||
name: '20260727_161752_add_municipality_engagement_level'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@ export interface Municipality {
|
|||
zoneNumber?: number | null;
|
||||
advisors?: (number | CampaignUser)[] | null;
|
||||
priority?: ('alta' | 'normal') | null;
|
||||
/**
|
||||
* Quanto a campanha investe neste município (N0 a N4). Movimento é decisão da coordenação e fica registrado em Decisões de alocação.
|
||||
*/
|
||||
engagementLevel?: ('n0' | 'n1' | 'n2' | 'n3' | 'n4') | null;
|
||||
/**
|
||||
* Motivo corrente do nível — o histórico vive em Decisões de alocação.
|
||||
*/
|
||||
levelNote?: string | null;
|
||||
/**
|
||||
* Derivado da última mudança de nível.
|
||||
*/
|
||||
levelChangedAt?: string | null;
|
||||
/**
|
||||
* Total esperado do município por cenário (pessimista/média/otimista) — distinto da soma das lideranças.
|
||||
*/
|
||||
|
|
@ -560,7 +572,7 @@ export interface AllocationDecision {
|
|||
id: number;
|
||||
municipality: number | Municipality;
|
||||
patternId: string;
|
||||
outcome: 'aceita' | 'descarta';
|
||||
outcome: 'aceita' | 'descarta' | 'movimento';
|
||||
rationale: string;
|
||||
alternativeReading?: string | null;
|
||||
/**
|
||||
|
|
@ -1389,6 +1401,9 @@ export interface MunicipalitySelect<T extends boolean = true> {
|
|||
zoneNumber?: T;
|
||||
advisors?: T;
|
||||
priority?: T;
|
||||
engagementLevel?: T;
|
||||
levelNote?: T;
|
||||
levelChangedAt?: T;
|
||||
expectedVotes?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -217,11 +217,27 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
|
|||
|
||||
export const canDeleteMunicipality: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/** Advisor assignment is unrestricted staff (coordinator + candidate; actions use overrideAccess). */
|
||||
export const canAssignMunicipalityAdvisors: FieldAccess = async ({ req }) => {
|
||||
/**
|
||||
* Fields only the coordination itself may write. The two policies below are
|
||||
* named apart because they answer different questions and can diverge, but
|
||||
* they resolve "unrestricted staff" once so a change to what that means is
|
||||
* made in one place.
|
||||
*/
|
||||
const unrestrictedCampaignFieldAccess: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignUnrestricted(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
/** Advisor assignment is unrestricted staff (coordinator + candidate; actions use overrideAccess). */
|
||||
export const canAssignMunicipalityAdvisors = unrestrictedCampaignFieldAccess
|
||||
|
||||
export const canManageMunicipalityAdvisors: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/**
|
||||
* E14 — moving a município between engagement levels is a reallocation
|
||||
* decision, so it is unrestricted staff only; the advisor proposes it through
|
||||
* a signal. Reading stays with `canReadCampaignStaffField` like the rest of
|
||||
* the staff block.
|
||||
*/
|
||||
export const canManageMunicipalityEngagementLevel = unrestrictedCampaignFieldAccess
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export {
|
|||
canCreateMunicipality,
|
||||
canDeleteMunicipality,
|
||||
canManageMunicipalityAdvisors,
|
||||
canManageMunicipalityEngagementLevel,
|
||||
canReadMunicipality,
|
||||
canUpdateMunicipality,
|
||||
getAccessibleMunicipalityIds,
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export const formatMunicipalityGeographyLabel = (municipality: {
|
|||
}
|
||||
|
||||
/**
|
||||
* The 11 real column ids in `/campanha/municipios` — 10 staff columns plus
|
||||
* The 12 real column ids in `/campanha/municipios` — 11 staff columns plus
|
||||
* `lastUpdateAt`, which only the non-staff (leader) view renders. Closed union
|
||||
* so `municipalityColumnDescriptions` below can't silently drop a column.
|
||||
*/
|
||||
|
|
@ -196,6 +196,7 @@ export type MunicipalityListColumnId =
|
|||
| 'kind'
|
||||
| 'votos'
|
||||
| 'classe'
|
||||
| 'nivel'
|
||||
| 'advisors'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
|
|
@ -225,6 +226,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
|
|||
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
|
||||
votos: formatMunicipalityConcentrationHint(),
|
||||
classe: CLASS_COLUMN_DESCRIPTION,
|
||||
nivel: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
* the former `municipalityUi.ts` in Pass 2 W1.
|
||||
*/
|
||||
import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { EMPTY_ENGAGEMENT_LEVEL_LABEL, formatEngagementLevelLabel } from '@/lib/engagementLevel'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
|
|
@ -16,9 +17,12 @@ import {
|
|||
} from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
buildMunicipalityListHref,
|
||||
municipalityListLevelFilterValues,
|
||||
municipalityListStateToRawParams,
|
||||
NO_LEVEL_FILTER_VALUE,
|
||||
parseMunicipalityListParams,
|
||||
serializeCanonicalMunicipalityListSearchParams,
|
||||
type MunicipalityListLevelFilterValue,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityListUrl'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
|
||||
|
|
@ -31,6 +35,7 @@ export type MunicipalityFilterParam =
|
|||
| 'coverage'
|
||||
| 'trend'
|
||||
| 'class'
|
||||
| 'level'
|
||||
| 'advisor'
|
||||
|
||||
type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
|
||||
|
|
@ -118,6 +123,22 @@ export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
|
|||
label: territorialClassLabels[territorialClass],
|
||||
})),
|
||||
},
|
||||
{
|
||||
param: 'level',
|
||||
label: 'Nível',
|
||||
staffOnly: true,
|
||||
selection: 'multi',
|
||||
// Static options, like `trend`: the ladder is a closed set, so offering
|
||||
// only the levels currently in use would hide the empty rungs the mesa is
|
||||
// deciding to fill. "Sem nível" is an option because it is the triage queue.
|
||||
options: municipalityListLevelFilterValues.map((value) => ({
|
||||
value,
|
||||
label:
|
||||
value === NO_LEVEL_FILTER_VALUE
|
||||
? EMPTY_ENGAGEMENT_LEVEL_LABEL
|
||||
: formatEngagementLevelLabel(value),
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const municipalityFilterDefinitionByParam = Object.fromEntries(
|
||||
|
|
@ -133,7 +154,13 @@ export const getMunicipalitySingleFilterValue = (
|
|||
param: 'kind' | 'coverage',
|
||||
): string | undefined => state[param]
|
||||
|
||||
export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend' | 'class'
|
||||
export type MunicipalityMultiFilterParam =
|
||||
| 'region'
|
||||
| 'slug'
|
||||
| 'advisor'
|
||||
| 'trend'
|
||||
| 'class'
|
||||
| 'level'
|
||||
|
||||
export const getMunicipalityMultiFilterValues = (
|
||||
state: MunicipalityListState,
|
||||
|
|
@ -143,6 +170,7 @@ export const getMunicipalityMultiFilterValues = (
|
|||
if (param === 'slug') return state.slugs ?? []
|
||||
if (param === 'trend') return state.trends ?? []
|
||||
if (param === 'class') return state.classes ?? []
|
||||
if (param === 'level') return state.levels ?? []
|
||||
return (state.advisors ?? []).map(String)
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +269,11 @@ const territorialClassCount = Object.keys(territorialClassLabels).length
|
|||
const isTerritorialClass = (value: string): value is MunicipalityTerritorialClass =>
|
||||
value in territorialClassLabels
|
||||
|
||||
const engagementLevelFilterCount = municipalityListLevelFilterValues.length
|
||||
|
||||
const isEngagementLevelFilterValue = (value: string): value is MunicipalityListLevelFilterValue =>
|
||||
(municipalityListLevelFilterValues as readonly string[]).includes(value)
|
||||
|
||||
/**
|
||||
* B16+ href fast path: option rows only need the WOULD-BE href, so the toggled
|
||||
* state is derived with the same canonical rules the parser applies (advisors
|
||||
|
|
@ -282,6 +315,10 @@ export const buildMunicipalityFilterOptionHref = (
|
|||
const classes = toggled.filter(isTerritorialClass)
|
||||
if (classes.length && classes.length < territorialClassCount) next.classes = classes
|
||||
else delete next.classes
|
||||
} else if (param === 'level') {
|
||||
const levels = toggled.filter(isEngagementLevelFilterValue)
|
||||
if (levels.length && levels.length < engagementLevelFilterCount) next.levels = levels
|
||||
else delete next.levels
|
||||
}
|
||||
|
||||
const query = serializeCanonicalMunicipalityListSearchParams(next).toString()
|
||||
|
|
@ -308,6 +345,8 @@ export const isMunicipalityColumnFilterActive = (
|
|||
return Boolean(state.trends?.length)
|
||||
case 'class':
|
||||
return Boolean(state.classes?.length)
|
||||
case 'level':
|
||||
return Boolean(state.levels?.length)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +378,17 @@ export const formatMunicipalityActiveFiltersSummary = (
|
|||
`Classe ${state.classes.map((territorialClass) => territorialClassLabels[territorialClass].toLowerCase()).join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.levels?.length) {
|
||||
parts.push(
|
||||
`Nível ${state.levels
|
||||
.map((level) =>
|
||||
level === NO_LEVEL_FILTER_VALUE
|
||||
? EMPTY_ENGAGEMENT_LEVEL_LABEL.toLowerCase()
|
||||
: level.toUpperCase(),
|
||||
)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { engagementLevels, type EngagementLevel } from '@/lib/engagementLevel'
|
||||
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import {
|
||||
|
|
@ -42,9 +43,29 @@ export type MunicipalityListSortKey =
|
|||
| 'deficit'
|
||||
| 'frescor'
|
||||
| 'classe'
|
||||
| 'nivel'
|
||||
|
||||
export type MunicipalityListSortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* E14 filter values: the five levels plus the absence of one. "Sem nível" is
|
||||
* a real answer here — it is the triage queue — so it is a selectable value
|
||||
* and not the empty selection.
|
||||
*
|
||||
* Same sentinel convention as `NO_PARTY_FILTER_VALUE` in `stateDeputyListUrl`
|
||||
* (second call site; a third should extract the pair sentinel + `exists: false`
|
||||
* branch). Unlike `party`, the level is a closed enum, so it also gets the
|
||||
* "all selected → absent" canonicalization of `parseExhaustiveEnumParam`.
|
||||
*/
|
||||
export const NO_LEVEL_FILTER_VALUE = 'sem_nivel'
|
||||
|
||||
export type MunicipalityListLevelFilterValue = EngagementLevel | typeof NO_LEVEL_FILTER_VALUE
|
||||
|
||||
export const municipalityListLevelFilterValues: readonly MunicipalityListLevelFilterValue[] = [
|
||||
...engagementLevels,
|
||||
NO_LEVEL_FILTER_VALUE,
|
||||
]
|
||||
|
||||
/**
|
||||
* E9 allocation queue: the list opens on the decision it exists to serve —
|
||||
* where the goal is least covered by auditable commitments (biggest deficit
|
||||
|
|
@ -68,6 +89,7 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
deficit: 'Cobertura da meta',
|
||||
frescor: 'Frescor do sinal',
|
||||
classe: 'Classe',
|
||||
nivel: 'Nível',
|
||||
}
|
||||
|
||||
export type MunicipalityListState = {
|
||||
|
|
@ -94,6 +116,12 @@ export type MunicipalityListState = {
|
|||
* Never holds the full set (same "todas" canonicalization as `trends`).
|
||||
*/
|
||||
classes?: MunicipalityTerritorialClass[]
|
||||
/**
|
||||
* Multi-select (OR) E14 engagement levels, including "sem nível". Stored,
|
||||
* so unlike `classes` this one IS part of `buildMunicipalityListWhere`.
|
||||
* Never holds the full set (same "todos" canonicalization as `trends`).
|
||||
*/
|
||||
levels?: MunicipalityListLevelFilterValue[]
|
||||
/** Candidate number for the map comparison mode (does not filter the list). */
|
||||
compare?: number
|
||||
sort?: MunicipalityListSortKey
|
||||
|
|
@ -112,6 +140,7 @@ const municipalityListParamNames = [
|
|||
'priority',
|
||||
'trend',
|
||||
'class',
|
||||
'level',
|
||||
'compare',
|
||||
'sort',
|
||||
'dir',
|
||||
|
|
@ -135,6 +164,8 @@ const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
|
|||
'frescor',
|
||||
// Ordinal, not alphabetical: descending means reduto first.
|
||||
'classe',
|
||||
// Ordinal too: descending opens on N4, where the campaign is most invested.
|
||||
'nivel',
|
||||
]
|
||||
|
||||
export const defaultMunicipalityListSortDir = (
|
||||
|
|
@ -174,6 +205,9 @@ export const formatMunicipalityListSortSummary = (
|
|||
? 'Ordenado por classe (reduto primeiro)'
|
||||
: 'Ordenado por classe (marginal primeiro)'
|
||||
}
|
||||
if (sort === 'nivel') {
|
||||
return dir === 'desc' ? 'Ordenado por nível (N4 primeiro)' : 'Ordenado por nível (N0 primeiro)'
|
||||
}
|
||||
const label = municipalityListSortLabels[sort]
|
||||
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
|
||||
}
|
||||
|
|
@ -219,6 +253,7 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
|
|||
|
||||
const politicalTrendStatusSet = new Set<string>(Object.keys(politicalTrendLabels))
|
||||
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
|
||||
const engagementLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
|
||||
|
||||
export const municipalityListStateToRawParams = (
|
||||
state: MunicipalityListState,
|
||||
|
|
@ -234,6 +269,7 @@ export const municipalityListStateToRawParams = (
|
|||
priority: state.priority,
|
||||
trend: state.trends,
|
||||
class: state.classes,
|
||||
level: state.levels,
|
||||
compare: state.compare === undefined ? undefined : String(state.compare),
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
|
|
@ -258,6 +294,10 @@ export const parseMunicipalityListParams = (
|
|||
params.class,
|
||||
territorialClassSet,
|
||||
)
|
||||
const levels = parseExhaustiveEnumParam<MunicipalityListLevelFilterValue>(
|
||||
params.level,
|
||||
engagementLevelFilterSet,
|
||||
)
|
||||
const rawCompare = strictDecimalInteger(firstValue(params.compare))
|
||||
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
|
||||
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
|
||||
|
|
@ -277,6 +317,7 @@ export const parseMunicipalityListParams = (
|
|||
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
|
||||
...(trends.length ? { trends } : {}),
|
||||
...(classes.length ? { classes } : {}),
|
||||
...(levels.length ? { levels } : {}),
|
||||
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
...(dir ? { dir } : {}),
|
||||
|
|
@ -305,6 +346,20 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
}
|
||||
if (state.priority) filters.push({ priority: { equals: state.priority } })
|
||||
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
|
||||
if (state.levels?.length) {
|
||||
// "Sem nível" is absence, which no `in` can express — it rides along as an
|
||||
// OR branch so "N0 ou sem nível" (the triage view) is one query.
|
||||
const selectedLevels = state.levels.filter(
|
||||
(level): level is EngagementLevel => level !== NO_LEVEL_FILTER_VALUE,
|
||||
)
|
||||
const levelFilters: Where[] = []
|
||||
if (selectedLevels.length) levelFilters.push({ engagementLevel: { in: selectedLevels } })
|
||||
if (state.levels.includes(NO_LEVEL_FILTER_VALUE)) {
|
||||
levelFilters.push({ engagementLevel: { exists: false } })
|
||||
}
|
||||
const [onlyFilter, ...restFilters] = levelFilters
|
||||
filters.push(onlyFilter && restFilters.length === 0 ? onlyFilter : { or: levelFilters })
|
||||
}
|
||||
// `state.classes` is absent on purpose: the class is derived from the TSE
|
||||
// artifact, not stored, so it can't be a Payload constraint. `municipalityPageData`
|
||||
// filters it in memory over the unpaginated scope.
|
||||
|
|
@ -339,6 +394,7 @@ export const serializeCanonicalMunicipalityListSearchParams = (
|
|||
for (const territorialClass of canonicalState.classes ?? []) {
|
||||
params.append('class', territorialClass)
|
||||
}
|
||||
for (const level of canonicalState.levels ?? []) params.append('level', level)
|
||||
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
|
||||
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
|
||||
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
|
||||
|
|
@ -408,6 +464,9 @@ const formatMunicipalitySortOptionLabel = (
|
|||
if (key === 'classe') {
|
||||
return dir === 'asc' ? `${base} (marginal primeiro)` : `${base} (reduto primeiro)`
|
||||
}
|
||||
if (key === 'nivel') {
|
||||
return dir === 'asc' ? `${base} (N0 primeiro)` : `${base} (N4 primeiro)`
|
||||
}
|
||||
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'server-only'
|
|||
import type { Payload, Where } from 'payload'
|
||||
|
||||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { engagementLevelRank } from '@/lib/engagementLevel'
|
||||
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import {
|
||||
compareMunicipalityVotesForSort,
|
||||
|
|
@ -196,6 +197,9 @@ const payloadSortFieldByKey: Partial<Record<MunicipalityListSortKey, string>> =
|
|||
kind: 'kind',
|
||||
lastUpdateAt: 'lastUpdateAt',
|
||||
trend: 'politicalTrend.status',
|
||||
// E9 (deficit, frescor), E10 (classe) and E14 (nivel) are absent on purpose:
|
||||
// the first three are derived, and `nivel` needs its NULLs at the end, which
|
||||
// the query cannot express.
|
||||
}
|
||||
|
||||
const municipalityNameCompare = (left: Municipality, right: Municipality): number =>
|
||||
|
|
@ -273,6 +277,14 @@ const applyDerivedMunicipalitySort = (
|
|||
return now - new Date(lastSignalAt).getTime()
|
||||
})
|
||||
}
|
||||
case 'nivel':
|
||||
// Stored, so Postgres could order it — but Postgres puts NULLs FIRST on
|
||||
// DESC, and with no backfill that hands "N4 primeiro" a page of "Sem
|
||||
// nível". Absence of a decision is not the top of the ladder: it sorts
|
||||
// like `sem_base` above, at the end in either direction.
|
||||
return sortByNullableValue(docs, dir, (municipality) =>
|
||||
municipality.engagementLevel ? engagementLevelRank[municipality.engagementLevel] : null,
|
||||
)
|
||||
case 'classe':
|
||||
// Ordinal weight, not the label: descending means reduto first, and
|
||||
// `sem_base` (no weight) lands at the end in either direction.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { EngagementLevel } from '@/lib/engagementLevel'
|
||||
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
||||
import {
|
||||
toVoteEstimateScenarioViewModel,
|
||||
|
|
@ -42,6 +43,9 @@ export const municipalityListSelect = {
|
|||
status: true,
|
||||
note: true,
|
||||
},
|
||||
engagementLevel: true,
|
||||
levelNote: true,
|
||||
levelChangedAt: true,
|
||||
} as const
|
||||
|
||||
type MunicipalityPoliticalTrendViewModel = {
|
||||
|
|
@ -72,6 +76,10 @@ export type MunicipalityListViewModel = {
|
|||
expectedVotes: VoteEstimateScenarioViewModel
|
||||
politicalTrendStatus: PoliticalTrendStatus | null
|
||||
politicalTrendNote: string | null
|
||||
/** E14 — staff-only; field access leaves it undefined for anyone else. */
|
||||
engagementLevel: EngagementLevel | null
|
||||
levelNote: string | null
|
||||
levelChangedAt: string | null
|
||||
pledges: MunicipalityPledgeAggregate
|
||||
votePosition2022: MunicipalityVoteRankEntry | null
|
||||
/** E10 — classe operacional derivada do artefato TSE. */
|
||||
|
|
@ -111,6 +119,9 @@ export const toMunicipalityListViewModel = (
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
|
||||
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
|
||||
politicalTrendNote: municipality.politicalTrend?.note ?? null,
|
||||
engagementLevel: municipality.engagementLevel ?? null,
|
||||
levelNote: municipality.levelNote ?? null,
|
||||
levelChangedAt: municipality.levelChangedAt ?? null,
|
||||
pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022,
|
||||
territorialClass: territorialClass.class,
|
||||
|
|
@ -151,6 +162,10 @@ export type MunicipalityStrategyViewModel = {
|
|||
priority: 'alta' | 'normal'
|
||||
expectedVotes: VoteEstimateScenarioViewModel
|
||||
politicalTrend: MunicipalityPoliticalTrendViewModel
|
||||
/** E14 — the ladder and the motivo behind the current rung. */
|
||||
engagementLevel: EngagementLevel | null
|
||||
levelNote: string | null
|
||||
levelChangedAt: string | null
|
||||
strengths: string[]
|
||||
risks: string[]
|
||||
stateDeputyIDs: number[]
|
||||
|
|
@ -193,6 +208,9 @@ export const toMunicipalityDetailViewModel = (
|
|||
recordedByName: trendRecordedByName,
|
||||
recordedAt: municipality.politicalTrend?.recordedAt ?? null,
|
||||
},
|
||||
engagementLevel: municipality.engagementLevel ?? null,
|
||||
levelNote: municipality.levelNote ?? null,
|
||||
levelChangedAt: municipality.levelChangedAt ?? null,
|
||||
strengths: (municipality.strengths ?? []).map((item) => item.text),
|
||||
risks: (municipality.risks ?? []).map((item) => item.text),
|
||||
stateDeputyIDs: (municipality.stateDeputies ?? [])
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
// compile before any spec's client makes the real request.
|
||||
for (const path of [
|
||||
'/campanha/municipios/advisors',
|
||||
'/campanha/municipios/engagement-level',
|
||||
'/campanha/municipios/expected-votes',
|
||||
'/campanha/municipios/political-trend',
|
||||
'/campanha/liderancas/support-status',
|
||||
|
|
|
|||
|
|
@ -999,6 +999,8 @@ export class CampaignFixtures {
|
|||
priority: 'normal',
|
||||
expectedVotes: { pessimistic: null, central: null, optimistic: null },
|
||||
politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null },
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
strengths: [],
|
||||
risks: [],
|
||||
dobradinhaNotes: null,
|
||||
|
|
|
|||
262
tests/int/campaignMunicipalityEngagementLevel.int.spec.ts
Normal file
262
tests/int/campaignMunicipalityEngagementLevel.int.spec.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setMunicipalityEngagementLevelRecord } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { ENGAGEMENT_LEVEL_PATTERN_ID, EngagementLevelBlockedError } from '@/lib/engagementLevel'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const findDecisions = (municipalityID: number) =>
|
||||
payload.find({
|
||||
collection: 'allocationDecision',
|
||||
where: { municipality: { equals: municipalityID } },
|
||||
sort: 'createdAt',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
describe('municipality engagement level (E14)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('records the level and its movement decision in one transaction', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
const updated = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n3',
|
||||
note: 'Prefeitura aliada e duas lideranças novas.',
|
||||
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect(updated.engagementLevel).toBe('n3')
|
||||
expect(updated.levelNote).toBe('Prefeitura aliada e duas lideranças novas.')
|
||||
expect(updated.levelChangedAt).toBeTruthy()
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
expect(decisions.docs).toHaveLength(1)
|
||||
const decision = decisions.docs[0]!
|
||||
fixtures.own('allocationDecision', decision.id)
|
||||
|
||||
expect(decision.patternId).toBe(ENGAGEMENT_LEVEL_PATTERN_ID)
|
||||
expect(decision.outcome).toBe('movimento')
|
||||
expect(decision.rationale).toBe('Prefeitura aliada e duas lideranças novas.')
|
||||
expect(decision.decidedBy).toBe(coordinator.id)
|
||||
expect(decision.snapshot).toMatchObject({
|
||||
from: null,
|
||||
to: 'n3',
|
||||
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
|
||||
violations: [],
|
||||
overridden: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('holds a movement that breaks the rules and writes nothing', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n1',
|
||||
note: 'Entrada no mapa como presença de mandato.',
|
||||
reversalSignals: 'Nenhuma agenda executada no trimestre.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
// Two levels up, no triangulated shock, and the level was recorded seconds
|
||||
// ago — every rule at once.
|
||||
const blocked = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n3',
|
||||
note: 'Achismo da semana.',
|
||||
reversalSignals: 'Nada.',
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
|
||||
expect((blocked as EngagementLevelBlockedError).violations.map((v) => v.id)).toContain(
|
||||
'salto-de-dois-niveis',
|
||||
)
|
||||
|
||||
const unchanged = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
depth: 0,
|
||||
})
|
||||
expect(unchanged.engagementLevel).toBe('n1')
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
expect(decisions.docs).toHaveLength(1)
|
||||
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
|
||||
})
|
||||
|
||||
it('lets the coordinator override knowingly and records what was overridden', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n1',
|
||||
note: 'Entrada no mapa.',
|
||||
reversalSignals: 'Agenda vazia por um trimestre.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n2',
|
||||
note: 'Adversário abriu comitê e o prefeito pediu presença.',
|
||||
reversalSignals: 'Comitê adversário fechar.',
|
||||
override: true,
|
||||
})
|
||||
expect(moved.engagementLevel).toBe('n2')
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
|
||||
expect(decisions.docs).toHaveLength(2)
|
||||
|
||||
const latest = decisions.docs.at(-1)!
|
||||
expect(latest.snapshot).toMatchObject({
|
||||
from: 'n1',
|
||||
to: 'n2',
|
||||
overridden: true,
|
||||
// A promotion is not held by the protection window — that rule guards
|
||||
// against undoing a decision, not against deepening it.
|
||||
violations: ['dois-movimentos-no-mes'],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a two-level jump when a triangulated shock is declared', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { engagementLevel: 'n1' },
|
||||
depth: 0,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
// Push the clock out of both windows so the shock is the only thing at play.
|
||||
await payload.db.drizzle.execute(
|
||||
`UPDATE municipality SET level_changed_at = now() - interval '90 days' WHERE id = ${municipality.id}`,
|
||||
)
|
||||
|
||||
const moved = await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n3',
|
||||
note: 'Três sinais independentes de invasão na mesma semana.',
|
||||
reversalSignals: 'Adversário recuar do território.',
|
||||
triangulatedShock: true,
|
||||
})
|
||||
expect(moved.engagementLevel).toBe('n3')
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
|
||||
expect(decisions.docs).toHaveLength(1)
|
||||
expect(decisions.docs[0]!.snapshot).toMatchObject({
|
||||
from: 'n1',
|
||||
to: 'n3',
|
||||
triangulatedShock: true,
|
||||
overridden: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the ladder with unrestricted staff — the advisor proposes, it does not move', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
|
||||
|
||||
await expect(
|
||||
setMunicipalityEngagementLevelRecord(payload, advisor, {
|
||||
municipality: municipality.id,
|
||||
level: 'n2',
|
||||
note: 'Quero subir o meu município.',
|
||||
reversalSignals: 'Nada.',
|
||||
}),
|
||||
).rejects.toThrow('coordenação geral ou o candidato')
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
expect(decisions.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides the level from the leader and shows it to the advisor read-only', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const leaderAccount = await fixtures.createCampaignUser('leader')
|
||||
const contact = await fixtures.createContact()
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality.id, [advisor.id])
|
||||
await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
user: leaderAccount.id,
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
await setMunicipalityEngagementLevelRecord(payload, coordinator, {
|
||||
municipality: municipality.id,
|
||||
level: 'n4',
|
||||
note: 'Prioridade máxima do ciclo.',
|
||||
reversalSignals: 'Queda de captura por dois meses.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
decisions.docs.forEach((decision) => fixtures.own('allocationDecision', decision.id))
|
||||
|
||||
const advisorRead = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(advisorRead.engagementLevel).toBe('n4')
|
||||
|
||||
// The advisor may edit the município, but not this field: Payload drops a
|
||||
// field the actor cannot write instead of failing the whole update, so the
|
||||
// proof is that the level did not move.
|
||||
const advisorAttempt = await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { engagementLevel: 'n0', levelNote: 'Assessor tentou mexer.' },
|
||||
depth: 0,
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(advisorAttempt.engagementLevel).toBe('n4')
|
||||
expect(advisorAttempt.levelNote).toBe('Prioridade máxima do ciclo.')
|
||||
|
||||
// The leader cannot read the município at all (lockdown), which is what
|
||||
// keeps the ladder off their surface.
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
depth: 0,
|
||||
user: leaderAccount,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
@ -338,6 +338,51 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
expect(sorted.municipalities.map((row) => row.slug)).toEqual([reduto.slug, marginal.slug])
|
||||
})
|
||||
|
||||
/**
|
||||
* E14: `nivel` is a stored column, so ordering is Postgres's. With no
|
||||
* backfill almost every município is null, and where the nulls land decides
|
||||
* what page 1 of "N4 primeiro" actually shows — so it is pinned rather than
|
||||
* inferred from the label.
|
||||
*/
|
||||
it('sorts by nível with the un-levelled municípios last in both directions', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const low = await fixtures.getMunicipality(municipalityCatalog[0]!.slug)
|
||||
const high = await fixtures.getMunicipality(municipalityCatalog[1]!.slug)
|
||||
const none = await fixtures.getMunicipality(municipalityCatalog[2]!.slug)
|
||||
|
||||
const marker = `e14-nivel-${Date.now()}`
|
||||
for (const [municipality, suffix, engagementLevel] of [
|
||||
[low, 'n0', 'n0'],
|
||||
[high, 'n4', 'n4'],
|
||||
[none, 'sem', null],
|
||||
] as const) {
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { name: `${marker}-${suffix}`, engagementLevel },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
}
|
||||
|
||||
const descending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'nivel',
|
||||
dir: 'desc',
|
||||
})
|
||||
expect(descending.municipalities.map((row) => row.engagementLevel)).toEqual(['n4', 'n0', null])
|
||||
|
||||
const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'nivel',
|
||||
dir: 'asc',
|
||||
})
|
||||
expect(ascending.municipalities.map((row) => row.engagementLevel)).toEqual(['n0', 'n4', null])
|
||||
})
|
||||
|
||||
it('keeps advisor access and applies URL filters on top', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const noopListFormAction = async (
|
|||
|
||||
const municipalityListDefaultProps = {
|
||||
isCoordinator: false,
|
||||
canMoveEngagementLevel: false,
|
||||
advisorOptions: [],
|
||||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
signalFormAction: noopListFormAction,
|
||||
|
|
@ -174,6 +175,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
|
||||
politicalTrendStatus: 'favoravel',
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: {
|
||||
declaredTotal: 1200,
|
||||
effectiveByScenario: { pessimistic: 1200, central: 1200, optimistic: 1200 },
|
||||
|
|
@ -214,6 +218,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'expansao',
|
||||
|
|
@ -246,12 +253,12 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).toContain('Cobertura da meta')
|
||||
expect(html).toContain('80%')
|
||||
expect(html).toContain('Faltam 300 votos para a meta')
|
||||
// B22: every one of the 10 staff columns carries a header explanation —
|
||||
// B22: every one of the 11 staff columns carries a header explanation —
|
||||
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
|
||||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
|
||||
// B41: horizontal scroll + sticky Município (Territory pattern).
|
||||
expect(html).toMatch(/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/)
|
||||
|
|
@ -348,6 +355,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'expansao',
|
||||
|
|
@ -389,6 +399,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'expansao',
|
||||
|
|
@ -427,6 +440,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: {
|
||||
declaredTotal: 800,
|
||||
effectiveByScenario: { pessimistic: 800, central: 800, optimistic: 800 },
|
||||
|
|
@ -471,6 +487,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'expansao',
|
||||
|
|
@ -520,10 +539,10 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
|
||||
// The staff `<thead>` already carries 10 header-explanation tooltips
|
||||
// The staff `<thead>` already carries 11 header-explanation tooltips
|
||||
// (B22), regardless of row data. This baseline isolates the advisors
|
||||
// CELL tooltip, the one this test actually exercises.
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
const HEADER_TOOLTIP_COUNT = 11
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
@ -539,6 +558,9 @@ describe('campaign visual foundation', () => {
|
|||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
engagementLevel: null,
|
||||
levelNote: null,
|
||||
levelChangedAt: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
territorialClass: 'sem_base' as const,
|
||||
|
|
|
|||
100
tests/unit/engagementLevel.unit.spec.ts
Normal file
100
tests/unit/engagementLevel.unit.spec.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ENGAGEMENT_LEVEL_RULES,
|
||||
engagementLevelRank,
|
||||
engagementLevels,
|
||||
getEngagementLevelViolations,
|
||||
isEngagementLevel,
|
||||
type EngagementLevel,
|
||||
type EngagementLevelViolationId,
|
||||
} from '@/lib/engagementLevel'
|
||||
|
||||
const NOW = new Date('2026-07-27T12:00:00.000Z')
|
||||
|
||||
const daysBefore = (days: number): string =>
|
||||
new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const violationIds = (input: {
|
||||
from: EngagementLevel | null
|
||||
to: EngagementLevel
|
||||
levelChangedAt?: string | null
|
||||
triangulatedShock?: boolean
|
||||
}): EngagementLevelViolationId[] =>
|
||||
getEngagementLevelViolations({
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
levelChangedAt: input.levelChangedAt ?? null,
|
||||
now: NOW,
|
||||
triangulatedShock: input.triangulatedShock ?? false,
|
||||
}).map((violation) => violation.id)
|
||||
|
||||
describe('engagement ladder', () => {
|
||||
it('ranks the levels in ladder order', () => {
|
||||
expect(engagementLevels.map((level) => engagementLevelRank[level])).toEqual([0, 1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('recognizes only the five levels', () => {
|
||||
expect(engagementLevels.every(isEngagementLevel)).toBe(true)
|
||||
expect(isEngagementLevel('n5')).toBe(false)
|
||||
expect(isEngagementLevel(null)).toBe(false)
|
||||
expect(isEngagementLevel(2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('engagement level movement rules', () => {
|
||||
it('treats the first level as a starting point, not a movement', () => {
|
||||
expect(violationIds({ from: null, to: 'n4' })).toEqual([])
|
||||
})
|
||||
|
||||
it('allows a single-step movement outside the protection window', () => {
|
||||
expect(violationIds({ from: 'n2', to: 'n3', levelChangedAt: daysBefore(60) })).toEqual([])
|
||||
})
|
||||
|
||||
it('holds a two-level jump unless a triangulated shock is declared', () => {
|
||||
const levelChangedAt = daysBefore(60)
|
||||
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt })).toEqual(['salto-de-dois-niveis'])
|
||||
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt, triangulatedShock: true })).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
it('protects a fresh level from being undone, but only downwards', () => {
|
||||
const levelChangedAt = daysBefore(ENGAGEMENT_LEVEL_RULES.protectionWindowDays - 1)
|
||||
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt })).toContain('janela-de-protecao')
|
||||
expect(violationIds({ from: 'n3', to: 'n4', levelChangedAt })).not.toContain(
|
||||
'janela-de-protecao',
|
||||
)
|
||||
})
|
||||
|
||||
it('lets a rebaixamento through once the window closed and the month turned', () => {
|
||||
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt: daysBefore(40) })).toEqual([])
|
||||
})
|
||||
|
||||
it('counts by calendar month, so a movement early in the month still blocks a second one', () => {
|
||||
// 26 days is past the 21-day protection window, and the month rule is what
|
||||
// is left holding it — the two rules are not the same rule.
|
||||
expect(
|
||||
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-07-01T09:00:00.000Z' }),
|
||||
).toEqual(['dois-movimentos-no-mes'])
|
||||
expect(
|
||||
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-06-30T09:00:00.000Z' }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reports every reason at once so the coordinator overrides them knowingly', () => {
|
||||
expect(violationIds({ from: 'n4', to: 'n2', levelChangedAt: daysBefore(2) })).toEqual([
|
||||
'salto-de-dois-niveis',
|
||||
'janela-de-protecao',
|
||||
'dois-movimentos-no-mes',
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a movement to the level already recorded', () => {
|
||||
expect(violationIds({ from: 'n2', to: 'n2', levelChangedAt: daysBefore(1) })).toEqual([])
|
||||
})
|
||||
|
||||
it('survives an unparseable stored timestamp instead of inventing a violation', () => {
|
||||
expect(violationIds({ from: 'n2', to: 'n1', levelChangedAt: 'nao-e-uma-data' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -183,6 +183,72 @@ describe('E10 classe territorial filter (derived, not a Payload constraint)', ()
|
|||
})
|
||||
})
|
||||
|
||||
describe('E14 nível de envolvimento (stored: sort and filter both reach the query)', () => {
|
||||
it('opens the nivel sort on N4 (desc default) and names the direction', () => {
|
||||
const base = parseMunicipalityListParams({})
|
||||
expect(buildMunicipalitySortHref(base, 'nivel')).toBe('/campanha/municipios?sort=nivel')
|
||||
const byLevel = parseMunicipalityListParams({ sort: 'nivel' })
|
||||
expect(resolveMunicipalityListSort(byLevel)).toEqual({ sort: 'nivel', dir: 'desc' })
|
||||
expect(formatMunicipalityListSortSummary('nivel', 'desc')).toBe(
|
||||
'Ordenado por nível (N4 primeiro)',
|
||||
)
|
||||
expect(formatMunicipalityListSortSummary('nivel', 'asc')).toBe(
|
||||
'Ordenado por nível (N0 primeiro)',
|
||||
)
|
||||
})
|
||||
|
||||
it('constrains the query by the selected levels', () => {
|
||||
const state = parseMunicipalityListParams({ level: ['n3', 'n4', 'n3'] })
|
||||
expect(state.levels).toEqual(['n3', 'n4'])
|
||||
expect(isMunicipalityColumnFilterActive(state, 'level')).toBe(true)
|
||||
expect(buildMunicipalityListWhere(state)).toEqual({
|
||||
and: [{ engagementLevel: { in: ['n3', 'n4'] } }],
|
||||
})
|
||||
expect(buildMunicipalityFilterHref(state)).toBe('/campanha/municipios?level=n3&level=n4')
|
||||
})
|
||||
|
||||
/**
|
||||
* "Sem nível" is the triage queue, so it must be selectable — and absence
|
||||
* can't ride inside an `in`, which is why it becomes its own OR branch.
|
||||
*/
|
||||
it('expresses "sem nível" as absence, alone or beside real levels', () => {
|
||||
expect(buildMunicipalityListWhere(parseMunicipalityListParams({ level: 'sem_nivel' }))).toEqual(
|
||||
{ and: [{ engagementLevel: { exists: false } }] },
|
||||
)
|
||||
expect(
|
||||
buildMunicipalityListWhere(parseMunicipalityListParams({ level: ['n0', 'sem_nivel'] })),
|
||||
).toEqual({
|
||||
and: [{ or: [{ engagementLevel: { in: ['n0'] } }, { engagementLevel: { exists: false } }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('discards unknown levels and treats "todos" as absent', () => {
|
||||
expect(parseMunicipalityListParams({ level: 'n9' }).levels).toBeUndefined()
|
||||
|
||||
const all = parseMunicipalityListParams({
|
||||
level: ['n0', 'n1', 'n2', 'n3', 'n4', 'sem_nivel'],
|
||||
})
|
||||
expect(all.levels).toBeUndefined()
|
||||
expect(buildMunicipalityListWhere(all)).toEqual({})
|
||||
expect(isMunicipalityColumnFilterActive(all, 'level')).toBe(false)
|
||||
|
||||
const allButOne = parseMunicipalityListParams({
|
||||
level: ['n0', 'n1', 'n2', 'n3', 'n4'],
|
||||
})
|
||||
expect(
|
||||
toggleMunicipalityMultiFilterValue(allButOne, 'level', 'sem_nivel').levels,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('names the level in the active-filters summary', () => {
|
||||
expect(
|
||||
formatMunicipalityActiveFiltersSummary(
|
||||
parseMunicipalityListParams({ level: ['n4', 'sem_nivel'] }),
|
||||
),
|
||||
).toBe('Nível N4, sem nível')
|
||||
})
|
||||
})
|
||||
|
||||
describe('municipality list header filters (B16+)', () => {
|
||||
it('toggles multi region OR filters and preserves sort/dir', () => {
|
||||
const state = parseMunicipalityListParams({
|
||||
|
|
@ -394,6 +460,18 @@ describe('buildMunicipalityFilterOptionHref (B16+ fast path)', () => {
|
|||
param: 'trend',
|
||||
value: 'neutra',
|
||||
},
|
||||
{
|
||||
name: 'adds "sem nível" beside a level',
|
||||
state: parseMunicipalityListParams({ level: ['n0'] }),
|
||||
param: 'level',
|
||||
value: 'sem_nivel',
|
||||
},
|
||||
{
|
||||
name: 'collapses the full level set to "todos"',
|
||||
state: parseMunicipalityListParams({ level: ['n0', 'n1', 'n2', 'n3', 'n4'] }),
|
||||
param: 'level',
|
||||
value: 'sem_nivel',
|
||||
},
|
||||
]
|
||||
|
||||
it.each(cases)('matches the canonical toggle+href path: $name', ({ state, param, value }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue