Files
compras/resources/js/pages/purchases/review-report.tsx
T

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: '#' },
],
};