Files
SKEEN-Proyecto/frontend/src/components/reportes/MembresiasReport.tsx

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;