mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(campanha): B147 município v2 shell + faixa de status
Rota paralela /campanha/municipio/<slug>/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 <fsolla@users.noreply.github.com>
This commit is contained in:
parent
466a34525d
commit
0aa47f8673
16 changed files with 1163 additions and 44 deletions
107
docs/plans/municipio-v2-shell-status-impl.md
Normal file
107
docs/plans/municipio-v2-shell-status-impl.md
Normal file
|
|
@ -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/<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/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
|
||||
|
|
@ -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<CampaignFormActionState> =>
|
||||
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.',
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<EngagementLevel | ''>('')
|
||||
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 = ({
|
|||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
|
||||
<FieldLabel htmlFor={fieldId('note')}>Motivo (opcional)</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId('note')}
|
||||
value={note}
|
||||
|
|
@ -294,18 +286,6 @@ export const MunicipalityListLevelControl = ({
|
|||
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" className={isSheet ? 'min-h-11' : undefined}>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useId, useState, type ReactNode } 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
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 === signalType) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -89,15 +89,14 @@ 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.
|
||||
* E14 + B134 — a movement still records hysteresis/override in the snapshot;
|
||||
* Motivo is optional free text (empty OK). "O que faria voltar atrás" left the
|
||||
* write model (B147 lands the policy soft-dep; historical snapshot scrub stays B134).
|
||||
*/
|
||||
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),
|
||||
note: trimmedNullableText(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. */
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
src/utilities/municipality/municipalityV2StatusData.ts
Normal file
78
src/utilities/municipality/municipalityV2StatusData.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
116
src/utilities/municipality/municipalityV2StatusView.ts
Normal file
116
src/utilities/municipality/municipalityV2StatusView.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 || !input.signalType) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
|
@ -41,7 +41,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
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)
|
||||
|
||||
|
|
@ -61,10 +60,32 @@ describe('municipality engagement level (E14)', () => {
|
|||
expect(decision.snapshot).toMatchObject({
|
||||
from: null,
|
||||
to: 'n3',
|
||||
reversalSignals: 'Rompimento do prefeito ou pledges abaixo de 40% da meta.',
|
||||
violations: [],
|
||||
overridden: false,
|
||||
})
|
||||
expect(decision.snapshot).not.toHaveProperty('reversalSignals')
|
||||
})
|
||||
|
||||
it('accepts an empty motivo and still writes the movement', 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: 'n1',
|
||||
note: null,
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
expect(updated.engagementLevel).toBe('n1')
|
||||
expect(updated.levelNote).toBeNull()
|
||||
|
||||
const decisions = await findDecisions(municipality.id)
|
||||
expect(decisions.docs).toHaveLength(1)
|
||||
const decision = decisions.docs[0]!
|
||||
fixtures.own('allocationDecision', decision.id)
|
||||
expect(decision.rationale).toBe('Sem motivo registrado.')
|
||||
})
|
||||
|
||||
it('holds a movement that breaks the rules and writes nothing', async () => {
|
||||
|
|
@ -76,7 +97,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
municipality: municipality.id,
|
||||
level: 'n1',
|
||||
note: 'Entrada no mapa como presença de mandato.',
|
||||
reversalSignals: 'Nenhuma agenda executada no trimestre.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
|
|
@ -86,7 +106,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
municipality: municipality.id,
|
||||
level: 'n3',
|
||||
note: 'Achismo da semana.',
|
||||
reversalSignals: 'Nada.',
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(blocked).toBeInstanceOf(EngagementLevelBlockedError)
|
||||
|
|
@ -115,7 +134,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
municipality: municipality.id,
|
||||
level: 'n1',
|
||||
note: 'Entrada no mapa.',
|
||||
reversalSignals: 'Agenda vazia por um trimestre.',
|
||||
})
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
|
|
@ -123,7 +141,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
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')
|
||||
|
|
@ -164,7 +181,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
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')
|
||||
|
|
@ -191,7 +207,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
municipality: municipality.id,
|
||||
level: 'n2',
|
||||
note: 'Quero subir o meu município.',
|
||||
reversalSignals: 'Nada.',
|
||||
}),
|
||||
).rejects.toThrow('coordenação geral ou o candidato')
|
||||
|
||||
|
|
@ -218,7 +233,6 @@ describe('municipality engagement level (E14)', () => {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
64
tests/unit/municipalityV2StatusView.unit.spec.ts
Normal file
64
tests/unit/municipalityV2StatusView.unit.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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('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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue