Merge pull request #340 from fsolla/cursor/pool-i330-633ff101-a548

B147 — Município v2 — shell + faixa de status
This commit is contained in:
Francisco Solla 2026-08-02 21:05:36 -03:00 committed by GitHub
commit ce0dcdb1cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1141 additions and 2 deletions

View file

@ -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/<slug>/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`.

View file

@ -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/<slug>/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

View file

@ -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',

View file

@ -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<CampaignFormActionState> => createMunicipalityListSignalFormAction({}, formData)

View file

@ -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 (
<CampaignPageShell>
<div className="flex flex-col gap-8">
<MunicipalityV2StatusStrip
status={status}
signalFormAction={createMunicipalityV2SignalFormAction}
/>
{/* Placeholders for B148–B150 — keep the vertical rhythm without shipping twin content. */}
<section aria-labelledby="municipio-v2-conta-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-conta-title" className="text-base font-medium text-muted-foreground">
Conta local
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
Rede
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
Agora
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
</div>
</CampaignPageShell>
)
}

View file

@ -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)
}

View file

@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`${id}-reason`}>{reasonLabel}</FieldLabel>
<Textarea
id={`${id}-reason`}
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
placeholder={reasonPlaceholder}
disabled={isPending}
/>
</Field>
{showTriangulatedShock ? (
<Field orientation="horizontal" className="min-h-11">
<Checkbox
id={`${id}-shock`}
checked={triangulatedShock}
onCheckedChange={(checked) => onTriangulatedShockChange?.(checked === true)}
disabled={isPending}
/>
<FieldContent>
<FieldLabel htmlFor={`${id}-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" className="min-h-11">
<Checkbox
id={`${id}-override`}
checked={override}
onCheckedChange={(checked) => onOverrideChange?.(checked === true)}
disabled={isPending}
/>
<FieldContent>
<FieldLabel htmlFor={`${id}-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}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => handleOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
disabled={!canConfirm || isPending}
onClick={() => onConfirm(reason)}
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{confirmLabel}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Conceitos:{' '}
<Link href="/campanha/conceitos" className="underline underline-offset-2">
glossário da campanha
</Link>
.
</p>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,467 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import type { MunicipalityListEngagementLevelResponse } from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import type { MunicipalityListPoliticalTrendResponse } from '@/app/(campaign)/campanha/(app)/municipios/political-trend/types'
import { MunicipalityV2StatusReasonDialog } from '@/components/campaign/municipality/MunicipalityV2StatusReasonDialog'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip'
import { campaignConceptHref, campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
ENGAGEMENT_LEVEL_RULES,
engagementLevelRank,
engagementLevels,
formatEngagementLevelLabel,
getEngagementLevelViolations,
isEngagementLevel,
type EngagementLevel,
type EngagementLevelViolation,
} from '@/lib/engagementLevel'
import {
parsePoliticalTrendStatusFormValue,
politicalTrendStatuses,
} from '@/lib/schemas/municipality'
import {
municipalitySignalTypeLabels,
municipalitySignalTypes,
parseMunicipalitySignalType,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatTerritorialClassWhy,
politicalTrendLabels,
territorialClassBadgeVariant,
territorialClassLabels,
} from '@/utilities/municipality/municipalityLabels'
import { formatMunicipalitySignalAgeLabel } from '@/utilities/municipality/municipalitySignal'
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
type MunicipalityV2StatusViewModel,
} from '@/utilities/municipality/municipalityV2StatusView'
const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
const POLITICAL_TREND_ENDPOINT = '/campanha/municipios/political-trend'
const LEVEL_ERROR = 'Não foi possível registrar o nível. Tente novamente.'
const TREND_ERROR = 'Não foi possível salvar a tendência. Tente novamente.'
const SIGNAL_ERROR = 'Não foi possível registrar o sinal. Tente novamente.'
type PendingAxis = 'level' | 'trend' | 'signal' | null
type MunicipalityV2StatusStripProps = {
status: MunicipalityV2StatusViewModel
signalFormAction: (formData: FormData) => Promise<CampaignFormActionState>
}
const ConceptTooltip = ({
conceptId,
label,
children,
}: {
conceptId: 'nivel-de-envolvimento' | 'classe-territorial' | 'pauta-do-silencio'
label: string
children: ReactNode
}) => (
<CampaignHoverTooltip
side="bottom"
align="start"
content={
<div className="flex max-w-64 flex-col gap-2 text-sm">
<p>{campaignConceptOneLiner(conceptId)}</p>
<Link
href={campaignConceptHref(conceptId)}
className="font-medium underline underline-offset-2"
>
Ver em Conceitos
</Link>
</div>
}
>
<button
type="button"
aria-label={`${label}: mais informações`}
className={cn(
'rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring',
campaignHoverExplanationClassName,
)}
>
{children}
</button>
</CampaignHoverTooltip>
)
export const MunicipalityV2StatusStrip = ({
status,
signalFormAction,
}: MunicipalityV2StatusStripProps) => {
const router = useRouter()
const formId = useId()
const abortRef = useRef<AbortController | null>(null)
const [level, setLevel] = useState<EngagementLevel | null>(status.engagementLevel)
const [levelNote, setLevelNote] = useState(status.levelNote)
const [levelChangedAt, setLevelChangedAt] = useState(status.levelChangedAt)
const [trendStatus, setTrendStatus] = useState(status.politicalTrendStatus)
const [trendNote, setTrendNote] = useState(status.politicalTrendNote)
const [signalType, setSignalType] = useState(status.lastSignalType)
const [signalBody, setSignalBody] = useState(status.lastSignalBody)
const [lastSignalAt, setLastSignalAt] = useState(status.lastSignalAt)
const [pendingAxis, setPendingAxis] = useState<PendingAxis>(null)
const [draftLevel, setDraftLevel] = useState<EngagementLevel | null>(null)
const [draftTrend, setDraftTrend] = useState<typeof trendStatus>(null)
const [draftSignal, setDraftSignal] = useState<MunicipalitySignalType | null>(null)
const [triangulatedShock, setTriangulatedShock] = useState(false)
const [override, setOverride] = useState(false)
const [serverBlock, setServerBlock] = useState<EngagementLevelViolation[] | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
useEffect(() => () => abortRef.current?.abort(), [])
// Adopt RSC refresh values when the server revalidates after a write.
useEffect(() => {
setLevel(status.engagementLevel)
setLevelNote(status.levelNote)
setLevelChangedAt(status.levelChangedAt)
setTrendStatus(status.politicalTrendStatus)
setTrendNote(status.politicalTrendNote)
setSignalType(status.lastSignalType)
setSignalBody(status.lastSignalBody)
setLastSignalAt(status.lastSignalAt)
}, [status])
const signalSelect = resolveMunicipalityV2SignalSelectState({
signalType,
lastSignalAt,
})
const aggregate = buildMunicipalityV2StatusAggregate({
levelNote,
trendNote,
signalBody,
signalType,
lastSignalAt,
engagementLevel: level,
})
const levelViolations =
pendingAxis === 'level' && draftLevel
? [
...getEngagementLevelViolations({
from: level,
to: draftLevel,
levelChangedAt,
now: new Date(),
triangulatedShock,
}),
...(serverBlock ?? []),
]
: []
const uniqueLevelViolations = levelViolations.filter(
(violation, index, list) => list.findIndex((item) => item.id === violation.id) === index,
)
const isJump =
pendingAxis === 'level' &&
draftLevel !== null &&
level !== null &&
Math.abs(engagementLevelRank[draftLevel] - engagementLevelRank[level]) >
ENGAGEMENT_LEVEL_RULES.maxStepsWithoutShock
const closeDialog = () => {
setPendingAxis(null)
setDraftLevel(null)
setDraftTrend(null)
setDraftSignal(null)
setTriangulatedShock(false)
setOverride(false)
setServerBlock(null)
setErrorMessage(null)
}
const confirmLevel = async (reason: string) => {
if (!draftLevel || isPending) return
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
setServerBlock(null)
try {
const { payload } = await postCampaignJson<MunicipalityListEngagementLevelResponse>(
ENGAGEMENT_LEVEL_ENDPOINT,
{
municipalityId: status.id,
level: draftLevel,
note: reason.trim() ? reason : null,
triangulatedShock,
override,
},
controller.signal,
)
if (payload.status === 'success') {
setLevel(payload.savedLevel.level)
setLevelNote(payload.savedLevel.note)
setLevelChangedAt(payload.savedLevel.changedAt)
closeDialog()
router.refresh()
return
}
if (payload.status === 'blocked') {
setServerBlock(payload.violations)
return
}
setErrorMessage(payload.message || LEVEL_ERROR)
} catch {
if (controller.signal.aborted) return
setErrorMessage(LEVEL_ERROR)
} finally {
setIsPending(false)
}
}
const confirmTrend = async (reason: string) => {
if (pendingAxis !== 'trend' || isPending) return
const controller = new AbortController()
abortRef.current = controller
setIsPending(true)
setErrorMessage(null)
try {
const { payload } = await postCampaignJson<MunicipalityListPoliticalTrendResponse>(
POLITICAL_TREND_ENDPOINT,
{
municipalityId: status.id,
status: draftTrend,
note: reason.trim() ? reason : null,
},
controller.signal,
)
if (payload.status === 'success') {
setTrendStatus(payload.savedTrend.status)
setTrendNote(payload.savedTrend.note)
closeDialog()
router.refresh()
return
}
setErrorMessage(payload.message || TREND_ERROR)
} catch {
if (controller.signal.aborted) return
setErrorMessage(TREND_ERROR)
} finally {
setIsPending(false)
}
}
const confirmSignal = async (reason: string) => {
if (!draftSignal || isPending) return
setIsPending(true)
setErrorMessage(null)
try {
const formData = new FormData()
formData.set('municipalityId', String(status.id))
formData.set('municipalitySlug', status.slug)
formData.set('signalType', draftSignal)
if (reason.trim()) formData.set('body', reason.trim())
const result = await signalFormAction(formData)
if (result.status !== 'success') {
setErrorMessage(result.message || SIGNAL_ERROR)
return
}
setSignalType(draftSignal)
setSignalBody(reason.trim() ? reason.trim() : null)
setLastSignalAt(new Date().toISOString())
closeDialog()
router.refresh()
} catch {
setErrorMessage(SIGNAL_ERROR)
} finally {
setIsPending(false)
}
}
const dialogTitle =
pendingAxis === 'level'
? 'Confirmar nível de envolvimento'
: pendingAxis === 'trend'
? 'Confirmar tendência'
: pendingAxis === 'signal'
? 'Confirmar sinal'
: ''
const dialogDescription =
pendingAxis === 'level' && draftLevel
? formatEngagementLevelLabel(draftLevel)
: pendingAxis === 'trend'
? draftTrend
? politicalTrendLabels[draftTrend]
: 'Não registrada'
: pendingAxis === 'signal' && draftSignal
? municipalitySignalTypeLabels[draftSignal]
: ''
return (
<section aria-labelledby={`${formId}-status-title`} className="flex flex-col gap-3">
<h2 id={`${formId}-status-title`} className="text-base font-medium">
Status
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field>
<ConceptTooltip conceptId="nivel-de-envolvimento" label="Nível">
<FieldLabel htmlFor={`${formId}-level`}>Nível</FieldLabel>
</ConceptTooltip>
{status.canMoveEngagementLevel ? (
<NativeSelect
id={`${formId}-level`}
className="min-h-11 w-full"
value={level ?? ''}
aria-busy={isPending && pendingAxis === 'level' ? true : undefined}
onChange={(event) => {
const next = event.target.value
if (!isEngagementLevel(next) || next === level) {
// Reset select to current when choosing empty / same.
event.target.value = level ?? ''
return
}
setDraftLevel(next)
setPendingAxis('level')
setTriangulatedShock(false)
setOverride(false)
setServerBlock(null)
setErrorMessage(null)
}}
>
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
{engagementLevels.map((option) => (
<NativeSelectOption key={option} value={option}>
{formatEngagementLevelLabel(option)}
</NativeSelectOption>
))}
</NativeSelect>
) : (
<p id={`${formId}-level`} className="flex min-h-11 items-center text-sm">
{level ? formatEngagementLevelLabel(level) : EMPTY_ENGAGEMENT_LEVEL_LABEL}
</p>
)}
</Field>
<Field>
<FieldLabel htmlFor={`${formId}-trend`}>Tendência</FieldLabel>
<NativeSelect
id={`${formId}-trend`}
className="min-h-11 w-full"
value={trendStatus ?? ''}
aria-busy={isPending && pendingAxis === 'trend' ? true : undefined}
onChange={(event) => {
const next = parsePoliticalTrendStatusFormValue(event.target.value)
if (next === trendStatus) return
setDraftTrend(next)
setPendingAxis('trend')
setErrorMessage(null)
}}
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{politicalTrendStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{politicalTrendLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<ConceptTooltip conceptId="pauta-do-silencio" label="Sinal">
<FieldLabel htmlFor={`${formId}-signal`}>Sinal</FieldLabel>
</ConceptTooltip>
<NativeSelect
id={`${formId}-signal`}
className="min-h-11 w-full"
value={signalSelect.value}
aria-busy={isPending && pendingAxis === 'signal' ? true : undefined}
onChange={(event) => {
const raw = event.target.value
if (raw === MUNICIPALITY_V2_SIGNAL_COLD_VALUE) {
event.target.value = signalSelect.value
return
}
const next = parseMunicipalitySignalType(raw)
if (!next || next === signalSelect.value) {
event.target.value = signalSelect.value
return
}
setDraftSignal(next)
setPendingAxis('signal')
setErrorMessage(null)
}}
>
{signalSelect.isCold ? (
<NativeSelectOption value={MUNICIPALITY_V2_SIGNAL_COLD_VALUE}>
{signalSelect.label}
</NativeSelectOption>
) : null}
{municipalitySignalTypes.map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalitySignalTypeLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<div className="flex flex-col gap-2">
<ConceptTooltip conceptId="classe-territorial" label="Classe territorial">
<span className="text-sm font-medium">Classe</span>
</ConceptTooltip>
<div className="flex min-h-11 flex-wrap items-center gap-2">
<Badge variant={territorialClassBadgeVariant[status.territorialClass.class]}>
{territorialClassLabels[status.territorialClass.class]}
</Badge>
<ConceptTooltip conceptId="pauta-do-silencio" label="Frescor">
<span className="text-xs text-muted-foreground">
{formatMunicipalitySignalAgeLabel(signalSelect.ageInDays)}
</span>
</ConceptTooltip>
</div>
<p className="text-xs text-muted-foreground">
{formatTerritorialClassWhy(status.territorialClass.factors)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground" data-slot="status-aggregate">
{aggregate}
</p>
<MunicipalityV2StatusReasonDialog
open={pendingAxis !== null}
onOpenChange={(open) => {
if (!open) {
closeDialog()
}
}}
title={dialogTitle}
description={dialogDescription}
isPending={isPending}
errorMessage={errorMessage}
showTriangulatedShock={Boolean(isJump)}
triangulatedShock={triangulatedShock}
onTriangulatedShockChange={setTriangulatedShock}
violations={uniqueLevelViolations}
override={override}
onOverrideChange={setOverride}
onConfirm={(reason) => {
if (pendingAxis === 'level') void confirmLevel(reason)
else if (pendingAxis === 'trend') void confirmTrend(reason)
else if (pendingAxis === 'signal') void confirmSignal(reason)
}}
/>
</section>
)
}

View file

@ -130,6 +130,11 @@ const pathRules: PathRule[] = [
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
},
{
// B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now.
match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
},
{
match: (pathname) => pathname === '/campanha/territorios',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.territorios),

View file

@ -83,7 +83,8 @@ export const municipalityUpdateCreateSchema = z
path: ['needs'],
})
}
} else if (!data.body) {
} else if (data.kind !== 'sinal' && !data.body) {
// Sinal inherits B147/B134 optional-motivo: type is enough; body/note is optional.
context.addIssue({
code: 'custom',
message: 'Informe o texto da atualização.',

View file

@ -27,9 +27,12 @@ export const revalidateMunicipalityListPaths = ({
if (scope === 'detail' || scope === 'both') {
if (detailSlug) {
revalidatePath(`/campanha/municipios/${detailSlug}`, 'page')
// B147 parallel v2 surface — same municipality, singular path.
revalidatePath(`/campanha/municipio/${detailSlug}/v2`, 'page')
return
}
revalidatePath('/campanha/municipios/[slug]', 'page')
revalidatePath('/campanha/municipio/[slug]/v2', 'page')
}
}

View file

@ -0,0 +1,78 @@
import 'server-only'
import type { Payload } from 'payload'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import {
getMunicipalityDetailViewModel,
resolveAccessibleMunicipalityContext,
type AccessibleMunicipalityContext,
} from '@/utilities/municipality/municipalityPageData'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import { loadMunicipalityUpdatesFeed } from '@/utilities/municipality/municipalityUpdatePageData'
import type { MunicipalityV2StatusViewModel } from '@/utilities/municipality/municipalityV2StatusView'
import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
import { aggregateMunicipalityPledgesFromRows } from '@/utilities/votePledgeViews'
export type { MunicipalityV2StatusViewModel }
const loadLatestSignal = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<{ type: MunicipalitySignalType | null; body: string | null }> => {
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, {
page: 1,
kind: 'sinal',
})
const latest = feed.updates[0]
if (!latest) return { type: null, body: null }
return { type: latest.signalType, body: latest.body }
}
export const loadMunicipalityV2StatusData = async (
payload: Payload,
user: CampaignUser,
municipalitySlug: string,
): Promise<{ context: AccessibleMunicipalityContext; status: MunicipalityV2StatusViewModel }> => {
const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
const [view, pledges, latestSignal] = await Promise.all([
getMunicipalityDetailViewModel(payload, context, user),
loadMunicipalityPledges(payload, user, context.id),
loadLatestSignal(payload, user, context.id),
])
const strategy = view.strategy
if (!strategy) {
// Page gate is noLeader; strategy should always be present for staff.
throw new Error('Municipality v2 status requires a staff strategy view model.')
}
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
const lastSignalAt = resolveMunicipalityLastSignalAt(
view.lastUpdateAt,
pledgeAggregate.lastPledgeAt,
)
return {
context,
status: {
id: view.id,
name: view.name,
slug: view.slug,
canMoveEngagementLevel: isCampaignUnrestricted(user),
engagementLevel: strategy.engagementLevel,
levelNote: strategy.levelNote,
levelChangedAt: strategy.levelChangedAt,
politicalTrendStatus: strategy.politicalTrend.status,
politicalTrendNote: strategy.politicalTrend.note,
lastSignalAt,
lastSignalType: latestSignal.type,
lastSignalBody: latestSignal.body,
territorialClass: computeMunicipalityTerritorialClass(view.slug),
},
}
}

View file

@ -0,0 +1,126 @@
/**
* Pure view helpers + status strip view model for municipality v2 (B147).
* Client-safe — no Payload / server-only imports.
*/
import {
EMPTY_ENGAGEMENT_LEVEL_LABEL,
formatEngagementLevelLabel,
type EngagementLevel,
} from '@/lib/engagementLevel'
import {
municipalitySignalTypeLabels,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
import type { PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels'
import {
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalitySignalAgeInDays,
} from '@/utilities/municipality/municipalitySignal'
import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass'
export type MunicipalityV2StatusViewModel = {
id: number
name: string
slug: string
canMoveEngagementLevel: boolean
engagementLevel: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
lastSignalAt: string | null
lastSignalType: MunicipalitySignalType | null
lastSignalBody: string | null
territorialClass: MunicipalityTerritorialClassification
}
/** Sentinel select value when the município is cold / has no typed signal. */
export const MUNICIPALITY_V2_SIGNAL_COLD_VALUE = '__cold__'
export type MunicipalityV2StatusNotes = {
levelNote: string | null
trendNote: string | null
signalBody: string | null
signalType: MunicipalitySignalType | null
lastSignalAt: string | null
engagementLevel: EngagementLevel | null
}
export type MunicipalityV2SignalSelectState = {
/** Current `<select>` value — signal type or cold sentinel. */
value: MunicipalitySignalType | typeof MUNICIPALITY_V2_SIGNAL_COLD_VALUE
/** Short label for the control (and cold readout). */
label: string
ageInDays: number | null
isCold: boolean
}
/**
* Product assumption (intention open Q): cold → sentinel “Sem sinal / frio”
* in the strip; age lives in aggregate/tooltip. Warm but typeless → “Sem tipo”
* without claiming frio (frescor can still be warm from notes/pledges).
*/
export const resolveMunicipalityV2SignalSelectState = (
input: {
signalType: MunicipalitySignalType | null
lastSignalAt: string | null
},
now: Date = new Date(),
): MunicipalityV2SignalSelectState => {
const ageInDays = municipalitySignalAgeInDays(input.lastSignalAt, now)
const isCold = isMunicipalitySignalCold(ageInDays)
if (isCold) {
const daysLabel =
ageInDays === null ? `frio (≥${MUNICIPALITY_COLD_SIGNAL_DAYS} d)` : `frio (${ageInDays} d)`
return {
value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
label: `Sem sinal / ${daysLabel}`,
ageInDays,
isCold: true,
}
}
if (!input.signalType) {
return {
value: MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
label: 'Sem tipo de sinal',
ageInDays,
isCold: false,
}
}
return {
value: input.signalType,
label: municipalitySignalTypeLabels[input.signalType],
ageInDays,
isCold: false,
}
}
/**
* Aggregate under the status strip: latest notes from level · trend · signal,
* with absence/age when there is nothing to quote.
*/
export const buildMunicipalityV2StatusAggregate = (
input: MunicipalityV2StatusNotes,
now: Date = new Date(),
): string => {
const levelPart = input.levelNote?.trim()
? input.levelNote.trim()
: input.engagementLevel
? `${formatEngagementLevelLabel(input.engagementLevel)} — sem motivo`
: EMPTY_ENGAGEMENT_LEVEL_LABEL
const trendPart = input.trendNote?.trim() ? input.trendNote.trim() : 'Tendência sem nota'
const signalState = resolveMunicipalityV2SignalSelectState(input, now)
const signalPart = input.signalBody?.trim()
? input.signalBody.trim()
: signalState.isCold
? signalState.label
: `${signalState.label} — sem nota`
return `${levelPart} · ${trendPart} · ${signalPart}`
}

View file

@ -114,6 +114,13 @@ describe('campaign municipality update domain', () => {
body: 'Visita adversária confirmada.',
}).success,
).toBe(false)
expect(
municipalityUpdateCreateSchema.safeParse({
municipality: 1,
kind: 'sinal',
signalType: 'invasao',
}).success,
).toBe(true)
})
it('strips forged author and timestamps from input', () => {
@ -212,6 +219,19 @@ describe('campaign municipality update domain', () => {
expect(visible.docs.map(({ id }) => id)).toContain(created.id)
})
it('accepts a sinal with type and empty motivo', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
kind: 'sinal',
signalType: 'invasao',
})
expect(created.signalType).toBe('invasao')
expect(created.body).toBeFalsy()
})
it('limits advisors to administered municipalities for create and read', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const otherAdvisor = await campaignFixtures().createCampaignUser('advisor')

View file

@ -25,6 +25,9 @@ describe('campaignPageChrome', () => {
expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({
title: 'Municípios',
})
expect(resolveCampaignPageChrome('/campanha/municipio/cairu/v2', 'coordinator')).toEqual({
title: 'Municípios',
})
})
it('resolves quadro without role-based prose subtitle', () => {

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
buildMunicipalityV2StatusAggregate,
MUNICIPALITY_V2_SIGNAL_COLD_VALUE,
resolveMunicipalityV2SignalSelectState,
} from '@/utilities/municipality/municipalityV2StatusView'
describe('municipalityV2StatusView', () => {
it('uses cold sentinel when there is no recent signal', () => {
const state = resolveMunicipalityV2SignalSelectState(
{ signalType: null, lastSignalAt: null },
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
expect(state.isCold).toBe(true)
expect(state.label).toContain('Sem sinal')
})
it('does not claim frio when frescor is warm but typeless', () => {
const state = resolveMunicipalityV2SignalSelectState(
{
signalType: null,
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe(MUNICIPALITY_V2_SIGNAL_COLD_VALUE)
expect(state.isCold).toBe(false)
expect(state.label).toBe('Sem tipo de sinal')
})
it('keeps the typed signal when still warm', () => {
const state = resolveMunicipalityV2SignalSelectState(
{
signalType: 'invasao',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(state.value).toBe('invasao')
expect(state.isCold).toBe(false)
expect(state.label).toBe('Invasão')
})
it('builds the aggregate from the three note axes', () => {
const aggregate = buildMunicipalityV2StatusAggregate(
{
engagementLevel: 'n2',
levelNote: 'Subiu o nível',
trendNote: 'Tendência melhor',
signalType: 'esfriamento',
signalBody: 'Rede parou',
lastSignalAt: '2026-08-01T12:00:00.000Z',
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(aggregate).toBe('Subiu o nível · Tendência melhor · Rede parou')
})
it('names absences when notes are empty', () => {
const aggregate = buildMunicipalityV2StatusAggregate(
{
engagementLevel: 'n1',
levelNote: null,
trendNote: null,
signalType: null,
signalBody: null,
lastSignalAt: null,
},
new Date('2026-08-02T12:00:00.000Z'),
)
expect(aggregate).toContain('sem motivo')
expect(aggregate).toContain('Tendência sem nota')
expect(aggregate).toContain('Sem sinal')
})
})