Files
compras/resources/js/pages/requisitions/show.tsx
T

492 lines
25 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import { Check, Circle, Pencil, Send } from 'lucide-react';
import {
FormErrors,
PageTitle,
Panel,
Status,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { date, money, priorityLabels, roleLabels } from '@/types/procurement';
import type { Requisition } from '@/types/procurement';
type Audit = {
id: number;
action: string;
user_id: number;
created_at: string;
};
const actions: Record<string, string> = {
'requisition.created': 'Rascunho criado',
'requisition.updated': 'Rascunho actualizado',
'requisition.submitted': 'Pedido submetido',
'approval.approved': 'Etapa aprovada',
'approval.rejected': 'Pedido rejeitado',
};
export default function Show({
requisition: r,
canEdit,
canDecide,
conflictBlocked,
audit,
technical,
}: {
requisition: Requisition;
canEdit: boolean;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
technical?: {
canReview: boolean;
basis_hash: string;
reviews: {
id: number;
decision: string;
reason: string;
basis_hash: string;
snapshot: { reviewer: { name: string } };
}[];
};
}) {
const submission = useForm({});
const review = useForm({
basis_hash: technical?.basis_hash ?? '',
decision: 'approved',
reason: '',
});
const decision = useForm<{ decision: string; comment: string }>({
decision: 'approved',
comment: '',
});
return (
<>
<Head title={r.number} />
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Requisição de compra"
title={r.number}
description={`${r.requester?.name} · ${r.department?.name} · ${date(r.created_at)}`}
action={
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link href={`/requisicoes/${r.id}/processo`}>
Ver processo completo
</Link>
</Button>
<Status value={r.status} />
</div>
}
/>
<FormErrors errors={submission.errors} />
{r.items.some((i) => i.purchase_type === 'equipment') && (
<Panel title="Validação técnica do equipamento">
<p className="mb-4 text-sm">
Responsável da área, distinto do solicitante.
Finanças é obrigatória para qualquer valor. A
adjudicação e a emissão exigem regra de investimento
vigente, com duas aprovações administrativas, e
reserva em dotação de investimento. Sem estas
condições permanecem bloqueadas. O orçamento ainda
não é validado; documentos de desenvolvimento não
autorizam compras reais.
</p>
{technical?.reviews.map((t) => (
<p
key={t.id}
className="mb-3 rounded-lg border p-3 text-sm"
>
{t.decision === 'approved'
? 'Validado'
: 'Rejeitado'}{' '}
por {t.snapshot.reviewer.name} ·{' '}
{t.basis_hash === technical.basis_hash
? 'Versão actual'
: 'Versão anterior, decisão preservada'}{' '}
· {t.reason}
</p>
))}
{technical?.canReview && (
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
review.transform((d) => ({
...d,
basis_hash: technical.basis_hash,
}));
review.post(
`/requisicoes/${r.id}/validacao-tecnica`,
);
}}
>
<select
aria-label="Decisão técnica"
className={controlClass}
value={review.data.decision}
onChange={(e) =>
review.setData(
'decision',
e.target.value,
)
}
>
<option value="approved">
Validar tecnicamente
</option>
<option value="rejected">
Rejeitar tecnicamente
</option>
</select>
<label className="grid gap-2 text-sm">
Fundamento técnico
<textarea
required
className={controlClass}
value={review.data.reason}
onChange={(e) =>
review.setData(
'reason',
e.target.value,
)
}
/>
</label>
<FormErrors errors={review.errors} />
<Button disabled={review.processing}>
Registar decisão técnica
</Button>
</form>
)}
</Panel>
)}
{conflictBlocked && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Existe um conflito de interesses activo que impede a
participação neste processo. As operações são
verificadas pelo servidor.{' '}
<Link href="/conflitos" className="underline">
Consultar as declarações disponíveis para o meu
perfil
</Link>
.
</div>
)}
{canEdit && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border bg-muted/30 p-5">
<p className="text-sm text-muted-foreground">
Confira o pedido antes de o enviar para aprovação.
</p>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/requisicoes/${r.id}/editar`}>
<Pencil className="size-4" />
Editar
</Link>
</Button>
<Button
disabled={submission.processing}
onClick={() =>
submission.post(
`/requisicoes/${r.id}/submeter`,
)
}
>
<Send className="size-4" />
Submeter para aprovação
</Button>
</div>
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Panel title="Detalhes do pedido">
<dl className="grid gap-5 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">
Centro de custo
</dt>
<dd className="mt-1 font-medium">
{r.cost_center?.code} ·{' '}
{r.cost_center?.name}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
Prioridade
</dt>
<dd className="mt-1 font-medium">
{priorityLabels[r.priority]}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
Entrega pretendida
</dt>
<dd className="mt-1 font-medium">
{date(r.needed_by)}
</dd>
</div>
<div className="sm:col-span-3">
<dt className="text-muted-foreground">
Justificação
</dt>
<dd className="mt-2 whitespace-pre-wrap">
{r.justification}
</dd>
</div>
</dl>
</Panel>
<Panel
title="Artigos e valores"
description="Moeda: MZN · Montantes estimados"
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="pb-3">Artigo</th>
<th className="pr-3">Qtd.</th>
<th className="pr-3">
Preço s/ IVA
</th>
<th className="pr-3">IVA</th>
<th className="text-right">
Total c/ IVA
</th>
</tr>
</thead>
<tbody>
{r.items.map((i) => (
<tr key={i.id} className="border-b">
<td className="py-4 pr-4">
<p className="font-medium">
{i.description}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{i.category}
</p>
{i.equipment_id && (
<p className="mt-1 text-xs">
Equipamento #
{i.equipment_id} ·
Peça #
{i.spare_part_id}
{i.downtime_started_at &&
` · Paragem: ${i.downtime_started_at} UTC`}
</p>
)}
{i.production_area && (
<p className="mt-1 text-xs">
Área de destino:{' '}
{i.production_area}
</p>
)}
</td>
<td className="pr-3 whitespace-nowrap">
{Number(i.quantity)}{' '}
{i.unit}
</td>
<td className="pr-3 whitespace-nowrap">
{money(i.unit_price_minor)}
</td>
<td className="pr-3">
{Number(i.tax_rate)}%
</td>
<td className="text-right whitespace-nowrap">
{money(i.total_minor)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-5 ml-auto max-w-xs space-y-3 text-sm">
<div className="flex justify-between">
<dt>Subtotal sem IVA</dt>
<dd>{money(r.subtotal_minor)}</dd>
</div>
<div className="flex justify-between">
<dt>IVA</dt>
<dd>{money(r.tax_minor)}</dd>
</div>
<div className="flex justify-between border-t pt-3 text-base font-semibold">
<dt>Total com IVA</dt>
<dd>{money(r.total_minor)}</dd>
</div>
</dl>
</Panel>
{canDecide && (
<Panel
title="Registar decisão"
description="A decisão fica associada à sua conta e ao histórico deste pedido."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
decision.post(
`/requisicoes/${r.id}/decidir`,
{
onSuccess: () =>
decision.reset(),
},
);
}}
>
<FormErrors errors={decision.errors} />
<div className="space-y-2">
<Label htmlFor="decision">
Decisão
</Label>
<select
id="decision"
className={controlClass}
value={decision.data.decision}
onChange={(e) =>
decision.setData(
'decision',
e.target.value,
)
}
>
<option value="approved">
Aprovar etapa
</option>
<option value="rejected">
Rejeitar pedido
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="comment">
Comentário{' '}
{decision.data.decision ===
'rejected'
? '(obrigatório)'
: '(opcional)'}
</Label>
<textarea
id="comment"
className={`${controlClass} h-24 py-3`}
required={
decision.data.decision ===
'rejected'
}
maxLength={2000}
value={decision.data.comment}
onChange={(e) =>
decision.setData(
'comment',
e.target.value,
)
}
/>
</div>
<Button
disabled={decision.processing}
variant={
decision.data.decision ===
'rejected'
? 'destructive'
: 'default'
}
>
Confirmar decisão
</Button>
</form>
</Panel>
)}
</div>
<div className="space-y-6">
<Panel title="Percurso de aprovação">
{r.steps.length === 0 ? (
<p className="text-sm text-muted-foreground">
As etapas serão calculadas pelo servidor
quando submeter o pedido.
</p>
) : (
<ol className="space-y-5">
{r.steps.map((s) => (
<li className="flex gap-3" key={s.id}>
<span
className={`flex size-8 shrink-0 items-center justify-center rounded-full ${s.decision === 'approved' ? 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'bg-muted text-muted-foreground'}`}
>
{s.decision === 'approved' ? (
<Check className="size-4" />
) : (
<Circle className="size-3" />
)}
</span>
<div>
<p className="text-sm font-medium">
{roleLabels[s.role]}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{s.decision === 'pending'
? r.status ===
'rejected'
? 'Não realizada'
: 'Aguarda decisão'
: s.decision ===
'approved'
? 'Aprovada'
: 'Rejeitada'}
{s.approver_id
? ` · Utilizador #${s.approver_id}`
: ''}
</p>
{s.comment && (
<p className="mt-2 text-sm whitespace-pre-wrap">
{s.comment}
</p>
)}
</div>
</li>
))}
</ol>
)}
{r.approval_snapshot?.rules_provisional && (
<p className="mt-5 border-t pt-4 text-xs text-amber-700 dark:text-amber-400">
Regras provisórias guardadas na submissão.
Base:{' '}
{r.approval_snapshot.approval_includes_tax
? 'com'
: 'sem'}{' '}
IVA.
</p>
)}
</Panel>
<Panel title="Histórico">
<ol className="space-y-4">
{audit.map((a) => (
<li
key={a.id}
className="border-l-2 border-teal-600/30 pl-3"
>
<p className="text-sm font-medium">
{actions[a.action] ?? a.action}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{date(a.created_at)} · Utilizador #
{a.user_id}
</p>
</li>
))}
</ol>
</Panel>
</div>
</div>
</div>
</>
);
}
Show.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Detalhe', href: '#' },
],
};