164 lines
6.2 KiB
TypeScript
164 lines
6.2 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { Clock } from 'lucide-react';
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
import { Card, Input, Badge, EmptyState, Skeleton, toast } from '../ui';
|
|
import { ExportButton } from '../ui/ExportButton';
|
|
import { exportToExcel } from '../../lib/exporter';
|
|
import { odooApi, type HoraAgendaRow } from '../../services/odoo';
|
|
import type { BadgeVariant } from '../ui/Badge';
|
|
import { useReportRange } from '../../lib/reportRange';
|
|
|
|
const citaStateLabels: Record<string, string> = {
|
|
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
|
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
|
};
|
|
|
|
const citaStateBadge = (state: string): BadgeVariant => {
|
|
switch (state) {
|
|
case 'done': return 'success';
|
|
case 'cancelled': case 'no_show': return 'danger';
|
|
case 'pending': return 'warning';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
|
|
const HorasAgendaReport: FC = () => {
|
|
// Rango global de Reportes; los campos Inicio/Fin fijan uno propio
|
|
const { start, end, setStart, setEnd } = useReportRange('horas-agenda');
|
|
const [horas, setHoras] = useState<HoraAgendaRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await odooApi.getHorasAgenda(start, end);
|
|
if (res.status === 'success') setHoras(res.horas);
|
|
} catch (err) {
|
|
toast.error('Error al cargar horas de agenda');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [start, end]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const [exporting, setExporting] = useState(false);
|
|
const exportar = async () => {
|
|
try {
|
|
setExporting(true);
|
|
const rows: Record<string, unknown>[] = [];
|
|
horas.forEach((h) => {
|
|
Object.entries(h.by_state).forEach(([state, count]) =>
|
|
rows.push({ hora: h.hora, estado: citaStateLabels[state] || state, citas: count }));
|
|
});
|
|
await exportToExcel({
|
|
filename: `horas-agenda-${start}_${end}`,
|
|
sheetName: 'Horas agenda',
|
|
title: 'Horas agenda',
|
|
subtitle: `Del ${start} al ${end} · ${total} citas`,
|
|
columns: [
|
|
{ header: 'Hora', key: 'hora' },
|
|
{ header: 'Estado', key: 'estado' },
|
|
{ header: 'Citas', key: 'citas', format: 'number' },
|
|
],
|
|
rows,
|
|
totals: { hora: '', estado: 'Total citas', citas: total },
|
|
});
|
|
toast.success('Excel descargado');
|
|
} catch (err) {
|
|
toast.error('Error al exportar');
|
|
console.error(err);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const chartData = horas.map((h) => ({ name: h.hora, citas: h.total }));
|
|
const total = horas.reduce((acc, h) => acc + h.total, 0);
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
|
|
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
|
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
|
|
|
<ExportButton onClick={exportar} loading={exporting} />
|
|
<p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Card.Body>
|
|
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por hora del día</h3>
|
|
{loading ? (
|
|
<Skeleton className="h-[260px] w-full" />
|
|
) : total === 0 ? (
|
|
<EmptyState title="Sin citas" subtitle="No hay citas en el periodo." icon={<Clock size={28} />} />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} allowDecimals={false} />
|
|
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
|
<Bar dataKey="citas" fill="#57534e" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{!loading && total > 0 && (
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
|
<tr>
|
|
<th className="text-left px-4 py-3">Hora</th>
|
|
<th className="text-right px-4 py-3">Citas</th>
|
|
<th className="text-left px-4 py-3">Estados</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-theme-border">
|
|
{horas.filter((h) => h.total > 0).map((h) => (
|
|
<tr key={h.hora} className="hover:bg-theme-bg">
|
|
<td className="px-4 py-3 font-medium text-theme-heading">{h.hora}</td>
|
|
<td className="px-4 py-3 text-right text-theme-muted">{h.total}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(h.by_state).map(([state, count]) => (
|
|
<Badge key={state} variant={citaStateBadge(state)}>
|
|
{citaStateLabels[state] || state}: {count}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HorasAgendaReport;
|