mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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).
184 lines
6.6 KiB
TypeScript
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>
|
|
)
|
|
}
|