diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 13abb166..2569fe7a 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-02):** **B147** (Issue #330) — rota paralela `/campanha/municipio//v2` com faixa de status (nível · tendência · sinal · classe · frescor) e modal de motivo opcional; soft-dep B134 no write de nível (motivo opcional, sem “voltar atrás”). Intenção [`municipio-v2-shell-status.md`](docs/plans/municipio-v2-shell-status.md); impl [`municipio-v2-shell-status-impl.md`](docs/plans/municipio-v2-shell-status-impl.md). Filhos B148–B152. + **Recently resolved (2026-08-02):** **B127** (Issue #264) — barra omnibox de filtros no piloto Municípios + chassis `CampaignListOmnibox` (chips + sugestões; URL como fonte de verdade; cenário/sort como chips de apresentação). Planos: [`barra-filtros-omnibox-listas.md`](docs/plans/barra-filtros-omnibox-listas.md), impl [`barra-filtros-omnibox-listas-impl.md`](docs/plans/barra-filtros-omnibox-listas-impl.md). Adoção nas demais listas = B128. **Recently resolved (2026-08-02):** **Skills plan/work reescritas** — `plan-issue` agora só gera planos de **intenção** (persona/fluxo/aceite + direção suave no código; sem decisões duras de engenharia). Execução: `agent-work-issue` (pool — Plan mode → `*-impl.md` → executa → `/simplify` → `capture-review-debts` autônomo → PR Ready + auto-merge) e `work-issue` (humano — claim + **pausa** após o impl plan). Prompt do pool aponta para `agent-work-issue`. 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..fc32ac9c --- /dev/null +++ b/docs/plans/municipio-v2-shell-status-impl.md @@ -0,0 +1,108 @@ +# 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/utilities/municipality/municipalityV2StatusView.ts`): pure aggregate text + cold sentinel helpers (unit-tested). Client-safe utilities (depends on `municipalitySignal`). +- **`MunicipalityV2StatusStrip`** + **`MunicipalityV2StatusReasonDialog`** (`src/components/campaign/municipality/`): client strip + shared Dialog (motivo opcional). +- **Schema/action/list level:** `municipalityEngagementLevelSchema` note → optional; drop `reversalSignals` from write; list control drops reversal field; int tests updated. +- **Chrome:** path rule `/campanha/municipio/[^/]+/v2`. +- **Form action:** thin alias over `createMunicipalityListSignalFormAction`. +- **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). +- Extrair helper compartilhado `confirmLevel`/`ListLevelControl.submit` — 2 call sites; gatilho: 3º writer do mesmo endpoint+blocked path. + +## 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/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index a360fb84..4ad88700 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -27,6 +27,7 @@ export const E2E_AFFECTED_MANIFEST = [ { prefixes: [ `${CAMPAIGN_APP}/municipios`, + `${CAMPAIGN_APP}/municipio`, 'src/components/campaign/municipality', 'src/components/campaign/map', 'src/components/campaign/shared/CampaignListOmnibox', 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..8d4d57f6 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions.ts @@ -0,0 +1,9 @@ +'use server' + +import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +/** Thin alias — same write path as the list signal control (B147). */ +export const createMunicipalityV2SignalFormAction = async ( + formData: FormData, +): Promise => createMunicipalityListSignalFormAction({}, formData) 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/collections/MunicipalityUpdate.ts b/src/collections/MunicipalityUpdate.ts index 8cf50667..d9d98b7c 100644 --- a/src/collections/MunicipalityUpdate.ts +++ b/src/collections/MunicipalityUpdate.ts @@ -52,7 +52,7 @@ const validateMunicipalityUpdateKind: CollectionBeforeValidateHook = ({ if (!nonEmptyText(nextData.failed)) throw new APIError('Informe o que não funcionou.', 400) if (!nonEmptyText(nextData.needs)) throw new APIError('Informe o que você precisa.', 400) } else if (kind === 'sinal') { - if (!nonEmptyText(nextData.body)) throw new APIError('Informe o texto da atualização.', 400) + // Motivo/body is optional (B147/B134); type is the required discriminator. if (typeof nextData.signalType !== 'string') { throw new APIError('Informe o tipo do sinal.', 400) } diff --git a/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx b/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx new file mode 100644 index 00000000..9a8275c5 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2StatusReasonDialog.tsx @@ -0,0 +1,169 @@ +'use client' + +import Link from 'next/link' +import { useId, useState } from 'react' + +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/Checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Field, FieldContent, FieldLabel } from '@/components/ui/field' +import { Spinner } from '@/components/ui/Spinner' +import { Textarea } from '@/components/ui/textarea' +import type { EngagementLevelViolation } from '@/lib/engagementLevel' +import { ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH } from '@/lib/engagementLevel' + +export type MunicipalityV2StatusReasonDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + reasonLabel?: string + reasonPlaceholder?: string + confirmLabel?: string + isPending?: boolean + errorMessage?: string | null + /** Level-only: show triangulated-shock checkbox. */ + showTriangulatedShock?: boolean + triangulatedShock?: boolean + onTriangulatedShockChange?: (value: boolean) => void + violations?: EngagementLevelViolation[] + override?: boolean + onOverrideChange?: (value: boolean) => void + onConfirm: (reason: string) => void +} + +/** + * Shared optional-motivo confirm for nível / tendência / sinal on the v2 strip. + * Empty reason is OK — product rite from B134/B147. + */ +export const MunicipalityV2StatusReasonDialog = ({ + open, + onOpenChange, + title, + description, + reasonLabel = 'Motivo (opcional)', + reasonPlaceholder = 'Contexto para esta mudança, se quiser registrar.', + confirmLabel = 'Confirmar', + isPending = false, + errorMessage = null, + showTriangulatedShock = false, + triangulatedShock = false, + onTriangulatedShockChange, + violations = [], + override = false, + onOverrideChange, + onConfirm, +}: MunicipalityV2StatusReasonDialogProps) => { + const id = useId() + const [reason, setReason] = useState('') + const canConfirm = violations.length === 0 || override + + const handleOpenChange = (next: boolean) => { + if (!next) setReason('') + onOpenChange(next) + } + + return ( + + + + {title} + {description} + +
+ + {reasonLabel} +