teqo-backup/src/components/campaign/DemandWorkflowCard.tsx
Francisco Ignácio Fontoura Solla cf13c441aa Remodel /campanha around predefined Praças replacing electoral nuclei.
436 seeded Praças (municipality-based; per-TSE-zone in Salvador/Camaçari) become
the operational unit; roles renamed to coordinator/advisor/leader with advisors
scoped to administered Praças. Leadership is one record per person across many
Praças/organizations; vote pledges split leader-declared vs staff-estimated
(estimates never serialized to leaders). Adds organizations, demand workflow
with staff-only cost/receipts, action-plan results with deputy presence, manual
political trend, multi-candidate comparison and the diverging red/white/blue
map mode. Migration 20260721_020109_remodel_plazas deliberately resets the
campaign vertical in production (public site untouched); test suite ported to
the new model (unit 187 / int 306 / e2e 9 green).
2026-07-21 02:28:00 -03:00

184 lines
6.6 KiB
TypeScript

'use client'
import { useActionState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
campaignDemandTransitionLabels,
campaignDemandTransitions,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
type FormAction = (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
type DemandWorkflowCardProps = {
demandID: number
status: CampaignDemandStatus
isCoordinator: boolean
currentCost: number | null
transitionFormAction: FormAction
costFormAction: FormAction
receiptFormAction: FormAction
}
const transitionVariant = (target: CampaignDemandStatus) =>
target === 'aprovada' ? 'default' : target === 'rejeitada' ? 'destructive' : 'secondary'
/** Staff-only workflow: analysis, escalation, decision, cost and receipts. */
export const DemandWorkflowCard = ({
demandID,
status,
isCoordinator,
currentCost,
transitionFormAction,
costFormAction,
receiptFormAction,
}: DemandWorkflowCardProps) => {
const [transitionState, submitTransition, transitionPending] = useActionState(
transitionFormAction,
{},
)
const [costState, submitCost, costPending] = useActionState(costFormAction, {})
const [receiptState, submitReceipt, receiptPending] = useActionState(receiptFormAction, {})
const availableTransitions = campaignDemandTransitions[status].filter(
(target) =>
isCoordinator || status !== 'escalada' || (target !== 'aprovada' && target !== 'rejeitada'),
)
return (
<section
aria-labelledby="demand-workflow-title"
className="flex flex-col gap-6 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="demand-workflow-title" className="text-base font-medium">
Tratar demanda
</h2>
<p className="text-sm text-muted-foreground">
O assessor analisa e decide — ou escala ao Coordenador Geral. Demandas escaladas são
decididas somente por ele.
</p>
</div>
{availableTransitions.length ? (
<form action={submitTransition} className="flex flex-col gap-3">
<input type="hidden" name="demandId" value={demandID} />
<Field>
<FieldLabel htmlFor={`demand-note-${demandID}`}>Nota da decisão</FieldLabel>
<Textarea
id={`demand-note-${demandID}`}
name="decisionNote"
rows={2}
maxLength={2000}
placeholder="Contexto do encaminhamento (visível para quem abriu)…"
/>
</Field>
<div className="flex flex-wrap gap-2">
{availableTransitions.map((target) => (
<Button
key={target}
type="submit"
name="status"
value={target}
variant={transitionVariant(target)}
disabled={transitionPending}
className="min-h-11"
>
{transitionPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{campaignDemandTransitionLabels[target]}
</Button>
))}
</div>
{transitionState.message && transitionState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{transitionState.message}</AlertDescription>
</Alert>
) : null}
{transitionState.status === 'success' ? (
<Alert>
<AlertDescription>{transitionState.message}</AlertDescription>
</Alert>
) : null}
</form>
) : (
<p className="text-sm text-muted-foreground">Esta demanda já foi decidida.</p>
)}
<form action={submitCost} className="flex flex-col gap-2">
<input type="hidden" name="demandId" value={demandID} />
<FieldLabel htmlFor={`demand-cost-${demandID}`}>
Custo estimado (R$) — controle interno
</FieldLabel>
<div className="flex gap-2">
<Input
id={`demand-cost-${demandID}`}
name="cost"
type="number"
min={0}
step="0.01"
inputMode="decimal"
defaultValue={currentCost ?? undefined}
className="min-h-11 w-40"
/>
<Button type="submit" variant="secondary" disabled={costPending} className="min-h-11">
{costPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar custo
</Button>
</div>
{fieldError(costState.fieldErrors, 'cost') ? (
<FieldError>{fieldError(costState.fieldErrors, 'cost')}</FieldError>
) : null}
{costState.message && costState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{costState.message}</AlertDescription>
</Alert>
) : null}
</form>
<form action={submitReceipt} className="flex flex-col gap-2">
<input type="hidden" name="demandId" value={demandID} />
<FieldLabel htmlFor={`demand-receipt-${demandID}`}>
Anexar comprovante (imagem ou PDF, até 10 MB)
</FieldLabel>
<p className="text-xs text-muted-foreground">
Controle interno de gastos — não substitui a prestação de contas oficial (SPCE/TSE).
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
id={`demand-receipt-${demandID}`}
name="receipt"
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
required
className="min-h-11 max-w-xs"
/>
<Button type="submit" variant="secondary" disabled={receiptPending} className="min-h-11">
{receiptPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Anexar
</Button>
</div>
{receiptState.message && receiptState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{receiptState.message}</AlertDescription>
</Alert>
) : null}
{receiptState.status === 'success' ? (
<Alert>
<AlertDescription>{receiptState.message}</AlertDescription>
</Alert>
) : null}
</form>
</section>
)
}