mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 10:43:07 +00:00
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).
235 lines
8.8 KiB
TypeScript
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>
|
|
)
|
|
}
|