629 lines
36 KiB
TypeScript
629 lines
36 KiB
TypeScript
import { Head, Link } from '@inertiajs/react';
|
|
import { Printer } from 'lucide-react';
|
|
import { PageTitle, Panel, Status } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { date, roleLabels, statusLabels } from '@/types/procurement';
|
|
|
|
type Criterion = {
|
|
id: number;
|
|
label: string;
|
|
category: string;
|
|
required: boolean;
|
|
};
|
|
type Answer = {
|
|
criterion_id: number;
|
|
result: 'pending' | 'met' | 'not_met' | 'not_applicable';
|
|
evidence: string;
|
|
reason: string;
|
|
};
|
|
type Analysis = {
|
|
id: number;
|
|
revision: number;
|
|
analyst: { name: string; purchasing_role: string };
|
|
created_at: string;
|
|
reason: string;
|
|
answers: Answer[];
|
|
};
|
|
type ComparisonRow = {
|
|
id: number;
|
|
reference: string;
|
|
supplier: string;
|
|
currency: string;
|
|
subtotal_minor: number;
|
|
tax_minor: number;
|
|
total_minor: number;
|
|
delivery_days: number;
|
|
valid_until: string;
|
|
evaluation_count: number;
|
|
score_minor: number | null;
|
|
eligible: boolean;
|
|
ineligibility_reasons: string[];
|
|
is_top: boolean;
|
|
weights: Record<string, number>;
|
|
};
|
|
type Report = {
|
|
generated_at: string;
|
|
generated_by: string;
|
|
company: string;
|
|
requisition: {
|
|
id: number;
|
|
number: string;
|
|
status: string;
|
|
justification: string;
|
|
department: string;
|
|
requester: string;
|
|
};
|
|
comparison: { ready: boolean; top_count: number; rows: ComparisonRow[] };
|
|
recommendation: { id: number; revision: number } | null;
|
|
admission: {
|
|
id: number;
|
|
revision: number;
|
|
reason: string;
|
|
snapshot: {
|
|
policy_revision: number;
|
|
required_count: number;
|
|
roles: string[];
|
|
};
|
|
} | null;
|
|
qualification: {
|
|
id: number;
|
|
revision: number;
|
|
created_at: string;
|
|
author: string;
|
|
reason: string;
|
|
criteria: Criterion[];
|
|
is_current: boolean;
|
|
proposals: {
|
|
id: number;
|
|
reference: string;
|
|
supplier: string;
|
|
analyses: Analysis[];
|
|
}[];
|
|
} | null;
|
|
opening: {
|
|
id: number;
|
|
revision: number;
|
|
reference: string;
|
|
source_changed: boolean;
|
|
} | null;
|
|
award: { id: number; status: string; total_minor: number } | null;
|
|
order: {
|
|
id: number;
|
|
number: string;
|
|
currency: string;
|
|
total_minor: number;
|
|
} | null;
|
|
};
|
|
const money = (minor: number, currency: string) =>
|
|
`${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
|
const results: Record<Answer['result'], string> = {
|
|
pending: 'Por analisar',
|
|
met: 'Cumprido',
|
|
not_met: 'Não cumprido',
|
|
not_applicable: 'Não aplicável',
|
|
};
|
|
const weights: Record<string, string> = {
|
|
price: 'Preço',
|
|
quality: 'Qualidade',
|
|
delivery: 'Entrega',
|
|
history: 'Histórico',
|
|
payment: 'Pagamento',
|
|
};
|
|
const categories: Record<string, string> = {
|
|
legal: 'Jurídica',
|
|
financial: 'Económico-financeira',
|
|
fiscal: 'Fiscal',
|
|
technical: 'Técnica',
|
|
administrative: 'Administrativa',
|
|
};
|
|
|
|
export default function ReviewReport({ report }: { report: Report }) {
|
|
const r = report.requisition;
|
|
const qualification = report.qualification;
|
|
const generated = new Date(report.generated_at).toLocaleString('pt-PT', {
|
|
timeZone: 'Africa/Johannesburg',
|
|
});
|
|
return (
|
|
<>
|
|
<Head title={`Relatório de conferência · ${r.number}`} />
|
|
<style>{`@media print { @page { size: A4 landscape; margin: 14mm; } .review-report { max-width: none; padding: 0; font-size: 11px; gap: 18px; } .review-report section { box-shadow: none; } .review-report table { font-size: 10px; table-layout: fixed; width: 100%; } .review-report th, .review-report td { overflow-wrap: anywhere; } .review-report thead { display: table-header-group; } .review-report tr { break-inside: avoid; } .review-report h2, .review-report h3 { break-after: avoid; } }`}</style>
|
|
<div className="review-report mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<header className="hidden border-b-2 border-[#ed1c24] pb-3 print:block">
|
|
<p className="font-semibold">
|
|
SGC · Sistema de Gestão de Compras
|
|
</p>
|
|
<p>{report.company}</p>
|
|
</header>
|
|
<PageTitle
|
|
eyebrow={r.number}
|
|
title="Relatório de conferência"
|
|
description={`${report.company} · ${r.department} · Solicitante: ${r.requester}`}
|
|
action={
|
|
<div className="flex gap-3 print:hidden">
|
|
<Button asChild variant="outline">
|
|
<Link href={`/requisicoes/${r.id}/processo`}>
|
|
Ver processo
|
|
</Link>
|
|
</Button>
|
|
<Button onClick={() => window.print()}>
|
|
<Printer className="size-4" />
|
|
Imprimir esta consulta
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="rounded-xl border p-4 text-sm">
|
|
<p>
|
|
Consultado em {generated} · Africa/Johannesburg (UTC+2),
|
|
por {report.generated_by}.
|
|
</p>
|
|
<p className="mt-2 text-muted-foreground">
|
|
A impressão usa os dados desta consulta. Actualizar a
|
|
página gera uma nova consulta; este relatório não é uma
|
|
versão assinada ou guardada como decisão.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950">
|
|
Relatório de consulta, sem recomendação de vencedor,
|
|
admissão ou exclusão de concorrentes, certificação de
|
|
evidências ou autorização financeira. A comparação actual e
|
|
a qualificação documental são apresentadas separadamente.
|
|
</div>
|
|
<Panel title="Identificação do pedido">
|
|
<p className="mb-4 text-sm">
|
|
{report.recommendation
|
|
? 'Regime de recomendação adoptado. A adjudicação exige recomendação confirmada e actual.'
|
|
: 'Sem adopção do regime de recomendação.'}{' '}
|
|
<Link
|
|
className="underline print:hidden"
|
|
href={`/requisicoes/${r.id}/recomendacao`}
|
|
>
|
|
Consultar recomendação e relatório preservado
|
|
</Link>
|
|
</p>
|
|
<p className="mb-4 text-sm">
|
|
{report.admission
|
|
? `Regime de admissão adoptado: política ${report.admission.snapshot.policy_revision}, ${report.admission.snapshot.required_count} confirmações independentes. A elegibilidade da comparação inclui admissão confirmada na base actual.`
|
|
: 'Sem adopção do regime de admissão: mantém-se o circuito de comparação existente.'}{' '}
|
|
<Link
|
|
className="underline print:hidden"
|
|
href={`/requisicoes/${r.id}/admissao`}
|
|
>
|
|
Consultar decisões e conferências de admissão
|
|
</Link>
|
|
</p>
|
|
{r.status !== 'approved' && (
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
O pedido não está em estado aprovado. As pontuações
|
|
abaixo são uma consulta da matriz e não autorizam
|
|
novas decisões ou emissão.
|
|
</p>
|
|
)}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="max-w-4xl text-sm whitespace-pre-wrap">
|
|
{r.justification}
|
|
</p>
|
|
<Status value={r.status} />
|
|
</div>
|
|
</Panel>
|
|
<Panel
|
|
title="Comparação actual das propostas"
|
|
description="Dados e pontuações devolvidos pelo método de comparação existente. A ordem abaixo é a de registo, sem nova classificação."
|
|
>
|
|
<p className="mb-4 text-sm font-medium">
|
|
{report.comparison.ready
|
|
? 'A comparação está pronta segundo o método existente. Isto não confirma orçamento, aprovações ou autorização para emitir uma ordem.'
|
|
: 'A matriz ainda não reúne as condições do circuito actual. Não se apresenta uma proposta recomendada.'}
|
|
</p>
|
|
{report.comparison.rows.length ? (
|
|
<>
|
|
<div className="overflow-x-auto print:overflow-visible">
|
|
<table className="w-full border-collapse text-left text-sm">
|
|
<thead>
|
|
<tr>
|
|
{[
|
|
'Concorrente / proposta',
|
|
'Sem IVA',
|
|
'IVA',
|
|
'Total',
|
|
'Avaliações e pontuação',
|
|
'Verificações do circuito actual',
|
|
].map((label) => (
|
|
<th
|
|
key={label}
|
|
className="border bg-muted/40 p-3 font-medium"
|
|
>
|
|
{label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{report.comparison.rows.map((row) => (
|
|
<tr key={row.id}>
|
|
<td className="border p-3 align-top">
|
|
<p className="font-semibold">
|
|
{row.supplier}
|
|
</p>
|
|
<p className="mt-1">
|
|
{row.reference}
|
|
</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Entrega:{' '}
|
|
{row.delivery_days} dias
|
|
· Validade:{' '}
|
|
{date(row.valid_until)}
|
|
</p>
|
|
</td>
|
|
<td className="border p-3 align-top">
|
|
{money(
|
|
row.subtotal_minor,
|
|
row.currency,
|
|
)}
|
|
</td>
|
|
<td className="border p-3 align-top">
|
|
{money(
|
|
row.tax_minor,
|
|
row.currency,
|
|
)}
|
|
</td>
|
|
<td className="border p-3 align-top">
|
|
{money(
|
|
row.total_minor,
|
|
row.currency,
|
|
)}
|
|
</td>
|
|
<td className="border p-3 align-top">
|
|
<p>
|
|
{row.evaluation_count}{' '}
|
|
avaliações válidas
|
|
</p>
|
|
<p className="mt-2">
|
|
Pontuação:{' '}
|
|
{row.score_minor ===
|
|
null
|
|
? 'Não calculada'
|
|
: (
|
|
row.score_minor /
|
|
100
|
|
).toFixed(2)}
|
|
</p>
|
|
{row.is_top && (
|
|
<p className="mt-2 font-medium">
|
|
{report.comparison
|
|
.top_count > 1
|
|
? 'Maior pontuação em empate; sem desempate automático.'
|
|
: 'Maior pontuação da matriz; não é uma adjudicação.'}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="border p-3 align-top">
|
|
{row.ineligibility_reasons
|
|
.length ? (
|
|
<ul className="list-inside list-disc space-y-1">
|
|
{row.ineligibility_reasons.map(
|
|
(reason) => (
|
|
<li
|
|
key={
|
|
reason
|
|
}
|
|
>
|
|
{reason}
|
|
</li>
|
|
),
|
|
)}
|
|
</ul>
|
|
) : (
|
|
<p>
|
|
{report.admission
|
|
? 'Sem impedimentos nesta matriz, incluindo admissão confirmada na base actual. Não autoriza compra.'
|
|
: 'Sem impedimentos assinalados por esta matriz. Não equivale a admissão formal.'}
|
|
</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
{report.comparison.rows.map((row) => (
|
|
<p
|
|
key={row.id}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Ponderações de {row.reference}:{' '}
|
|
{Object.entries(row.weights)
|
|
.map(
|
|
([key, value]) =>
|
|
`${weights[key] ?? key} ${value}%`,
|
|
)
|
|
.join(' · ')}
|
|
.
|
|
</p>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem propostas registadas.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
<Panel
|
|
title="Qualificação documental"
|
|
description="Apenas a versão mais recente dos requisitos e o último registo de cada analista por proposta. As correcções anteriores mantêm-se no histórico do módulo."
|
|
>
|
|
{qualification ? (
|
|
<>
|
|
<p className="text-sm font-medium">
|
|
Requisitos v{qualification.revision} ·{' '}
|
|
{qualification.is_current
|
|
? 'Base documental actual'
|
|
: 'Base documental histórica ou desactualizada'}
|
|
</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Registados por {qualification.author} em{' '}
|
|
{date(qualification.created_at)}.
|
|
</p>
|
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
|
{qualification.reason}
|
|
</p>
|
|
{!qualification.is_current && (
|
|
<p className="mt-4 rounded-lg border border-amber-300 p-3 text-sm">
|
|
Os resultados abaixo referem-se às propostas
|
|
preservadas nesta versão. Não os aplique à
|
|
comparação actual; nomes e referências
|
|
abaixo pertencem à base documental original.
|
|
</p>
|
|
)}
|
|
<ul className="mt-4 space-y-2">
|
|
{qualification.criteria.map((criterion) => (
|
|
<li
|
|
key={criterion.id}
|
|
className="rounded-lg border p-3 text-sm"
|
|
>
|
|
<strong className="font-medium">
|
|
{criterion.label}
|
|
</strong>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{categories[criterion.category]} ·{' '}
|
|
{criterion.required
|
|
? 'Declarado obrigatório segundo o processo'
|
|
: 'Sem obrigatoriedade declarada'}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="mt-6 space-y-6">
|
|
{qualification.proposals.map((proposal) => (
|
|
<section key={proposal.id}>
|
|
<h3 className="text-base font-semibold">
|
|
{proposal.supplier} ·{' '}
|
|
{proposal.reference}
|
|
</h3>
|
|
{proposal.analyses.length ? (
|
|
proposal.analyses.map(
|
|
(analysis) => (
|
|
<div
|
|
key={analysis.id}
|
|
className="mt-3 rounded-xl border p-4"
|
|
>
|
|
<h4 className="text-sm font-semibold">
|
|
{
|
|
analysis.analyst
|
|
.name
|
|
}{' '}
|
|
·{' '}
|
|
{
|
|
roleLabels[
|
|
analysis
|
|
.analyst
|
|
.purchasing_role
|
|
]
|
|
}{' '}
|
|
· Análise v
|
|
{analysis.revision}
|
|
</h4>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{date(
|
|
analysis.created_at,
|
|
)}{' '}
|
|
·{' '}
|
|
{
|
|
analysis.answers.filter(
|
|
(answer) =>
|
|
answer.result ===
|
|
'pending',
|
|
).length
|
|
}{' '}
|
|
por analisar ·{' '}
|
|
{
|
|
analysis.answers.filter(
|
|
(answer) =>
|
|
answer.result ===
|
|
'not_met',
|
|
).length
|
|
}{' '}
|
|
não cumpridos
|
|
</p>
|
|
<p className="mt-3 text-sm whitespace-pre-wrap">
|
|
{analysis.reason}
|
|
</p>
|
|
<table className="mt-4 w-full border-collapse text-left text-sm">
|
|
<thead>
|
|
<tr>
|
|
{[
|
|
'Requisito',
|
|
'Resultado',
|
|
'Referência da evidência',
|
|
'Fundamento',
|
|
].map(
|
|
(
|
|
label,
|
|
) => (
|
|
<th
|
|
key={
|
|
label
|
|
}
|
|
className="border bg-muted/40 p-2 font-medium"
|
|
>
|
|
{
|
|
label
|
|
}
|
|
</th>
|
|
),
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{analysis.answers.map(
|
|
(
|
|
answer,
|
|
) => (
|
|
<tr
|
|
key={
|
|
answer.criterion_id
|
|
}
|
|
>
|
|
<td className="border p-2 align-top">
|
|
{
|
|
qualification.criteria.find(
|
|
(
|
|
criterion,
|
|
) =>
|
|
criterion.id ===
|
|
answer.criterion_id,
|
|
)
|
|
?.label
|
|
}
|
|
</td>
|
|
<td className="border p-2 align-top">
|
|
{
|
|
results[
|
|
answer
|
|
.result
|
|
]
|
|
}
|
|
</td>
|
|
<td className="border p-2 align-top whitespace-pre-wrap">
|
|
{answer.evidence ||
|
|
'Não indicada'}
|
|
</td>
|
|
<td className="border p-2 align-top whitespace-pre-wrap">
|
|
{answer.reason ||
|
|
'Sem fundamento registado; por analisar.'}
|
|
</td>
|
|
</tr>
|
|
),
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
),
|
|
)
|
|
) : (
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
Sem registos de analistas para
|
|
esta proposta nesta versão. Não
|
|
se presume uma análise concluída
|
|
ou uma dispensa.
|
|
</p>
|
|
)}
|
|
</section>
|
|
))}
|
|
</div>
|
|
<p className="mt-5 text-sm text-muted-foreground">
|
|
Não há consolidação por maioria ou quórum. Não
|
|
aplicável não dispensa automaticamente um
|
|
requisito declarado obrigatório.
|
|
</p>
|
|
<Link
|
|
href={`/requisicoes/${r.id}/qualificacao?version=${qualification.id}`}
|
|
className="mt-4 inline-flex text-sm underline print:hidden"
|
|
>
|
|
Consultar requisitos e histórico completo
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem qualificação documental registada. A ausência do
|
|
registo não constitui admissão ou exclusão de
|
|
concorrentes.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
<Panel title="Registos do processo para consulta">
|
|
<div className="space-y-3 text-sm">
|
|
{report.opening ? (
|
|
<div>
|
|
<p>
|
|
Acta {report.opening.reference} · Versão{' '}
|
|
{report.opening.revision}.
|
|
</p>
|
|
{report.opening.source_changed && (
|
|
<p className="mt-1 text-muted-foreground">
|
|
Há diferenças ou propostas não incluídas
|
|
face aos dados actuais. A acta conserva
|
|
a sua base original.
|
|
</p>
|
|
)}
|
|
<Link
|
|
href={`/requisicoes/${r.id}/abertura`}
|
|
className="mt-2 inline-flex underline print:hidden"
|
|
>
|
|
Consultar actas
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<p>
|
|
Sem acta de abertura registada neste módulo
|
|
opcional.
|
|
</p>
|
|
)}
|
|
{report.award ? (
|
|
<p>
|
|
Adjudicação #{report.award.id} ·{' '}
|
|
{statusLabels[report.award.status] ??
|
|
report.award.status}{' '}
|
|
· Valor preservado:{' '}
|
|
{money(report.award.total_minor, 'MZN')}.
|
|
</p>
|
|
) : (
|
|
<p>Sem adjudicação registada.</p>
|
|
)}
|
|
{report.order ? (
|
|
<p>
|
|
Ordem {report.order.number} · Valor preservado:{' '}
|
|
{money(
|
|
report.order.total_minor,
|
|
report.order.currency,
|
|
)}
|
|
.
|
|
</p>
|
|
) : (
|
|
<p>Sem ordem emitida.</p>
|
|
)}
|
|
<p className="text-muted-foreground">
|
|
Valores de documentos emitidos ou da adjudicação são
|
|
os seus registos preservados; não são recalculados
|
|
por este relatório.
|
|
</p>
|
|
</div>
|
|
</Panel>
|
|
<footer className="border-t pt-4 text-xs text-muted-foreground">
|
|
SGC · Sistema de Gestão de Compras · {r.number} · Consulta
|
|
de {generated}, Africa/Johannesburg (UTC+2). Decisão formal
|
|
e autorização financeira seguem os seus circuitos próprios.
|
|
</footer>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
ReviewReport.layout = {
|
|
breadcrumbs: [
|
|
{ title: 'Requisições', href: '/requisicoes' },
|
|
{ title: 'Relatório de conferência', href: '#' },
|
|
],
|
|
};
|