From 0aa47f8673e99157a50ddeac7fedb13cd695b253 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:29:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(campanha):=20B147=20munic=C3=ADpio=20v2=20?= =?UTF-8?q?shell=20+=20faixa=20de=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rota paralela /campanha/municipio//v2 com selects de nível, tendência e sinal (modal de motivo opcional), classe territorial e agregado. Soft-dep B134: motivo opcional no movimento de nível e remoção do campo “voltar atrás” do write path/lista. Co-authored-by: Francisco Solla --- docs/plans/municipio-v2-shell-status-impl.md | 107 ++++ .../(app)/municipio/[slug]/v2/formActions.ts | 35 ++ .../(app)/municipio/[slug]/v2/page.tsx | 70 +++ .../campanha/actions/municipality.ts | 11 +- .../MunicipalityListLevelControl.tsx | 30 +- .../MunicipalityV2StatusReasonDialog.tsx | 170 +++++++ .../MunicipalityV2StatusStrip.tsx | 467 ++++++++++++++++++ src/lib/campaignPageChrome.ts | 5 + src/lib/schemas/municipality.ts | 9 +- src/lib/schemas/municipalityUpdate.ts | 3 +- .../municipality/municipalityRevalidation.ts | 3 + .../municipality/municipalityV2StatusData.ts | 78 +++ .../municipality/municipalityV2StatusView.ts | 116 +++++ ...ignMunicipalityEngagementLevel.int.spec.ts | 32 +- .../campaignMunicipalityUpdate.int.spec.ts | 7 + .../municipalityV2StatusView.unit.spec.ts | 64 +++ 16 files changed, 1163 insertions(+), 44 deletions(-) create mode 100644 docs/plans/municipio-v2-shell-status-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2StatusStrip.tsx create mode 100644 src/utilities/municipality/municipalityV2StatusData.ts create mode 100644 src/utilities/municipality/municipalityV2StatusView.ts create mode 100644 tests/unit/municipalityV2StatusView.unit.spec.ts diff --git a/docs/plans/municipio-v2-shell-status-impl.md b/docs/plans/municipio-v2-shell-status-impl.md new file mode 100644 index 00000000..89bcc5b6 --- /dev/null +++ b/docs/plans/municipio-v2-shell-status-impl.md @@ -0,0 +1,107 @@ +# Impl: Município v2 — shell + faixa de status + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #330 +Intenção: docs/plans/municipio-v2-shell-status.md +Appetite restante: ~1–1,5 dia eng (herdado) + +## Leitura da intenção + +- **Outcome:** rota paralela `/campanha/municipio//v2` com faixa de status operável (nível · tendência · sinal · classe · silêncio/frescor · agregado de notas); select abre modal de motivo opcional; confirmação grava; rota antiga intacta; leader lockdown. +- **O que NÃO negociar:** leader fora; auto-save no select; motivo obrigatório; hero nome/TI no corpo; painel de ajuda inline; padrão distinto para sinal; expor estimado; matar `/municipios/[slug]`. +- **O que reavaliar:** “nova rota + composição municipality/” (ok); hipótese de só reusar loaders vs extrair view model de status; soft-dep B134 (schema de nível ainda exige motivo + reversalSignals). + +## Abordagem recomendada + +```mermaid +flowchart LR + Page["municipio/[slug]/v2/page.tsx"] + Loader["loadMunicipalityV2StatusData"] + Strip["MunicipalityV2StatusStrip"] + Modal["MunicipalityV2StatusReasonDialog"] + LevelAPI["POST …/engagement-level"] + TrendAPI["POST …/political-trend"] + SignalFA["createMunicipalityListSignalFormAction"] + Page --> Loader --> Strip + Strip --> Modal + Modal --> LevelAPI + Modal --> TrendAPI + Modal --> SignalFA +``` + +**Opções consideradas:** + +| | A — Thin page + extract shared list controls into strip | B — V2-owned status strip + shared optional-reason dialog; reuse write endpoints | C — Full dual-path ops-sync surface for v2 | +| --- | ------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ | +| | Reusa UI da lista | UI nova densa; writes iguais | Prematuro (OH cutover) | + +**Recomendação:** B — because the list cells are Popover/Sheet autosave (trend) or multi-field E14 popover (level), which violate the product rite (select → optional reason modal → confirm). A new strip that calls the same endpoints keeps one write policy without twinning actions. + +**Rejeitadas:** A because list UX ≠ select+modal (would force a second mode into list cells or ship the wrong rite). C because B147 is shell-only and appetite forbids dual-path. + +### Decisões de engenharia + +1. **Rota singular nova** `src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx` + Opções: sob `municipios/…/v2` | singular `municipio/…/v2` | query `?v=2`. + **Rec:** singular — matches locked product URL. Rejected: plural (breaks contract); query (pollutes old detail). + +2. **Dados da faixa** — thin loader `loadMunicipalityV2StatusData` composing existing detail VM + territorial class + last typed signal preview. + Opções: only `getMunicipalityDetailViewModel` | new loader | dossier loader. + **Rec:** new thin composer over `resolveAccessibleMunicipalityContext` + strategy fields + `computeMunicipalityTerritorialClass` + latest `sinal` update (for type/note aggregate). Rejected: dossier (heavy); detail-only (no last signal type). + +3. **Motivo opcional no nível (soft-dep B134)** — relax engagement write schema so empty note OK; stop requiring `reversalSignals` on new writes (omit/empty in snapshot). Align list level control to the same rite (optional note only; drop reversal field from UI). + Opções: leave schema + break “vazio OK” | v2-only bypass with placeholder text | relax schema + update list owner in this PR. + **Rec:** third — product acceptance requires empty OK; edit the owner (schema + list control + action). Historical snapshot scrub / wizard trend step remain on #288 (B134). No Payload migration: `reversalSignals` lives only in `allocationDecision.snapshot` JSON. + +4. **Sinal frio (questão aberta da intenção)** — assume A: select value = sentinel “Sem sinal / frio” when cold; age in aggregate/tooltip. + Threshold: `MUNICIPALITY_COLD_SIGNAL_DAYS` (21) from `municipalitySignal.ts` (lista). + +5. **Chrome / nome** — no body hero. Path rule → section title `Municípios`; `generateMetadata` uses municipality name. B145 entity-title-in-header stays soft-dep (no twin). + +6. **Nível só unrestricted** — strip shows level as readout for advisors; selects for trend/signal for all staff. Gate mirrors list (`canMoveEngagementLevel`). + +### Componentes / mudanças + +- **`MunicipalityV2Page`** (`src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx`): RSC; `requireCampaignPageActor({ gate: 'noLeader' })`; status strip; empty slots for conta/rede/agora (children). +- **`loadMunicipalityV2StatusData`** (`src/utilities/municipality/municipalityV2StatusData.ts`): server-only composer. +- **`buildMunicipalityV2StatusAggregate`** (`src/lib/municipalityV2Status.ts`): pure aggregate text + cold sentinel helpers (unit-tested). +- **`MunicipalityV2StatusStrip`** + **`MunicipalityV2StatusReasonDialog`** (`src/components/campaign/municipality/`): client strip + shared Dialog (motivo opcional). +- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; `reversalSignals` optional/default `''`; list control drops reversal field; int tests updated. +- **Chrome:** path rule `/campanha/municipio/[^/]+/v2`. +- **Form action:** reuse `createMunicipalityListSignalFormAction` (or thin v2 wrapper colocated under the new route). +- **Migration:** nenhuma. +- **Access / Consent:** existing municipality access; no Consent; leader blocked by page gate. +- **UI:** Impeccable C — shape → craft → critique → polish; tokens `data-theme='campaign'`; `CampaignHoverTooltip` + `campaignConceptHref` / `campaignConceptOneLiner`. + +### Dados → forma + +- Forma: dense control strip (native selects + readouts), not cards/KPIs — qualitative status for CG decisions. Rejected: card tower (old overview); pill cluster for signal (product locked select parity). + +## Fases verificáveis + +1. **Tracer / server** — schema note optional + reversal optional; loader + pure aggregate; page RSC with strip read-only first; unit tests for aggregate/cold. +2. **UI** — three selects + modal + writes + tooltips + class/silence; placeholders for children sections. +3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Conta / rede / agora / FAB / cutover (B148–B152). +- Wizard de tendência (resto B134). +- Scrub histórico de `reversalSignals` em snapshots. +- Dual-path / ops-sync. +- Redesign hysteresis UI beyond override/shock checkboxes already in list (keep minimal in modal when violations apply). + +## Riscos e mitigação + +- **B134 race:** #288 blocked; we land write-policy subset needed for aceite; comment on #288 that schema/list already optional. +- **Hysteresis in modal:** keep override + triangulatedShock checkboxes when violations present (same as list), else confirm is one-tap. +- **Signal body required by schema for non-semanal:** modal “motivo” maps to `body`; empty fails today — relax sinal body to optional in create schema when kind=sinal (motivo opcional), keeping signalType required. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio: unit aggregate/cold; int engagement level empty note; list control still submits + +Self-score decision-quality: 4/5 diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts new file mode 100644 index 00000000..d41d55d1 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts @@ -0,0 +1,35 @@ +'use server' + +import { createMunicipalityUpdate } from '@/app/(campaign)/campanha/actions/municipalityUpdate' +import { + optionalFormText, + optionalMunicipalitySlugFromForm, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' + +const SIGNAL_SAVED_MESSAGE = 'Sinal registrado.' + +export const createMunicipalityV2SignalFormAction = async ( + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const municipality = requiredRelationshipFormValue(formData, 'municipalityId') + + await createMunicipalityUpdate({ + municipality, + kind: 'sinal', + body: optionalFormText(formData, 'body'), + signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')), + }) + revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) }) + return { message: SIGNAL_SAVED_MESSAGE } + }, + genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx new file mode 100644 index 00000000..070b1e2e --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx @@ -0,0 +1,70 @@ +import config from '@payload-config' +import { notFound } from 'next/navigation' +import { getPayload } from 'payload' + +import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions' +import { MunicipalityV2StatusStrip } from '@/components/campaign/municipality/MunicipalityV2StatusStrip' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' +import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData' +import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData' + +type MunicipalityV2PageProps = { + params: Promise<{ slug: string }> +} + +export async function generateMetadata({ params }: MunicipalityV2PageProps) { + const { slug } = await params + const entry = getMunicipalityCatalogEntry(slug) + // Soft-dep B145: entity title belongs in chrome; metadata carries the name until then. + return campaignPageMetadata({ title: entry?.name ?? 'Município' }) +} + +export default async function MunicipalityV2Page({ params }: MunicipalityV2PageProps) { + const { slug } = await params + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'noLeader' }), + getPayload({ config }), + ]) + + let status + try { + ;({ status } = await loadMunicipalityV2StatusData(payload, user, slug)) + } catch (error) { + if (error instanceof MunicipalityNotFoundError) notFound() + throw error + } + + return ( + +
+ + + {/* Placeholders for B148–B150 — keep the vertical rhythm without shipping twin content. */} +
+

+ Conta local +

+

Em breve nesta visão.

+
+
+

+ Rede +

+

Em breve nesta visão.

+
+
+

+ Agora +

+

Em breve nesta visão.

+
+
+
+ ) +} diff --git a/src/app/(campaign)/campanha/actions/municipality.ts b/src/app/(campaign)/campanha/actions/municipality.ts index 229d1ef8..c356148c 100644 --- a/src/app/(campaign)/campanha/actions/municipality.ts +++ b/src/app/(campaign)/campanha/actions/municipality.ts @@ -149,8 +149,12 @@ export const setMunicipalityEngagementLevelRecord = async ( actor: CampaignUser, input: MunicipalityEngagementLevelInput, ) => { - const { municipality, level, note, reversalSignals, triangulatedShock, override } = + const { municipality, level, note, triangulatedShock, override } = municipalityEngagementLevelSchema.parse(input) + const levelNote = note ?? null + // allocationDecision.rationale is required; empty Motivo still leaves an + // auditable row without inventing coordinator prose. + const rationale = levelNote ?? 'Sem motivo registrado.' return withPayloadTransaction( payload, @@ -190,7 +194,7 @@ export const setMunicipalityEngagementLevelRecord = async ( const updated = await payload.update({ collection: 'municipality', id: municipality, - data: { engagementLevel: level, levelNote: note }, + data: { engagementLevel: level, levelNote }, depth: 0, user: currentActor, overrideAccess: false, @@ -203,13 +207,12 @@ export const setMunicipalityEngagementLevelRecord = async ( municipality, patternId: ENGAGEMENT_LEVEL_PATTERN_ID, outcome: 'movimento', - rationale: note, + rationale, // 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, diff --git a/src/components/campaign/municipality/MunicipalityListLevelControl.tsx b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx index c40cd2de..6c56b1d4 100644 --- a/src/components/campaign/municipality/MunicipalityListLevelControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListLevelControl.tsx @@ -45,9 +45,8 @@ type MunicipalityListLevelControlProps = { /** * E14 — unlike the trend and estimate cells 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. + * explicitly: a movement may need an override / triangulated shock. Motivo is + * optional (B134/B147). Auto-saving would skip the confirmation rite. */ export const MunicipalityListLevelControl = ({ municipalityID, @@ -73,7 +72,6 @@ export const MunicipalityListLevelControl = ({ // with it the select — is not mounted. const [draftLevel, setDraftLevel] = useState('') const [note, setNote] = useState('') - const [reversalSignals, setReversalSignals] = useState('') const [triangulatedShock, setTriangulatedShock] = useState(false) const [override, setOverride] = useState(false) const [isPending, setIsPending] = useState(false) @@ -142,7 +140,6 @@ export const MunicipalityListLevelControl = ({ const resetDraft = () => { setDraftLevel(currentLevel ?? '') setNote('') - setReversalSignals('') setTriangulatedShock(false) setOverride(false) setErrorMessage(null) @@ -173,8 +170,7 @@ export const MunicipalityListLevelControl = ({ { municipalityId: municipalityID, level: draftLevel, - note, - reversalSignals, + note: note.trim() ? note : null, triangulatedShock, override, }, @@ -205,11 +201,7 @@ export const MunicipalityListLevelControl = ({ } } - const canSubmit = - isMovement && - note.trim().length > 0 && - reversalSignals.trim().length > 0 && - (violations.length === 0 || override) + const canSubmit = isMovement && (violations.length === 0 || override) const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}` const isSheet = variant === 'sheet' const submitButton = ( @@ -283,7 +275,7 @@ export const MunicipalityListLevelControl = ({ - Motivo + Motivo (opcional)