172 lines
7.6 KiB
TypeScript
172 lines
7.6 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { Repeat, RefreshCw } from 'lucide-react';
|
|
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
|
|
import { odooApi, type MembresiasReporte } from '../../services/odoo';
|
|
import { useReportRange } from '../../lib/reportRange';
|
|
|
|
const fmtMoney = (value: number, currency = 'MXN') =>
|
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
|
|
|
|
// Pestaña "Membresías" del módulo de Reportes: altas, cancelaciones, ingresos
|
|
// recurrentes por mes, próximas renovaciones y sesiones consumidas.
|
|
const MembresiasReport: FC = () => {
|
|
const { start, end } = useReportRange('membresias');
|
|
const [data, setData] = useState<MembresiasReporte | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await odooApi.getMembresiasReporte({ start, end });
|
|
if (res.status === 'success') setData(res);
|
|
else toast.error(res.message || 'No se pudo cargar el reporte');
|
|
} catch (err) {
|
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
toast.error(msg || 'Error al cargar el reporte de membresías');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [start, end]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
if (loading) {
|
|
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
|
}
|
|
if (!data) {
|
|
return (
|
|
<Card><Card.Body>
|
|
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de membresías." icon={<Repeat size={28} />} />
|
|
</Card.Body></Card>
|
|
);
|
|
}
|
|
|
|
const totalConsumidas = data.sesiones_consumidas || 0;
|
|
const incluidas = data.sesiones_incluidas_activas || 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-theme-muted">
|
|
Periodo {data.start} a {data.end}
|
|
</p>
|
|
<Button variant="outline" size="sm" onClick={load}>
|
|
<RefreshCw size={14} className="mr-1.5" /> Actualizar
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Card><Card.Body>
|
|
<p className="text-xs text-theme-muted">Altas del periodo</p>
|
|
<p className="text-2xl font-heading text-theme-heading">{data.altas}</p>
|
|
</Card.Body></Card>
|
|
<Card><Card.Body>
|
|
<p className="text-xs text-theme-muted">Cancelaciones</p>
|
|
<p className="text-2xl font-heading text-theme-heading">{data.cancelaciones}</p>
|
|
</Card.Body></Card>
|
|
<Card><Card.Body>
|
|
<p className="text-xs text-theme-muted">Membresías activas</p>
|
|
<p className="text-2xl font-heading text-theme-heading">{data.membresias_activas}</p>
|
|
</Card.Body></Card>
|
|
<Card><Card.Body>
|
|
<p className="text-xs text-theme-muted">Ingresos por membresía</p>
|
|
<p className="text-2xl font-heading text-theme-heading">{fmtMoney(data.total_ingresos)}</p>
|
|
<p className="text-[11px] text-theme-muted">de ellos {fmtMoney(data.total_recurrente)} recurrentes</p>
|
|
</Card.Body></Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<Card.Header><p className="font-medium text-theme-heading">Ingresos por mes</p></Card.Header>
|
|
<Card.Body noPadding>
|
|
{data.recurrentes_por_mes.length === 0 ? (
|
|
<div className="p-6"><p className="text-sm text-theme-muted">Sin cobros de membresía en el periodo.</p></div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
|
<tr>
|
|
<th className="text-left px-4 py-3">Mes</th>
|
|
<th className="text-right px-4 py-3">Ingreso recurrente</th>
|
|
<th className="text-right px-4 py-3">Total cobrado</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-theme-border">
|
|
{data.ingresos_por_mes.map((row) => {
|
|
const rec = data.recurrentes_por_mes.find((r) => r.mes === row.mes)?.total ?? 0;
|
|
return (
|
|
<tr key={row.mes}>
|
|
<td className="px-4 py-3 text-theme-heading">{row.mes}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(rec)}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(row.total)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<Card>
|
|
<Card.Header><p className="font-medium text-theme-heading">Próximas renovaciones (60 días)</p></Card.Header>
|
|
<Card.Body noPadding>
|
|
{data.proximas_renovaciones.length === 0 ? (
|
|
<div className="p-6"><p className="text-sm text-theme-muted">No hay renovaciones próximas.</p></div>
|
|
) : (
|
|
<ul className="divide-y divide-theme-border">
|
|
{data.proximas_renovaciones.map((r) => (
|
|
<li key={r.id} className="px-4 py-3 flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-theme-heading truncate">{r.patient}</p>
|
|
<p className="text-[11px] text-theme-muted">{r.folio} · {r.plan}</p>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<p className="text-sm text-theme-heading">{r.proximo_cobro}</p>
|
|
<p className="text-[11px] text-theme-muted">{fmtMoney(r.precio, r.moneda)}</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Card.Header><p className="font-medium text-theme-heading">Sesiones</p></Card.Header>
|
|
<Card.Body>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-theme-muted">Consumidas en el periodo</span>
|
|
<span className="text-theme-heading font-semibold tabular-nums">{totalConsumidas}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-theme-muted">Incluidas en membresías activas</span>
|
|
<span className="text-theme-heading font-semibold tabular-nums">{incluidas}</span>
|
|
</div>
|
|
{incluidas > 0 && (
|
|
<div>
|
|
<div className="h-2 rounded-full bg-theme-bg overflow-hidden">
|
|
<div
|
|
className="h-full bg-theme-accent"
|
|
style={{ width: `${Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-theme-muted mt-1.5">
|
|
Uso estimado del {Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}% de las sesiones incluidas.
|
|
</p>
|
|
</div>
|
|
)}
|
|
<Badge variant="info">Nota: comparación por periodo; estimación de uso.</Badge>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MembresiasReport;
|