diff --git a/docs/plans/municipio-v2-agora-impl.md b/docs/plans/municipio-v2-agora-impl.md new file mode 100644 index 00000000..08989f9d --- /dev/null +++ b/docs/plans/municipio-v2-agora-impl.md @@ -0,0 +1,67 @@ +# Impl: Município v2 — Agora (encaminhamento + sugestão + visita) + +Status: aprovado +Atualizado em: 2026-08-03 +Issue: #333 +Intenção: docs/plans/municipio-v2-agora.md +Appetite restante: herdado (~0,5–1 dia eng) + +## Leitura da intenção + +- **Outcome:** Bloco “Agora” na rota v2 com encaminhamento editável no lugar, até duas sugestões acionáveis (ou silêncio explícito) e visita condensada em uma linha. +- **O que NÃO negociar:** um só motor E11; resolve/dispensa existente; `nextSteps` como fonte única; sem checklist E13 expandida; staff only (`noLeader`); silêncio explícito. +- **O que reavaliar:** hipótese de componente monolítico — separar loader server, controle client de `nextSteps` e seção RSC; JSON route (precedente political-trend) vs form action (precedente AdvisorDebouncedTextCell). + +## Abordagem recomendada + +```mermaid +flowchart TB + page[v2/page.tsx] --> loader[loadMunicipalityV2AgoraData] + loader --> VM[strategy.nextSteps + E11 slice 0-2 + E13 summary] + page --> section[MunicipalityV2AgoraSection RSC] + section --> next[MunicipalityV2NextStepsControl] + section --> cards[SuggestionCard × 0-2] + section --> visit[formatVisitEligibilitySummary + link giros] + next -->|POST /municipios/next-steps| action[updateMunicipalityStrategy partial] + cards -->|resolveSuggestionFormAction| E11[resolveSuggestion] +``` + +**Opções consideradas:** A) reutilizar `SuggestionsPanel` inteiro | B) compor loader + peças novas enxutas | C) novo motor de sugestão v2 +**Recomendação:** B — mesmos loaders/ações; UI condensada sem statute/card E13. +**Rejeitadas:** A (torre de chrome do dashboard); C (twin motor). + +### Componentes / mudanças + +- **`formatVisitEligibilitySummary`** (`src/utilities/visit/visitEligibility.ts`): headline + frase curta; teste unitário. +- **`loadMunicipalityV2AgoraData`** (`src/utilities/municipality/municipalityV2AgoraData.ts`): `Promise.all` detail VM + suggestions + visit. +- **`MunicipalityV2NextStepsControl`** + **`/municipios/next-steps/route.ts`**: autosave debounced (precedente political-trend JSON). +- **`MunicipalityV2AgoraSection`**: RSC que monta os três sub-blocos. +- **`resolveSuggestion`**: `revalidateMunicipalityListPaths` inclui v2. +- **Migration:** sem migration. + +### Dados → forma + +- Encaminhamento: textarea auto-save (edit where you see). +- Sugestões: 0–2 `SuggestionCard` compactos; silêncio copiado do OverviewTab. +- Visita: uma linha “Elegível|Não elegível · motivo” + link compositor. + +## Fases verificáveis + +1. **Schema/server** — summary helper, loader, next-steps route, revalidation fix. +2. **UI** — section + control + wire v2 page. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- FAB B151; Conta/Rede B148–B149; compositor completo. + +## Riscos e mitigação + +- Partial strategy update sobrescrever outros campos → action só envia `nextSteps`. +- Sugestão stale na v2 → revalidate v2 no resolve. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit summary visit) diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx index 5a00d136..5e333848 100644 --- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx @@ -2,13 +2,16 @@ import config from '@payload-config' import { notFound } from 'next/navigation' import { getPayload } from 'payload' +import { resolveSuggestionFormAction } from '@/app/(campaign)/campanha/(app)/suggestionFormActions' import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions' +import { MunicipalityV2AgoraSection } from '@/components/campaign/municipality/MunicipalityV2AgoraSection' import { MunicipalityV2NetworkSection } from '@/components/campaign/municipality/MunicipalityV2NetworkSection' 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 { loadMunicipalityV2AgoraData } from '@/utilities/municipality/municipalityV2AgoraData' import { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData' import { loadMunicipalityV2NetworkData } from '@/utilities/municipality/municipalityV2NetworkData' import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData' @@ -33,13 +36,16 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP let status let network + let agora try { - const loaded = await Promise.all([ + const [statusLoaded, networkLoaded, agoraLoaded] = await Promise.all([ loadMunicipalityV2StatusData(payload, user, slug), loadMunicipalityV2NetworkData(payload, user, slug), + loadMunicipalityV2AgoraData(payload, user, slug), ]) - ;({ status } = loaded[0]) - network = loaded[1] + ;({ status } = statusLoaded) + network = networkLoaded + agora = agoraLoaded } catch (error) { if (error instanceof MunicipalityNotFoundError) notFound() throw error @@ -53,7 +59,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP signalFormAction={createMunicipalityV2SignalFormAction} /> - {/* Placeholders for B148–B150 — keep the vertical rhythm without shipping twin content. */} + {/* Placeholder for B148 — keep the vertical rhythm without shipping twin content. */}

Conta local @@ -61,13 +67,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP

Em breve nesta visão.

- -
-

- Agora -

-

Em breve nesta visão.

-
+ ) diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx index b9bd1690..456630a6 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/MunicipalityDetailTabs.tsx @@ -30,7 +30,7 @@ import { import { loadMunicipalityElectoralBaseline } from '@/utilities/municipality/municipalityElectoralBaseline' import { loadMunicipalityGoalAccount } from '@/utilities/municipality/municipalityGoalAccount' import type { getMunicipalityDetailViewModel } from '@/utilities/municipality/municipalityPageData' -import { formatSilenceAgeLabel } from '@/utilities/municipality/municipalitySignal' +import { formatMunicipalitySuggestionEmptyMessage } from '@/utilities/municipality/municipalitySignal' import { loadMunicipalityTicketPartners } from '@/utilities/municipality/municipalityTicketPartnerData' import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { @@ -149,17 +149,11 @@ const SuggestionsCard = async ({ activeCount={bundle.suggestions.length} resolveAction={resolveSuggestionFormAction} emptyState={ - silence ? ( -

- Nenhum padrão do catálogo dispara aqui — e o município{' '} - {formatSilenceAgeLabel(silence.lastSignalAgeDays)}. Silêncio é pergunta: vale checar a - rede e o registro. -

- ) : ( -

- Nenhum padrão do catálogo dispara aqui agora. -

- ) +

+ {formatMunicipalitySuggestionEmptyMessage( + silence ? { lastSignalAgeDays: silence.lastSignalAgeDays } : null, + )} +

} /> ) diff --git a/src/app/(campaign)/campanha/(app)/municipios/next-steps/route.ts b/src/app/(campaign)/campanha/(app)/municipios/next-steps/route.ts new file mode 100644 index 00000000..7e8aed41 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/next-steps/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages' +import { updateMunicipalityStrategy } from '@/app/(campaign)/campanha/actions/municipality' +import { trimmedNullableText, positiveRelationshipId } from '@/lib/schemas/primitives' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' + +import type { MunicipalityNextStepsResponse } from './types' + +export type { MunicipalityNextStepsResponse } from './types' + +export const dynamic = 'force-dynamic' + +const bodySchema = z.object({ + municipalityId: positiveRelationshipId, + nextSteps: trimmedNullableText(4000), +}) + +export const POST = campaignJsonMutationRoute( + { + bodySchema, + safeMessages: municipalityStaffEditSafeMessages, + genericMessage: + 'Não foi possível salvar o encaminhamento. Verifique seu acesso e tente novamente.', + }, + async ({ municipalityId, nextSteps }) => { + const updated = await updateMunicipalityStrategy({ + municipality: municipalityId, + nextSteps, + }) + + revalidateMunicipalityListPaths({ slug: updated.slug, scope: 'detail' }) + + return NextResponse.json({ + status: 'success', + message: 'Encaminhamento atualizado.', + savedNextSteps: updated.nextSteps ?? null, + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/municipios/next-steps/types.ts b/src/app/(campaign)/campanha/(app)/municipios/next-steps/types.ts new file mode 100644 index 00000000..29ac2537 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/next-steps/types.ts @@ -0,0 +1,10 @@ +export type MunicipalityNextStepsResponse = + | { + status: 'success' + message: string + savedNextSteps: string | null + } + | { + status: 'error' + message: string + } diff --git a/src/app/(campaign)/campanha/actions/suggestion.ts b/src/app/(campaign)/campanha/actions/suggestion.ts index 02c0706d..94133b20 100644 --- a/src/app/(campaign)/campanha/actions/suggestion.ts +++ b/src/app/(campaign)/campanha/actions/suggestion.ts @@ -19,6 +19,7 @@ import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate' import type { AllocationDecision, CampaignUser } from '@/payload-types' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { CAMPAIGN_STAFF_QUADRO_PATH } from '@/utilities/campaignPageActor' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' @@ -143,10 +144,9 @@ export const resolveSuggestion = async ( const { payload, actor } = await getCampaignActionContext() const { municipalitySlug, postponeDays } = await resolveSuggestionRecord(payload, actor, input) - // The two surfaces that render the queue: the staff dashboard panel and the - // município detail card. + // The surfaces that render the queue: staff dashboard, legacy detail, v2 Agora. revalidatePath(CAMPAIGN_STAFF_QUADRO_PATH) - revalidatePath(`/campanha/municipios/${municipalitySlug}`) + revalidateMunicipalityListPaths({ slug: municipalitySlug, scope: 'detail' }) return { postponeDays } } diff --git a/src/components/campaign/municipality/MunicipalityV2AgoraSection.tsx b/src/components/campaign/municipality/MunicipalityV2AgoraSection.tsx new file mode 100644 index 00000000..59201fb7 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2AgoraSection.tsx @@ -0,0 +1,81 @@ +import { ArrowRightIcon } from 'lucide-react' +import Link from 'next/link' +import type { ComponentProps } from 'react' + +import { MunicipalityV2NextStepsControl } from '@/components/campaign/municipality/MunicipalityV2NextStepsControl' +import { SuggestionCard } from '@/components/campaign/suggestion/SuggestionCard' +import { Button } from '@/components/ui/button' +import type { MunicipalityV2AgoraViewModel } from '@/utilities/municipality/municipalityV2AgoraView' +import { buildTourComposerHref } from '@/utilities/visit/visitPlannerUrl' + +type MunicipalityV2AgoraSectionProps = { + agora: MunicipalityV2AgoraViewModel + resolveAction: ComponentProps['resolveAction'] +} + +/** + * B150 — "o que fazer agora": encaminhamento editável, até duas sugestões, visita + * em uma linha. Server component; os sub-blocos interativos são ilhas client. + */ +export const MunicipalityV2AgoraSection = ({ + agora, + resolveAction, +}: MunicipalityV2AgoraSectionProps) => { + const tourHref = buildTourComposerHref({ region: agora.visit.region }) + + return ( +
+

+ Agora +

+ +
+ +
+ +
+

Sugestões

+ {agora.suggestions.length > 0 ? ( +
+ {agora.suggestions.map((suggestion) => ( + + ))} +
+ ) : ( +

{agora.suggestionSilence}

+ )} +
+ +
+
+

Visita do candidato

+ {agora.visit.summary ? ( +

+ {agora.visit.summary.headline} + {' · '} + {agora.visit.summary.detail} +

+ ) : ( +

+ Dados insuficientes para avaliar a elegibilidade de visita. +

+ )} +
+ +
+
+ ) +} diff --git a/src/components/campaign/municipality/MunicipalityV2NextStepsControl.tsx b/src/components/campaign/municipality/MunicipalityV2NextStepsControl.tsx new file mode 100644 index 00000000..09f7ea85 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2NextStepsControl.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react' + +import type { MunicipalityNextStepsResponse } from '@/app/(campaign)/campanha/(app)/municipios/next-steps/types' +import { Spinner } from '@/components/ui/Spinner' +import { Textarea } from '@/components/ui/textarea' +import { campaignInlineInputClassName } from '@/lib/campaignInlineInput' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { cn } from '@/lib/utils' + +const NEXT_STEPS_ENDPOINT = '/campanha/municipios/next-steps' +const SAVE_DEBOUNCE_MS = 600 +const SAVE_ERROR_MESSAGE = 'Não foi possível salvar o encaminhamento. Tente novamente.' +const NEXT_STEPS_MAX_LENGTH = 4000 + +const normalizeNextSteps = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +type MunicipalityV2NextStepsControlProps = { + municipalityID: number + municipalityName: string + defaultValue: string | null +} + +export const MunicipalityV2NextStepsControl = ({ + municipalityID, + municipalityName, + defaultValue, +}: MunicipalityV2NextStepsControlProps) => { + const statusId = useId() + const router = useRouter() + const displayDefault = defaultValue ?? '' + const [value, setValue] = useState(displayDefault) + const [isPending, setIsPending] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const lastSaved = useRef(normalizeNextSteps(displayDefault) ?? '') + const timerRef = useRef | null>(null) + const requestId = useRef(0) + const textareaRef = useRef(null) + + useEffect(() => { + if (textareaRef.current && document.activeElement === textareaRef.current) return + const next = defaultValue ?? '' + setValue(next) + lastSaved.current = normalizeNextSteps(next) ?? '' + }, [defaultValue, municipalityID]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const save = (nextRaw: string) => { + const normalized = normalizeNextSteps(nextRaw) ?? '' + if (normalized === lastSaved.current) return + + const id = ++requestId.current + setIsPending(true) + setErrorMessage(null) + + void postCampaignJson( + NEXT_STEPS_ENDPOINT, + { municipalityId: municipalityID, nextSteps: normalized || null }, + ).then(({ ok, payload }) => { + if (id !== requestId.current) return + setIsPending(false) + if (!ok || payload.status !== 'success') { + setErrorMessage( + payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE, + ) + return + } + lastSaved.current = normalizeNextSteps(payload.savedNextSteps) ?? '' + router.refresh() + }) + } + + const scheduleSave = (nextRaw: string) => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + timerRef.current = null + save(nextRaw) + }, SAVE_DEBOUNCE_MS) + } + + return ( +
+ +
+