teqo-backup/src/components/campaign/demand/DemandWorkflowCard.tsx
Francisco Solla a9f0c61edf fix(campanha): restaura submitter no FormData do card de demanda (#921)
O dispatch manual do C139 montava new FormData(form) sem o submitter;
os botões de transição (name=status value=aprovada|rejeitada|…) nunca
viajavam e transitionDemandFormAction rejeitava toda decisão por status
vazio. submitterFrom (cast honesto nativeEvent as SubmitEvent) + segundo
arg do construtor nos handlers de transição e custo; C139 intacto.

Teste unit novo pina o glue FormData→status (SubmitEvent real, não
fireEvent.submit). E2e 'advisor opens a demand and decides it' verde
local (isolado e em runs de arquivo inteiro).
2026-08-25 21:48:30 -03:00

235 lines
8.8 KiB
TypeScript

'use client'
import { startTransition, useActionState, type FormEvent } 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 {
campaignDemandStatusLabels,
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
/** Coordinator or candidate — the roles allowed to decide escalated demands. */
canDecideEscalated: boolean
currentCost: number | null
transitionFormAction: FormAction
costFormAction: FormAction
receiptFormAction: FormAction
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the workflow renders as status-only, no transitions/cost/receipt. */
readOnly?: boolean
}
const transitionVariant = (target: CampaignDemandStatus) =>
target === 'aprovada' ? 'default' : target === 'rejeitada' ? 'destructive' : 'secondary'
/**
* The native submit event always carries the pressed submit button; React
* types `FormEvent.nativeEvent` as a plain `Event`, so the read is a cast.
*/
const submitterFrom = (event: FormEvent<HTMLFormElement>): HTMLElement | null =>
(event.nativeEvent as SubmitEvent).submitter
/** Staff-only workflow: analysis, escalation, decision, cost and receipts. */
export const DemandWorkflowCard = ({
demandID,
status,
canDecideEscalated,
currentCost,
transitionFormAction,
costFormAction,
receiptFormAction,
readOnly = false,
}: 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) =>
canDecideEscalated ||
status !== 'escalada' ||
(target !== 'aprovada' && target !== 'rejeitada'),
)
// C139 — manual dispatch (no `action={submitTransition}`/`action={submitCost}`):
// React 19 resets uncontrolled fields after any settled form action, wiping
// the typed decision note and cost on a validation error. `startTransition`
// keeps the pending flags correct for the imperative dispatch. The FormData
// constructor receives the submitter (#921): the decision buttons carry
// `name="status" value={target}` and only the pressed one must travel.
const handleTransitionSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
startTransition(() => submitTransition(new FormData(event.currentTarget, submitterFrom(event))))
}
const handleCostSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
startTransition(() => submitCost(new FormData(event.currentTarget, submitterFrom(event))))
}
if (readOnly) {
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>
<p className="text-sm text-muted-foreground">
Status: {campaignDemandStatusLabels[status]}
</p>
</section>
)
}
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 onSubmit={handleTransitionSubmit} 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 onSubmit={handleCostSubmit} 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>
)
}