539 lines
25 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|