feat: complete administration and reporting
This commit is contained in:
194
resources/js/pages/reports/index.tsx
Normal file
194
resources/js/pages/reports/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Download,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { PageHeading } from '@/components/page-heading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type Summary = {
|
||||
total: number;
|
||||
completed: number;
|
||||
incidents: number;
|
||||
overdue: number;
|
||||
utilizationHours: number;
|
||||
};
|
||||
|
||||
export default function ReportsIndex({
|
||||
filters,
|
||||
summary,
|
||||
byStatus,
|
||||
byCategory,
|
||||
byDepartment,
|
||||
topAssets,
|
||||
}: {
|
||||
filters: { date_from: string; date_to: string };
|
||||
summary: Summary;
|
||||
byStatus: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
byDepartment: Record<string, number>;
|
||||
topAssets: Record<string, number>;
|
||||
}) {
|
||||
const [dateFrom, setDateFrom] = useState(filters.date_from);
|
||||
const [dateTo, setDateTo] = useState(filters.date_to);
|
||||
|
||||
function filter(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
router.get('/relatorios', { date_from: dateFrom, date_to: dateTo });
|
||||
}
|
||||
|
||||
const exportUrl = `/relatorios/exportar?${new URLSearchParams({ date_from: dateFrom, date_to: dateTo })}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Relatórios" />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 p-5 sm:p-8">
|
||||
<PageHeading
|
||||
eyebrow="Gestão e análise"
|
||||
title="Relatórios operacionais"
|
||||
description="Indicadores consolidados por período, com exportação detalhada para tratamento externo."
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<a href={exportUrl}>
|
||||
<Download /> Exportar CSV
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
onSubmit={filter}
|
||||
className="flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
De
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(event) =>
|
||||
setDateFrom(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
Até
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(event) => setDateTo(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary">
|
||||
Atualizar
|
||||
</Button>
|
||||
</form>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Metric
|
||||
icon={FileText}
|
||||
label="Requisições"
|
||||
value={summary.total}
|
||||
/>
|
||||
<Metric
|
||||
icon={CheckCircle2}
|
||||
label="Concluídas"
|
||||
value={summary.completed}
|
||||
/>
|
||||
<Metric
|
||||
icon={Clock3}
|
||||
label="Horas de utilização"
|
||||
value={summary.utilizationHours}
|
||||
/>
|
||||
<Metric
|
||||
icon={AlertTriangle}
|
||||
label="Incidentes"
|
||||
value={summary.incidents}
|
||||
/>
|
||||
<Metric
|
||||
icon={AlertTriangle}
|
||||
label="Atrasos"
|
||||
value={summary.overdue}
|
||||
/>
|
||||
</section>
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<Breakdown title="Por estado" values={byStatus} />
|
||||
<Breakdown title="Por categoria" values={byCategory} />
|
||||
<Breakdown title="Por departamento" values={byDepartment} />
|
||||
<Breakdown
|
||||
title="Equipamentos mais requisitados"
|
||||
values={topAssets}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-3 py-5 shadow-none">
|
||||
<CardContent>
|
||||
<Icon className="mb-3 size-5 text-primary" />
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number>;
|
||||
}) {
|
||||
const max = Math.max(1, ...Object.values(values));
|
||||
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.keys(values).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sem dados no período.
|
||||
</p>
|
||||
)}
|
||||
{Object.entries(values).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<div className="mb-1 flex justify-between gap-3 text-sm">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-muted">
|
||||
<div
|
||||
className="h-2 rounded bg-primary"
|
||||
style={{ width: `${(value / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
ReportsIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Relatórios', href: '/relatorios' }],
|
||||
};
|
||||
Reference in New Issue
Block a user