Files
compras/resources/js/pages/suppliers/evaluation.tsx
T
nala 48347e2d3a
tests / ci (push) Canceled after 0s
commit5
2026-10-30 10:55:18 +02:00

539 lines
25 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import SupplierImprovements from '@/components/supplier-improvements';
import type { ImprovementData } from '@/components/supplier-improvements';
import SupplierResponses from '@/components/supplier-responses';
import type { ResponseData } from '@/components/supplier-responses';
import { PageTitle, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import SupplierEvaluationFields, {
evaluationStates,
criterionStates,
} from '@/components/supplier-evaluation-fields';
import type {
EvaluationPayload,
EvaluationOrder,
} from '@/components/supplier-evaluation-fields';
type Evaluation = {
id: number;
supplier_id: number;
revision: number;
version: number;
status: string;
previous_id: number | null;
created_by: number;
contributors: number[];
supplier_snapshot: { id: number; name: string; nuit: string };
payload: EvaluationPayload;
review_reason: string | null;
reviewed_at: string | null;
};
type History = {
id: number;
action: string;
user_id: number;
after: Evaluation;
created_at: string;
};
const actions: Record<string, string> = {
created: 'Criação',
updated: 'Alteração',
submit: 'Submissão',
approve: 'Aprovação',
return: 'Devolução',
revised: 'Nova revisão',
};
export default function SupplierEvaluation({
evaluation: e,
orders,
canEdit,
canReview,
canRevise,
reportOnly,
nextRevisionId,
history,
people,
notice,
generatedAt,
improvements,
responses,
}: {
evaluation: Evaluation;
orders: EvaluationOrder[];
canEdit: boolean;
canReview: boolean;
canRevise: boolean;
reportOnly: boolean;
nextRevisionId: number | null;
history: {
data: History[];
total: number;
prev_page_url: string | null;
next_page_url: string | null;
};
people: Record<number, string>;
notice?: string;
generatedAt: string;
improvements: ImprovementData;
responses: ResponseData;
}) {
const form = useForm({ version: e.version, payload: e.payload });
const command = useForm({ version: e.version, reason: '' });
const base = `/fornecedores/avaliacoes/${e.id}`;
const dirty =
JSON.stringify(form.data.payload) !== JSON.stringify(e.payload);
const waiting = e.payload.criteria.filter((row) =>
['pending', 'insufficient'].includes(row.result),
).length;
const submit = (action: string) =>
command.post(`${base}/${action}`, {
preserveScroll: true,
preserveState: 'errors',
});
const errors = [
...Object.values(form.errors),
...Object.values(command.errors),
];
return (
<>
<Head title={`AVF-${e.id} · Avaliação de fornecedor`} />
<style>{`@media print { @page {size:A4;margin:14mm} .evaluation-report {font-size:11px;max-width:none;padding:0} .evaluation-report section {box-shadow:none} .evaluation-report h2,.evaluation-report h3 {break-after:avoid} .evaluation-report article {break-inside:avoid} }`}</style>
<div className="evaluation-report mx-auto max-w-5xl space-y-6 px-5 py-8 lg:px-8">
<PageTitle
eyebrow={`AVF-${e.id} · Revisão ${e.revision} · Versão ${e.version}`}
title={e.payload.title}
description={`${e.supplier_snapshot.name} · ${evaluationStates[e.status] ?? e.status}`}
action={
<div className="flex flex-wrap gap-3 print:hidden">
<Button asChild variant="outline">
<Link href="/fornecedores/avaliacoes">
Avaliações
</Link>
</Button>
{!reportOnly && (
<Button asChild variant="outline">
<Link href={`${base}/relatorio`}>
Abrir relatório
</Link>
</Button>
)}
{reportOnly && (
<>
<Button asChild variant="outline">
<Link href={base}>
Voltar à avaliação
</Link>
</Button>
<Button onClick={() => window.print()}>
<Printer className="size-4" />
Imprimir
</Button>
</>
)}
</div>
}
/>
{notice && (
<p
role="status"
className="rounded border border-teal-300 p-3 print:hidden"
>
{notice}
</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
<span>
Autor: {people[e.created_by] ?? `#${e.created_by}`}
</span>
<span>NUIT preservado: {e.supplier_snapshot.nuit}</span>
{e.previous_id && (
<Link
className="underline"
href={`/fornecedores/avaliacoes/${e.previous_id}/relatorio`}
>
Revisão anterior preservada
</Link>
)}
{nextRevisionId && (
<Link
className="underline"
href={`/fornecedores/avaliacoes/${nextRevisionId}`}
>
Abrir revisão posterior
</Link>
)}
</div>
{e.status !== 'approved' && (
<p className="rounded border border-amber-300 p-3 text-sm">
{evaluationStates[e.status]} — este documento ainda não
contém uma aprovação independente.
</p>
)}
<p className="text-sm text-muted-foreground">
Registo qualitativo da prestação da entidade fornecedora.
Sem pontuação ou classificação automática; não altera por si
só a qualificação, suspensão ou elegibilidade do fornecedor.
</p>
{canEdit ? (
<Panel title="Preparar e conservar evidências">
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
form.put(base, {
preserveScroll: true,
preserveState: 'errors',
});
}}
>
<SupplierEvaluationFields
payload={form.data.payload}
orders={orders}
disabled={form.processing || command.processing}
onChange={(payload) =>
form.setData('payload', payload)
}
/>
{errors.map((error, index) => (
<p
key={index}
role="alert"
className="text-sm text-red-700"
>
{error}
</p>
))}
<div className="flex flex-wrap gap-3">
<Button
disabled={
form.processing || command.processing
}
>
Guardar rascunho
</Button>
<Button
variant="outline"
type="button"
disabled={
form.processing ||
command.processing ||
dirty
}
onClick={() => submit('submit')}
>
Submeter para revisão
</Button>
</div>
{dirty && (
<p className="text-sm text-muted-foreground">
Guarde as alterações antes de submeter. A
revisão compara os dados efectivamente
guardados.
</p>
)}
</form>
</Panel>
) : (
<>
<Panel title="Âmbito e evidências">
<div className="space-y-4 text-sm">
<p>
Componente:{' '}
{e.payload.nature === 'goods'
? 'Bens'
: 'Serviços'}{' '}
· Período:{' '}
{e.payload.period_start ?? 'Por indicar'} —{' '}
{e.payload.period_end ?? 'Por indicar'}.
</p>
<p className="whitespace-pre-wrap">
{e.payload.scope ||
'Âmbito ainda não indicado.'}
</p>
<div>
Ordens abrangidas:{' '}
{e.payload.order_ids.length
? e.payload.order_ids.map((id) => (
<Link
key={id}
className="mr-3 underline"
href={`/ordens/${id}`}
>
{orders.find(
(order) =>
order.id === id,
)?.number ?? `#${id}`}
</Link>
))
: 'Sem ordens associadas; conferir as referências declaradas no âmbito.'}
</div>
<p>
{e.payload.criteria.length} critérios ·{' '}
{waiting} por analisar ou com evidência
insuficiente.
</p>
{e.payload.criteria.map((row, index) => (
<article
key={index}
className="space-y-2 rounded border p-3"
>
<h3 className="font-medium">
{index + 1}.{' '}
{row.label ||
'Critério por identificar'}
</h3>
<p>{criterionStates[row.result]}</p>
<p className="whitespace-pre-wrap">
Justificação:{' '}
{row.reason || 'Por completar'}
</p>
<p className="whitespace-pre-wrap">
Evidência declarada:{' '}
{row.evidence || 'Não indicada'}
</p>
</article>
))}
<h3 className="font-medium">Conclusão</h3>
<p className="whitespace-pre-wrap">
{e.payload.conclusion ||
'Ainda não registada.'}
</p>
<h3 className="font-medium">
Ocorrências críticas
</h3>
<p className="whitespace-pre-wrap">
{e.payload.critical_occurrences ||
'Sem informação registada neste campo; não equivale a uma declaração de ausência de ocorrências.'}
</p>
{e.payload.critical_evidence && (
<p className="whitespace-pre-wrap">
Evidência: {e.payload.critical_evidence}
</p>
)}
</div>
</Panel>
</>
)}
{e.review_reason && (
<Panel title="Última decisão documentada">
<p className="text-sm whitespace-pre-wrap">
{e.review_reason}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{e.reviewed_at}
</p>
</Panel>
)}
{canReview && (
<Panel title="Conferência independente">
<form
className="space-y-4"
onSubmit={(event) => event.preventDefault()}
>
<p className="text-sm">
Confirme âmbito, atribuição das ocorrências,
aplicabilidade e referências. Evidências
pendentes não são convertidas em notas nem
ignoradas.
</p>
<label className="grid gap-1 text-sm">
Fundamento da decisão
<textarea
className={
controlClass + ' h-auto min-h-24 py-2'
}
value={command.data.reason}
minLength={10}
maxLength={4000}
onChange={(event) =>
command.setData(
'reason',
event.target.value,
)
}
/>
</label>
{errors.map((error, index) => (
<p
key={index}
role="alert"
className="text-sm text-red-700"
>
{error}
</p>
))}
<div className="flex flex-wrap gap-3">
<Button
type="button"
disabled={command.processing || waiting > 0}
onClick={() => submit('approve')}
>
Aprovar avaliação
</Button>
<Button
type="button"
variant="outline"
disabled={command.processing}
onClick={() => submit('return')}
>
Devolver para correcção
</Button>
</div>
{waiting > 0 && (
<p className="text-sm text-amber-800">
Existem {waiting} critérios pendentes.
Devolva para completar as evidências antes
de aprovar.
</p>
)}
</form>
</Panel>
)}
{e.status === 'review' && !canReview && !reportOnly && (
<p className="text-sm">
Aguarda conferência por outra pessoa autorizada que não
tenha elaborado ou submetido esta revisão.
</p>
)}
{canRevise && (
<Panel title="Rectificar sem apagar a avaliação aprovada">
<p className="mb-3 text-sm">
A rectificação cria outro rascunho, com número de
revisão próprio. A versão aprovada anterior mantém o
seu relatório e histórico.
</p>
<Button
disabled={command.processing}
onClick={() => submit('rever')}
>
Preparar nova revisão
</Button>
{errors.map((error, index) => (
<p
key={index}
role="alert"
className="mt-2 text-sm text-red-700"
>
{error}
</p>
))}
</Panel>
)}
{!reportOnly && (
<Panel title="Histórico de alterações e decisões">
<p className="mb-3 text-sm">
{history.total} registos preservados. Cada alteração
conserva a sua fotografia; versões aprovadas não são
reescritas.
</p>
<div className="space-y-3">
{history.data.map((event) => (
<details
key={event.id}
className="rounded border p-3 text-sm"
>
<summary className="cursor-pointer">
{actions[
event.action.split('.').at(-1) ?? ''
] ?? event.action}{' '}
· Versão {event.after.version} ·{' '}
{people[event.user_id] ??
`#${event.user_id}`}{' '}
·{' '}
{new Date(
event.created_at,
).toLocaleString('pt-MZ')}
</summary>
<div className="mt-3 space-y-2">
<p>
{
evaluationStates[
event.after.status
]
}{' '}
· {event.after.payload.title}
</p>
<p className="whitespace-pre-wrap">
{event.after.payload.scope}
</p>
{event.after.payload.criteria.map(
(row, index) => (
<p
key={index}
className="whitespace-pre-wrap"
>
{row.label} ·{' '}
{
criterionStates[
row.result
]
}{' '}
· {row.reason} · Evidência:{' '}
{row.evidence ||
'Não indicada'}
</p>
),
)}
<p className="whitespace-pre-wrap">
{event.after.payload.conclusion}
</p>
{event.after.review_reason && (
<p className="whitespace-pre-wrap">
Decisão:{' '}
{event.after.review_reason}
</p>
)}
</div>
</details>
))}
</div>
<nav
aria-label="Paginação do histórico"
className="mt-4 flex gap-4 text-sm"
>
{history.prev_page_url && (
<Link
className="underline"
href={history.prev_page_url}
>
Anterior
</Link>
)}
{history.next_page_url && (
<Link
className="underline"
href={history.next_page_url}
>
Seguinte
</Link>
)}
</nav>
</Panel>
)}
<SupplierImprovements
data={improvements}
evaluationId={e.id}
names={people}
reportOnly={reportOnly}
generatedAt={generatedAt}
/>
<SupplierResponses
data={responses}
evaluationId={e.id}
names={people}
reportOnly={reportOnly}
generatedAt={generatedAt}
/>
<footer className="border-t pt-4 text-xs text-muted-foreground">
SGC · AVF-{e.id} · Revisão {e.revision} ·{' '}
{evaluationStates[e.status]} · Consulta de{' '}
{new Date(generatedAt).toLocaleString('pt-MZ')}. As
referências são declarações documentadas, não verificação
automática do conteúdo.
</footer>
</div>
</>
);
}