fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto
This commit is contained in:
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal file
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user