Files
compras/resources/js/pages/requisitions/show.tsx
T
2026-11-03 11:51:44 +02:00

778 lines
41 KiB
TypeScript

import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { Check, Circle, Pencil, Send } from 'lucide-react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
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;
execution?: string | null;
};
const actions: Record<string, string> = {
'requisition.created': 'Rascunho criado',
'requisition.automatically_created':
'Rascunho de reposição criado automaticamente',
'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,
auditPagination,
technical,
comparisonPolicy,
replenishmentSource,
automaticSource,
approvalRouting,
canRefreshRouting = false,
}: {
requisition: Requisition;
canRefreshRouting?: boolean;
approvalRouting?: {
status: string;
step_id: number | null;
role: string | null;
people: { id: number; name: string }[];
checked_at: string;
};
comparisonPolicy: ComparisonPolicy;
replenishmentSource?: {
approval_version: number;
prepared_on: string;
quantity_milli: number;
unit: string;
delivery_on: string;
purpose: string;
origin: string;
} | null;
canEdit: boolean;
automaticSource?: {
need_id: number;
rule_id: number;
created_at: string;
origins: {
warehouse_request_id: number;
href: string | null;
destination: string;
equipment: { code: string; description: string } | null;
remaining_milli: number;
}[];
} | null;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
auditPagination?: {
total: number;
current_page: number;
last_page: number;
prev_page_url: string | null;
next_page_url: string | null;
};
technical?: {
canReview: boolean;
basis_hash: string;
reviews: {
id: number;
decision: string;
reason: string;
basis_hash: string;
snapshot: { reviewer: { name: string } };
}[];
};
}) {
const ready = usePage().props.internalRequisitionReady === true;
const submission = useForm({
operation_key: ready ? crypto.randomUUID() : null,
process_version: r.process_version ?? null,
});
const routing = useForm({
operation_key: crypto.randomUUID(),
process_version: r.process_version ?? 1,
step_id: approvalRouting?.step_id,
reason: '',
});
const review = useForm({
basis_hash: technical?.basis_hash ?? '',
decision: 'approved',
reason: '',
});
const decision = useForm({
operation_key: ready ? crypto.randomUUID() : null,
process_version: r.process_version ?? null,
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">
{r.process_version && (
<p className="text-sm">
Compras internas · Versão {r.process_version}
</p>
)}
<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} />
{automaticSource && (
<Panel title="Origem na reposição automática">
<p>
Preparada automaticamente em{' '}
{automaticSource.created_at}, pela parametrização #
{automaticSource.rule_id}. Necessidade #
{automaticSource.need_id}. O requisitante confere e
submete às aprovações habituais.
</p>
{automaticSource.origins.map((o, i) => (
<p key={i}>
{o.href ? (
<Link href={o.href}>
Requisição ao armazém RA-
{o.warehouse_request_id}
</Link>
) : (
<>
Requisição ao armazém RA-
{o.warehouse_request_id}
</>
)}{' '}
· Utilização: {o.destination}
{o.equipment
? ` · Equipamento ${o.equipment.code} · ${o.equipment.description}`
: ''}{' '}
· Saldo na origem: {o.remaining_milli / 1000}
</p>
))}
</Panel>
)}
{replenishmentSource && (
<Panel title="Origem na reposição">
<p className="text-sm">
Criada a partir de uma sugestão de reposição, versão
aprovada {replenishmentSource.approval_version},
preparada em {replenishmentSource.prepared_on}.
Proposta de{' '}
{(
replenishmentSource.quantity_milli / 1000
).toLocaleString('pt-MZ', {
maximumFractionDigits: 3,
})}{' '}
{replenishmentSource.unit}, para{' '}
{replenishmentSource.delivery_on}.
</p>
<p className="mt-2 text-sm text-muted-foreground">
{replenishmentSource.purpose === 'resale'
? 'Revenda'
: 'Uso interno'}{' '}
·{' '}
{replenishmentSource.origin === 'import'
? 'Importação directa'
: 'Compra local'}
. A preparação e as decisões originais permanecem
preservadas; esta requisição segue o circuito
habitual.
</p>
</Panel>
)}
<Panel title="Regra de comparação deste processo">
<ComparisonPolicySummary policy={comparisonPolicy} />
</Panel>
{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">
{approvalRouting &&
approvalRouting.status !== 'inactive' && (
<Panel
title="Quem pode tratar a etapa actual"
description="Consulta das permissões e impedimentos actuais, respeitando o percurso já aprovado."
>
<p className="text-sm">
Etapa:{' '}
{roleLabels[
approvalRouting.role ?? ''
] ?? approvalRouting.role}
.
</p>
{canRefreshRouting && (
<details className="my-3 rounded-md border p-3">
<summary className="cursor-pointer text-sm font-medium">
Actualizar encaminhamento
</summary>
<form
className="mt-3 space-y-3"
onSubmit={(event) => {
event.preventDefault();
routing.post(
`/requisicoes/${r.id}/encaminhamento`,
{
preserveState:
'errors',
preserveScroll: true,
},
);
}}
>
<p className="text-sm">
Avisa novas pessoas
actualmente elegíveis para
esta etapa. Não muda perfis,
aprovações ou avisos já
lidos.
</p>
<FormErrors
errors={routing.errors}
/>
<label className="block text-sm">
Motivo
<input
className={controlClass}
required
minLength={10}
value={
routing.data.reason
}
onChange={(event) =>
routing.setData(
'reason',
event.target
.value,
)
}
/>
</label>
<Button
disabled={
routing.processing
}
>
Actualizar avisos da etapa
</Button>
</form>
</details>
)}
{approvalRouting.people.length === 0 ? (
<p
className="mt-3 text-sm"
role="status"
>
Sem pessoa actualmente elegível para
decidir esta etapa. A administração
deve conferir perfis, departamento,
validade dos acessos e impedimentos.
O pedido continua pendente.
</p>
) : (
<>
<ul className="mt-3 space-y-1 text-sm">
{approvalRouting.people.map(
(person) => (
<li key={person.id}>
{person.name}
</li>
),
)}
</ul>
<p className="mt-3 text-xs text-muted-foreground">
Estas pessoas podem consultar a
tarefa em As minhas aprovações.
A lista é uma fila de elegíveis,
sem atribuição exclusiva ou
aviso enviado; o servidor volta
a validar a autorização ao
decidir.
</p>
</>
)}
</Panel>
)}
<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">
<div id="requisition-history" />
<a
className="mb-4 inline-block text-sm underline"
href={`/requisicoes/${r.id}/historico.csv`}
>
Exportar metadados do histórico (CSV)
</a>
<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)} ·{' '}
{a.execution ===
'scheduled_replenishment'
? 'Execução periódica · Responsável de origem #'
: 'Utilizador #'}
{a.user_id}
</p>
</li>
))}
</ol>
{auditPagination && (
<nav
aria-label="Paginação do histórico da requisição"
className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm"
>
<span>
{auditPagination.total} eventos · Página{' '}
{auditPagination.current_page} de{' '}
{auditPagination.last_page}
</span>
<div className="flex gap-4">
{auditPagination.prev_page_url && (
<Link
preserveState
preserveScroll
only={[
'audit',
'auditPagination',
]}
className="underline"
href={`${auditPagination.prev_page_url}#requisition-history`}
>
Anterior
</Link>
)}
{auditPagination.next_page_url && (
<Link
preserveState
preserveScroll
only={[
'audit',
'auditPagination',
]}
className="underline"
href={`${auditPagination.next_page_url}#requisition-history`}
>
Seguinte
</Link>
)}
</div>
</nav>
)}
</Panel>
</div>
</div>
</div>
</>
);
}
Show.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Detalhe', href: '#' },
],
};