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

429 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Head, Link, useForm } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import { PageTitle, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Filters = {
supplier_id?: string;
responsible_id?: string;
status?: string;
from?: string;
to?: string;
overdue?: string;
search?: string;
};
type Row = {
id: number;
evaluation_id: number;
revision: number;
supplier: string;
responsible: string;
title: string;
description: string;
due_on: string;
status: string;
version: number;
overdue: boolean;
result: string | null;
evidence: string | null;
review_reason: string | null;
};
const states: Record<string, string> = {
open: 'Em execução',
review: 'A aguardar conferência',
returned: 'Devolvida para correcção',
closed: 'Encerrada',
};
const base = '/relatorios/medidas-fornecedores';
function query(filters: Filters) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(filters))
if (value) search.set(key, String(value));
return search.size ? '?' + search.toString() : '';
}
export default function SupplierImprovementReport({
records,
filters,
counts,
overdue,
suppliers,
responsibles,
today,
generatedAt,
}: {
records: {
data: Row[];
total: number;
from: number | null;
to: number | null;
current_page: number;
last_page: number;
prev_page_url: string | null;
next_page_url: string | null;
};
filters: Filters;
counts: Record<string, number>;
overdue: number;
suppliers: { id: number; name: string }[];
responsibles: { id: number; name: string }[];
today: string;
generatedAt: string;
}) {
const form = useForm({
supplier_id: filters.supplier_id ?? '',
responsible_id: filters.responsible_id ?? '',
status: filters.status ?? '',
from: filters.from ?? '',
to: filters.to ?? '',
overdue: filters.overdue ?? '',
search: filters.search ?? '',
});
const applied = query(filters);
return (
<>
<Head title="Medidas de melhoria de fornecedores" />
<div className="mx-auto max-w-7xl space-y-6 px-5 py-8 lg:px-8">
<PageTitle
eyebrow="Relatórios · Fornecedores"
title="Medidas de melhoria"
description="Acompanhar responsáveis, prazos, execução e encerramentos em todas as avaliações autorizadas."
action={
<div className="flex flex-wrap gap-3 print:hidden">
<Button asChild variant="outline">
<Link href="/fornecedores/avaliacoes">
Avaliações
</Link>
</Button>
<Button asChild variant="outline">
<a href={base + '.csv' + applied}>
Exportar todos os resultados (CSV)
</a>
</Button>
<Button
variant="outline"
onClick={() => window.print()}
>
<Printer className="size-4" />
Imprimir esta página
</Button>
</div>
}
/>
<Panel title="Filtros do acompanhamento">
<form
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 print:hidden"
onSubmit={(e) => {
e.preventDefault();
form.get(base, {
preserveState: 'errors',
preserveScroll: true,
});
}}
>
<label className="space-y-1">
Fornecedor
<select
className={controlClass}
value={form.data.supplier_id}
onChange={(e) =>
form.setData('supplier_id', e.target.value)
}
>
<option value="">Todos os autorizados</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
Responsável
<select
className={controlClass}
value={form.data.responsible_id}
onChange={(e) =>
form.setData(
'responsible_id',
e.target.value,
)
}
>
<option value="">Todos os autorizados</option>
{responsibles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
Estado
<select
className={controlClass}
value={form.data.status}
onChange={(e) =>
form.setData('status', e.target.value)
}
>
<option value="">Todos</option>
{Object.entries(states).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label className="space-y-1">
Título da medida
<input
className={controlClass}
maxLength={200}
value={form.data.search}
onChange={(e) =>
form.setData('search', e.target.value)
}
/>
</label>
<label className="space-y-1">
Prazo a partir de
<input
type="date"
className={controlClass}
value={form.data.from}
onChange={(e) =>
form.setData('from', e.target.value)
}
/>
</label>
<label className="space-y-1">
Prazo até
<input
type="date"
className={controlClass}
value={form.data.to}
onChange={(e) =>
form.setData('to', e.target.value)
}
/>
</label>
<label className="flex items-center gap-2 self-end pb-2">
<input
type="checkbox"
checked={form.data.overdue === '1'}
onChange={(e) =>
form.setData(
'overdue',
e.target.checked ? '1' : '',
)
}
/>
Só prazos ultrapassados
</label>
<div className="flex items-end gap-3">
<Button disabled={form.processing}>Aplicar</Button>
<Button variant="outline" asChild>
<Link href={base}>Limpar</Link>
</Button>
</div>
{Object.keys(form.errors).length > 0 && (
<div
role="alert"
className="text-sm text-red-700 sm:col-span-2 lg:col-span-4"
>
{Object.entries(form.errors).map(
([key, error]) => (
<p key={key}>{error}</p>
),
)}
</div>
)}
</form>
<p className="mt-3 text-sm text-muted-foreground">
Data de referência: {today} · Consulta:{' '}
{new Date(generatedAt).toLocaleString('pt-MZ')}. O
resumo e o CSV usam os filtros aplicados. Prazo
ultrapassado significa medida ainda não encerrada com
prazo anterior à data de referência.
</p>
<p className="mt-2 text-sm">
Filtros aplicados:{' '}
{suppliers.find(
(s) => String(s.id) === String(filters.supplier_id),
)?.name ??
(filters.supplier_id
? `Fornecedor #${filters.supplier_id}`
: 'todos os fornecedores')}
;{' '}
{responsibles.find(
(p) =>
String(p.id) === String(filters.responsible_id),
)?.name ??
(filters.responsible_id
? `Responsável #${filters.responsible_id}`
: 'todos os responsáveis')}
; {states[filters.status ?? ''] ?? 'todos os estados'};
prazo {filters.from || 'sem início'} a{' '}
{filters.to || 'sem fim'}
{filters.overdue === '1' ? '; só ultrapassados' : ''}
{filters.search ? `; título: ${filters.search}` : ''}.
</p>
</Panel>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-6">
{[
['Resultados', records.total],
...Object.entries(states).map(([key, label]) => [
label,
counts[key] ?? 0,
]),
['Prazo ultrapassado', overdue],
].map(([label, count]) => (
<div key={String(label)} className="rounded border p-4">
<p className="text-xs text-muted-foreground">
{label}
</p>
<p className="mt-1 text-2xl font-semibold">
{count}
</p>
</div>
))}
</div>
<Panel title="Medidas dos filtros aplicados">
<p className="mb-4 text-sm">
A mostrar {records.from ?? 0}–{records.to ?? 0} de{' '}
{records.total}. Página {records.current_page} de{' '}
{records.last_page}. O CSV inclui todos os resultados,
incluindo medidas de revisões anteriores.
</p>
{records.data.length === 0 ? (
<p className="text-sm">
Não existem medidas autorizadas para estes filtros.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b">
<th className="p-3">
Medida e fornecedor
</th>
<th className="p-3">
Responsável e prazo
</th>
<th className="p-3">
Estado e acompanhamento
</th>
<th className="p-3 print:hidden">
Percurso
</th>
</tr>
</thead>
<tbody>
{records.data.map((row) => (
<tr
key={row.id}
className="border-b align-top"
>
<td className="p-3">
<p className="font-medium">
MM-{row.id} · {row.title}
</p>
<p>{row.supplier}</p>
<p className="text-xs text-muted-foreground">
AVF-{row.evaluation_id} ·
Revisão {row.revision}
</p>
</td>
<td className="p-3">
<p>{row.responsible}</p>
<p>{row.due_on}</p>
{row.overdue && (
<p className="font-medium text-amber-800 dark:text-amber-300">
Prazo ultrapassado
</p>
)}
</td>
<td className="max-w-md space-y-2 p-3">
<p className="font-medium">
{states[row.status]} ·
Versão {row.version}
</p>
<details>
<summary className="cursor-pointer underline">
Resultado e evidência
</summary>
<div className="mt-2 space-y-2 whitespace-pre-wrap">
<p>{row.description}</p>
<p>
{row.result ||
'Sem resultado registado.'}
</p>
<p>
{row.evidence ||
'Sem evidência registada.'}
</p>
{row.review_reason && (
<p>
Última
conferência:{' '}
{
row.review_reason
}
</p>
)}
</div>
</details>
</td>
<td className="space-y-2 p-3 print:hidden">
<Link
className="block underline"
href={`/fornecedores/avaliacoes/${row.evaluation_id}#medida-${row.id}`}
>
Avaliação e acompanhamento
</Link>
<Link
className="block underline"
href={`/fornecedores/avaliacoes/${row.evaluation_id}/relatorio#medida-${row.id}`}
>
Relatório individual
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<nav
aria-label="Paginação das medidas"
className="mt-4 flex gap-4 text-sm print:hidden"
>
{records.prev_page_url && (
<Link
className="underline"
href={records.prev_page_url}
>
Anterior
</Link>
)}
{records.next_page_url && (
<Link
className="underline"
href={records.next_page_url}
>
Seguinte
</Link>
)}
</nav>
</Panel>
<p className="text-xs text-muted-foreground">
Consulta de acompanhamento. Não altera avaliações aprovadas,
elegibilidade, pagamentos ou saldos. Nomes de fornecedores
preservados nas avaliações; responsáveis apresentados
segundo a identificação actual autorizada.
</p>
</div>
</>
);
}