492 lines
25 KiB
TypeScript
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: '#' },
|
|
],
|
|
};
|