429 lines
20 KiB
TypeScript
429 lines
20 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|