B150 — Município v2 bloco Agora (encaminhamento + sugestão + visita)
- Loader e seção Agora na rota /campanha/municipio/[slug]/v2 - Encaminhamento editável com auto-save via POST /municipios/next-steps - Até 2 sugestões E11 com resolve/dispensa existente; silêncio explícito - Visita E13 condensada em uma linha + link ao compositor de giros - Revalidação v2 ao resolver sugestão; helper compartilhado de empty state E11 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
deefe566b0
commit
6955cccc6b
13 changed files with 507 additions and 26 deletions
67
docs/plans/municipio-v2-agora-impl.md
Normal file
67
docs/plans/municipio-v2-agora-impl.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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. */}
|
||||
<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
|
||||
|
|
@ -61,13 +67,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
|
|||
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
|
||||
</section>
|
||||
<MunicipalityV2NetworkSection network={network} />
|
||||
|
||||
<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>
|
||||
<MunicipalityV2AgoraSection agora={agora} resolveAction={resolveSuggestionFormAction} />
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum padrão do catálogo dispara aqui agora.
|
||||
</p>
|
||||
)
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMunicipalitySuggestionEmptyMessage(
|
||||
silence ? { lastSignalAgeDays: silence.lastSignalAgeDays } : null,
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityNextStepsResponse>({
|
||||
status: 'success',
|
||||
message: 'Encaminhamento atualizado.',
|
||||
savedNextSteps: updated.nextSteps ?? null,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export type MunicipalityNextStepsResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
savedNextSteps: string | null
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof SuggestionCard>['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 (
|
||||
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-6">
|
||||
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
|
||||
Agora
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<MunicipalityV2NextStepsControl
|
||||
municipalityID={agora.municipalityID}
|
||||
municipalityName={agora.municipalityName}
|
||||
defaultValue={agora.nextSteps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Sugestões</h3>
|
||||
{agora.suggestions.length > 0 ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{agora.suggestions.map((suggestion) => (
|
||||
<SuggestionCard
|
||||
key={`${suggestion.municipalityID}-${suggestion.patternId}`}
|
||||
suggestion={suggestion}
|
||||
resolveAction={resolveAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{agora.suggestionSilence}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="text-sm font-medium">Visita do candidato</h3>
|
||||
{agora.visit.summary ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{agora.visit.summary.headline}</span>
|
||||
{' · '}
|
||||
{agora.visit.summary.detail}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dados insuficientes para avaliar a elegibilidade de visita.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||||
<Link href={tourHref}>
|
||||
Abrir compositor de giros
|
||||
<ArrowRightIcon className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string | null>(null)
|
||||
const lastSaved = useRef(normalizeNextSteps(displayDefault) ?? '')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestId = useRef(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<MunicipalityNextStepsResponse>(
|
||||
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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`${statusId}-next-steps`} className="text-sm font-medium">
|
||||
Próximo passo combinado
|
||||
<span className="sr-only"> para {municipalityName}</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id={`${statusId}-next-steps`}
|
||||
value={value}
|
||||
rows={3}
|
||||
maxLength={NEXT_STEPS_MAX_LENGTH}
|
||||
placeholder="O que a equipe combinou fazer a seguir neste município?"
|
||||
aria-label={`Encaminhamento de ${municipalityName}`}
|
||||
aria-busy={isPending}
|
||||
aria-describedby={errorMessage ? `${statusId}-next-steps-error` : undefined}
|
||||
className={cn(campaignInlineInputClassName, 'min-h-24 resize-y py-2')}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(event.currentTarget.value)
|
||||
setErrorMessage(null)
|
||||
scheduleSave(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
save(value)
|
||||
}}
|
||||
/>
|
||||
{isPending ? (
|
||||
<Spinner className="pointer-events-none absolute top-2 right-2 size-3.5" />
|
||||
) : null}
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p id={`${statusId}-next-steps-error`} className="text-sm text-destructive" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Salva automaticamente ao editar.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -56,3 +56,11 @@ export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): stri
|
|||
*/
|
||||
export const formatSilenceAgeLabel = (ageInDays: number | null): string =>
|
||||
ageInDays === null ? 'nunca recebeu sinal' : `sem sinal há ${ageInDays} dias`
|
||||
|
||||
/** E11 empty queue copy — shared by the detail overview and the v2 Agora block. */
|
||||
export const formatMunicipalitySuggestionEmptyMessage = (
|
||||
silence: { lastSignalAgeDays: number | null } | null,
|
||||
): string =>
|
||||
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.'
|
||||
|
|
|
|||
56
src/utilities/municipality/municipalityV2AgoraData.ts
Normal file
56
src/utilities/municipality/municipalityV2AgoraData.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
getMunicipalityDetailViewModel,
|
||||
resolveAccessibleMunicipalityContext,
|
||||
} from '@/utilities/municipality/municipalityPageData'
|
||||
import { formatMunicipalitySuggestionEmptyMessage } from '@/utilities/municipality/municipalitySignal'
|
||||
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
|
||||
import type { MunicipalityV2AgoraViewModel } from '@/utilities/municipality/municipalityV2AgoraView'
|
||||
import { formatVisitEligibilitySummary } from '@/utilities/visit/visitEligibility'
|
||||
import { loadMunicipalityVisitEligibility } from '@/utilities/visit/visitPlannerData'
|
||||
|
||||
const MAX_AGORA_SUGGESTIONS = 2
|
||||
|
||||
export const loadMunicipalityV2AgoraData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalitySlug: string,
|
||||
): Promise<MunicipalityV2AgoraViewModel> => {
|
||||
const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
|
||||
const [view, suggestionBundle, visit] = await Promise.all([
|
||||
getMunicipalityDetailViewModel(payload, context, user),
|
||||
loadMunicipalitySuggestions(payload, user, { municipalityID: context.id }),
|
||||
loadMunicipalityVisitEligibility(payload, user, municipalitySlug),
|
||||
])
|
||||
|
||||
const strategy = view.strategy
|
||||
if (!strategy) {
|
||||
throw new Error('Municipality v2 Agora requires a staff strategy view model.')
|
||||
}
|
||||
|
||||
const silence = suggestionBundle.silence.find((entry) => entry.municipalityID === context.id)
|
||||
const suggestionSilence =
|
||||
suggestionBundle.suggestions.length === 0
|
||||
? formatMunicipalitySuggestionEmptyMessage(
|
||||
silence ? { lastSignalAgeDays: silence.lastSignalAgeDays } : null,
|
||||
)
|
||||
: null
|
||||
|
||||
return {
|
||||
municipalityID: view.id,
|
||||
municipalityName: view.name,
|
||||
nextSteps: strategy.nextSteps,
|
||||
suggestions: suggestionBundle.suggestions
|
||||
.slice(0, MAX_AGORA_SUGGESTIONS)
|
||||
.map(({ metrics: _metrics, ...card }) => card),
|
||||
suggestionSilence,
|
||||
visit: {
|
||||
summary: visit.candidate ? formatVisitEligibilitySummary(visit.candidate.eligibility) : null,
|
||||
region: visit.candidate?.region ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
15
src/utilities/municipality/municipalityV2AgoraView.ts
Normal file
15
src/utilities/municipality/municipalityV2AgoraView.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { SuggestionCardData } from '@/components/campaign/suggestion/SuggestionCard'
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import type { VisitEligibilitySummary } from '@/utilities/visit/visitEligibility'
|
||||
|
||||
export type MunicipalityV2AgoraViewModel = {
|
||||
municipalityID: number
|
||||
municipalityName: string
|
||||
nextSteps: string | null
|
||||
suggestions: SuggestionCardData[]
|
||||
suggestionSilence: string | null
|
||||
visit: {
|
||||
summary: VisitEligibilitySummary | null
|
||||
region: BahiaIdentityTerritory | null
|
||||
}
|
||||
}
|
||||
|
|
@ -285,3 +285,45 @@ export const visitConditionLabels: Record<VisitConditionId, string> = {
|
|||
janela: 'Janela política',
|
||||
encaixe: 'Encaixe em giro',
|
||||
}
|
||||
|
||||
export type VisitEligibilitySummary = {
|
||||
eligible: boolean
|
||||
headline: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* B150 — one-line readout for the v2 "Agora" block: eligible or not, plus a
|
||||
* short reason. Full checklist stays on the FAB/detail card (E13).
|
||||
*/
|
||||
export const formatVisitEligibilitySummary = (
|
||||
eligibility: VisitEligibility,
|
||||
): VisitEligibilitySummary => {
|
||||
const allMet =
|
||||
eligibility.metCount === VISIT_CONDITIONS.length && eligibility.contraindication === null
|
||||
|
||||
if (allMet) {
|
||||
return {
|
||||
eligible: true,
|
||||
headline: 'Elegível',
|
||||
detail: 'As cinco condições estão atendidas.',
|
||||
}
|
||||
}
|
||||
|
||||
if (eligibility.contraindication) {
|
||||
return {
|
||||
eligible: false,
|
||||
headline: 'Não elegível',
|
||||
detail: visitContraindicationLabels[eligibility.contraindication.id],
|
||||
}
|
||||
}
|
||||
|
||||
const firstUnmet = eligibility.conditions.find((condition) => !condition.met)
|
||||
return {
|
||||
eligible: false,
|
||||
headline: 'Não elegível',
|
||||
detail: firstUnmet
|
||||
? `${visitConditionLabels[firstUnmet.id]}: ${firstUnmet.detail}`
|
||||
: 'Condições incompletas.',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
calendarPhaseLabels,
|
||||
calendarPhaseVisitProduct,
|
||||
evaluateVisitEligibility,
|
||||
formatVisitEligibilitySummary,
|
||||
resolveCalendarPhase,
|
||||
VISIT_CONDITIONS,
|
||||
VISIT_CONTRAINDICATIONS,
|
||||
|
|
@ -190,6 +191,36 @@ describe('visit planner copy', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('formatVisitEligibilitySummary', () => {
|
||||
it('reads eligible when all five conditions are met', () => {
|
||||
const summary = formatVisitEligibilitySummary(evaluateVisitEligibility(input()))
|
||||
expect(summary).toEqual({
|
||||
eligible: true,
|
||||
headline: 'Elegível',
|
||||
detail: 'As cinco condições estão atendidas.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads not eligible with the contraindication headline when one fires', () => {
|
||||
const summary = formatVisitEligibilitySummary(
|
||||
evaluateVisitEligibility(
|
||||
input({ leadershipCount: 0, pledgeCount: 0, territorialClass: 'sem_base' }),
|
||||
),
|
||||
)
|
||||
expect(summary.eligible).toBe(false)
|
||||
expect(summary.headline).toBe('Não elegível')
|
||||
expect(summary.detail).toBe(visitContraindicationLabels.perdida)
|
||||
})
|
||||
|
||||
it('names the first unmet condition when there is no contraindication', () => {
|
||||
const summary = formatVisitEligibilitySummary(
|
||||
evaluateVisitEligibility(input({ projectedValidVotes: 1_000, projectedValidVotesCut: 10_000 })),
|
||||
)
|
||||
expect(summary.eligible).toBe(false)
|
||||
expect(summary.detail).toContain(visitConditionLabels.volume)
|
||||
})
|
||||
})
|
||||
|
||||
type CandidateOverrides = {
|
||||
slug: string
|
||||
name?: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue