321 lines
15 KiB
TypeScript
321 lines
15 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { Download } from 'lucide-react';
|
|
import {
|
|
PageTitle,
|
|
Panel,
|
|
Status,
|
|
controlClass,
|
|
} from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { date, statusLabels } from '@/types/procurement';
|
|
import { ProcurementOperationalInsights } from '@/components/procurement-operational-insights';
|
|
import type { ProcurementInsights } from '@/components/procurement-operational-insights';
|
|
|
|
type Row = {
|
|
id: number;
|
|
number: string;
|
|
status: string;
|
|
created_at: string;
|
|
department: string | null;
|
|
requester: string | null;
|
|
total_minor: string;
|
|
quotations_count: number;
|
|
order_number: string | null;
|
|
};
|
|
type Filters = {
|
|
search?: string;
|
|
status?: string;
|
|
from?: string;
|
|
to?: string;
|
|
department_id?: string | number;
|
|
};
|
|
const estimate = (minor: string) => {
|
|
const digits = minor.padStart(3, '0');
|
|
return `${digits.slice(0, -2)},${digits.slice(-2)} MZN`;
|
|
};
|
|
export default function ProcurementReports({
|
|
records,
|
|
counts,
|
|
filters,
|
|
departments,
|
|
generatedAt,
|
|
insights,
|
|
}: {
|
|
records: {
|
|
data: Row[];
|
|
total: number;
|
|
current_page: number;
|
|
last_page: number;
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
};
|
|
counts: Record<string, number>;
|
|
filters: Filters;
|
|
departments: { id: number; name: string }[];
|
|
generatedAt: string;
|
|
insights?: ProcurementInsights;
|
|
}) {
|
|
const form = useForm({
|
|
search: filters.search ?? '',
|
|
status: filters.status ?? '',
|
|
from: filters.from ?? '',
|
|
to: filters.to ?? '',
|
|
department_id: String(filters.department_id ?? ''),
|
|
});
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(filters))
|
|
if (value !== null && value !== undefined && value !== '')
|
|
params.set(key, String(value));
|
|
return (
|
|
<>
|
|
<Head title="Relatórios de compras" />
|
|
<div className="mx-auto max-w-7xl space-y-6 px-5 py-8 lg:px-8">
|
|
<PageTitle
|
|
eyebrow="Consulta e relatórios"
|
|
title="Relatórios de compras"
|
|
description="Localize o pedido, acompanhe o processo e abra o relatório de conferência com as decisões e os documentos disponíveis."
|
|
action={
|
|
<Button asChild variant="outline">
|
|
<a href={`/relatorios/compras.csv?${params}`}>
|
|
<Download className="size-4" />
|
|
Exportar CSV
|
|
</a>
|
|
</Button>
|
|
}
|
|
/>
|
|
{insights && <ProcurementOperationalInsights data={insights} />}
|
|
<Panel
|
|
title="Filtrar requisições"
|
|
description="O período refere-se à criação da requisição. Os resultados respeitam o seu acesso à empresa e ao departamento."
|
|
>
|
|
<form
|
|
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.get('/relatorios', { preserveState: true });
|
|
}}
|
|
>
|
|
<label className="grid gap-1 text-sm">
|
|
Número da requisição
|
|
<input
|
|
className={controlClass}
|
|
value={form.data.search}
|
|
onChange={(event) =>
|
|
form.setData('search', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Estado da requisição
|
|
<select
|
|
className={controlClass}
|
|
value={form.data.status}
|
|
onChange={(event) =>
|
|
form.setData('status', event.target.value)
|
|
}
|
|
>
|
|
<option value="">Todos</option>
|
|
{Object.entries(statusLabels).map(
|
|
([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Departamento
|
|
<select
|
|
className={controlClass}
|
|
value={form.data.department_id}
|
|
onChange={(event) =>
|
|
form.setData(
|
|
'department_id',
|
|
event.target.value,
|
|
)
|
|
}
|
|
>
|
|
<option value="">Todos os autorizados</option>
|
|
{departments.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Criada desde
|
|
<input
|
|
type="date"
|
|
className={controlClass}
|
|
value={form.data.from}
|
|
onChange={(event) =>
|
|
form.setData('from', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm">
|
|
Criada até
|
|
<input
|
|
type="date"
|
|
className={controlClass}
|
|
value={form.data.to}
|
|
onChange={(event) =>
|
|
form.setData('to', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="flex items-end gap-3">
|
|
<Button type="submit" disabled={form.processing}>
|
|
Aplicar filtros
|
|
</Button>
|
|
<Link
|
|
className="py-2 text-sm underline"
|
|
href="/relatorios"
|
|
>
|
|
Limpar
|
|
</Link>
|
|
</div>
|
|
{Object.entries(form.errors).map(([key, message]) => (
|
|
<p
|
|
key={key}
|
|
role="alert"
|
|
className="text-sm text-red-700"
|
|
>
|
|
{message}
|
|
</p>
|
|
))}
|
|
</form>
|
|
</Panel>
|
|
<section
|
|
aria-label="Resumo dos resultados"
|
|
className="flex flex-wrap gap-3"
|
|
>
|
|
<p className="rounded-lg border bg-card px-4 py-3 font-medium">
|
|
{records.total} requisições
|
|
</p>
|
|
{Object.entries(counts).map(([status, count]) => (
|
|
<p
|
|
key={status}
|
|
className="rounded-lg border bg-card px-4 py-3 text-sm"
|
|
>
|
|
{statusLabels[status] ?? status}: {count}
|
|
</p>
|
|
))}
|
|
</section>
|
|
<Panel
|
|
title="Do pedido ao relatório"
|
|
description="A estimativa da requisição, a adjudicação, a recepção e o pagamento têm estados distintos. Propostas registadas não significam propostas elegíveis; uma ordem emitida não comprova liquidação."
|
|
>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
O CSV inclui todos os resultados dos filtros aplicados,
|
|
não apenas esta página. Consulta gerada em{' '}
|
|
{new Date(generatedAt).toLocaleString('pt-MZ')}.
|
|
</p>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-240 text-left text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th className="p-2">Requisição</th>
|
|
<th className="p-2">
|
|
Departamento / solicitante
|
|
</th>
|
|
<th className="p-2">Estado</th>
|
|
<th className="p-2">Estimativa total</th>
|
|
<th className="p-2">Propostas / ordem</th>
|
|
<th className="p-2">Continuar</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.data.map((row) => (
|
|
<tr key={row.id} className="border-t">
|
|
<td className="p-2">
|
|
<Link
|
|
className="font-medium underline"
|
|
href={`/requisicoes/${row.id}`}
|
|
>
|
|
{row.number}
|
|
</Link>
|
|
<span className="block text-muted-foreground">
|
|
{date(row.created_at)}
|
|
</span>
|
|
</td>
|
|
<td className="p-2">
|
|
{row.department ??
|
|
'Departamento indisponível'}
|
|
<span className="block text-muted-foreground">
|
|
{row.requester ??
|
|
'Solicitante indisponível'}
|
|
</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<Status value={row.status} />
|
|
</td>
|
|
<td className="p-2 whitespace-nowrap">
|
|
{estimate(row.total_minor)}
|
|
</td>
|
|
<td className="p-2">
|
|
{row.quotations_count} propostas
|
|
<span className="block text-muted-foreground">
|
|
{row.order_number ??
|
|
'Sem ordem de compra'}
|
|
</span>
|
|
</td>
|
|
<td className="space-y-2 p-2">
|
|
<Link
|
|
className="block underline"
|
|
href={`/requisicoes/${row.id}/processo`}
|
|
>
|
|
Acompanhar processo
|
|
</Link>
|
|
<Link
|
|
className="block underline"
|
|
href={`/requisicoes/${row.id}/relatorio`}
|
|
>
|
|
Gerar relatório de conferência
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{records.data.length === 0 && (
|
|
<p className="py-6 text-sm">
|
|
Nenhuma requisição encontrada nos filtros e no
|
|
âmbito autorizado.
|
|
</p>
|
|
)}
|
|
<nav
|
|
aria-label="Paginação dos relatórios"
|
|
className="mt-4 flex items-center justify-between gap-3"
|
|
>
|
|
<span className="text-sm">
|
|
Página {records.current_page} de {records.last_page}
|
|
</span>
|
|
<div className="flex gap-4">
|
|
{records.prev_page_url && (
|
|
<Link
|
|
className="text-sm underline"
|
|
href={records.prev_page_url}
|
|
>
|
|
Anterior
|
|
</Link>
|
|
)}
|
|
{records.next_page_url && (
|
|
<Link
|
|
className="text-sm underline"
|
|
href={records.next_page_url}
|
|
>
|
|
Seguinte
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|