Files
SKEEN-Proyecto/frontend/src/pages/Reportes.tsx
Consultoría Alcaraz Salazar 01f6007e30 Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
2026-08-13 23:30:08 +00:00

401 lines
17 KiB
TypeScript

import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3 } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import Layout from '../components/Layout';
import DailyReport from '../components/reportes/DailyReport';
import CortesReport from '../components/reportes/CortesReport';
import IngresosReport from '../components/reportes/IngresosReport';
import InventarioReport from '../components/reportes/InventarioReport';
import AdeudosReport from '../components/reportes/AdeudosReport';
import ComisionesReport from '../components/reportes/ComisionesReport';
import PagosServiciosReport from '../components/reportes/PagosServiciosReport';
import PagosClientesReport from '../components/reportes/PagosClientesReport';
import DevolucionesReport from '../components/reportes/DevolucionesReport';
import TopClientesReport from '../components/reportes/TopClientesReport';
import ExportarReport from '../components/reportes/ExportarReport';
import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
import PaquetesReport from '../components/reportes/PaquetesReport';
import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
const TABS = [
{ key: 'general', label: 'General' },
{ key: 'diario', label: 'Movimientos diarios' },
{ key: 'cortes', label: 'Cortes de caja' },
{ key: 'ingresos', label: 'Ingresos' },
{ key: 'inventario', label: 'Inventario' },
{ key: 'adeudos', label: 'Adeudos' },
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios' },
{ key: 'pagos-clientes', label: 'Pagos · Clientes' },
{ key: 'devoluciones', label: 'Devoluciones' },
{ key: 'top-clientes', label: 'Top clientes' },
{ key: 'exportar', label: 'Exportar' },
{ key: 'horas-agenda', label: 'Horas agenda' },
{ key: 'paquetes', label: 'Paquetes' },
{ key: 'vendedores', label: 'Vendedores' },
{ key: 'concentrado', label: 'Concentrado' },
{ key: 'recomendaciones', label: 'Recomendaciones' },
{ key: 'kpis', label: 'KPIs' },
] as const;
interface SalesReport {
total_sales: number;
total_paid: number;
total_due: number;
count: number;
}
interface AppointmentsReport {
total: number;
by_state: Record<string, number>;
}
interface CashReport {
total: number;
by_method: Record<string, number>;
}
const Reportes: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'general';
const setTab = (t: string) => setSearchParams({ tab: t });
const [start, setStart] = useState(() => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
});
const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]);
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
const [cashReport, setCashReport] = useState<CashReport | null>(null);
const [commissions, setCommissions] = useState<CommissionRow[]>([]);
const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (tab !== 'general') return;
const load = async () => {
try {
setLoading(true);
setError(null);
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([
odooApi.getSalesReport(start, end),
odooApi.getAppointmentsReport(start, end),
odooApi.getCashReport(today),
odooApi.getCommissions(start, end),
]);
if (salesRes.status === 'success')
setSalesReport({
total_sales: salesRes.total_sales,
total_paid: salesRes.total_paid,
total_due: salesRes.total_due,
count: salesRes.count,
});
if (aptRes.status === 'success')
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
if (cashRes.status === 'success')
setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
if (commRes.status === 'success') {
setCommissions(commRes.commissions || []);
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
}
} catch (err) {
setError('Error al cargar reportes');
toast.error('Error al cargar reportes');
console.error(err);
} finally {
setLoading(false);
}
};
load();
}, [start, end, today, tab]);
const aptData = appointmentsReport
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
: [];
const cashData = cashReport
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
: [];
const [exporting, setExporting] = useState(false);
const exportExcel = async () => {
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
if (salesReport) {
rows.push({ seccion: 'Ventas', concepto: 'Total', cantidad: null, monto: salesReport.total_sales });
rows.push({ seccion: 'Ventas', concepto: 'Pagado', cantidad: null, monto: salesReport.total_paid });
rows.push({ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: salesReport.total_due });
rows.push({ seccion: 'Ventas', concepto: 'Cantidad', cantidad: salesReport.count, monto: null });
}
aptData.forEach((row) => rows.push({ seccion: 'Citas por estado', concepto: row.name, cantidad: row.value, monto: null }));
cashData.forEach((row) => rows.push({ seccion: 'Efectivo por método', concepto: row.name, cantidad: null, monto: row.value }));
commissions.forEach((c) =>
rows.push({ seccion: 'Comisiones', concepto: c.doctor, cantidad: c.items, monto: c.commission }));
await exportToExcel({
filename: `reporte-skeen-${start}_${end}`,
sheetName: 'General',
title: 'Reporte general SKEEN',
subtitle: `Rango ${start} a ${end} · efectivo del ${today}`,
columns: [
{ header: 'Sección', key: 'seccion' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
{ header: 'Monto', key: 'monto', format: 'currency' },
],
rows,
totals: { seccion: '', concepto: 'Comisión total', monto: commTotals.commission },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
{tab === 'general' && (
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
<Download size={16} className="mr-2" />
Exportar
</Button>
)}
</PageHeader>
{/* Tabs de reportes */}
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'diario' ? (
<DailyReport />
) : tab === 'cortes' ? (
<CortesReport />
) : tab === 'ingresos' ? (
<IngresosReport />
) : tab === 'inventario' ? (
<InventarioReport />
) : tab === 'adeudos' ? (
<AdeudosReport />
) : tab === 'comisiones' ? (
<ComisionesReport />
) : tab === 'pagos-servicios' ? (
<PagosServiciosReport />
) : tab === 'pagos-clientes' ? (
<PagosClientesReport />
) : tab === 'devoluciones' ? (
<DevolucionesReport />
) : tab === 'top-clientes' ? (
<TopClientesReport />
) : tab === 'exportar' ? (
<ExportarReport />
) : tab === 'horas-agenda' ? (
<HorasAgendaReport />
) : tab === 'paquetes' ? (
<PaquetesReport />
) : tab === 'vendedores' ? (
<VendedoresReport />
) : tab === 'concentrado' ? (
<ConcentradoReport />
) : tab === 'recomendaciones' ? (
<RecomendacionesReport />
) : tab === 'kpis' ? (
<KpisReport />
) : (
<>
<Card className="mb-6">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
<Input
label="Inicio"
type="date"
value={start}
onChange={(e) => setStart(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input
label="Fin"
type="date"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input
label="Día efectivo"
type="date"
value={today}
onChange={(e) => setToday(e.target.value)}
className="sm:max-w-[180px]"
/>
</div>
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : salesReport ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Ventas</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_sales}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Pagado</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_paid}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Por cobrar</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_due}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Cantidad</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{salesReport.count}</p>
</Card>
</div>
) : null}
</Card.Body>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por estado</h3>
{loading ? (
<Skeleton className="h-[250px] w-full" />
) : aptData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay citas en el periodo." icon={<BarChart3 size={28} />} />
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={aptData}>
<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 }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="value" fill="#57534e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Efectivo por método</h3>
{loading ? (
<Skeleton className="h-[250px] w-full" />
) : cashData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay pagos registrados para el día." icon={<BarChart3 size={28} />} />
) : (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={cashData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{cashData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
</PieChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
</div>
<Card className="mt-4 sm:mt-6">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h3 className="font-heading text-xl text-theme-heading">Comisiones por médico</h3>
<p className="text-xs text-theme-muted">Comisión = % del médico sobre la venta de artículos recetados por él.</p>
</div>
<div className="text-sm text-theme-muted">
Base recetada: <span className="font-semibold text-theme-heading">${commTotals.base.toLocaleString('es-MX')}</span>
{' · '}Comisión total: <span className="font-semibold text-theme-heading">${commTotals.commission.toLocaleString('es-MX')}</span>
</div>
</div>
{loading ? (
<Skeleton className="h-24 w-full" />
) : commissions.length === 0 ? (
<p className="text-sm text-theme-muted">No hay ventas de artículos recetados en el periodo. Marca las líneas como “Artículo recetado” al crear la venta y asigna el % en Médicos.</p>
) : (
<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">Médico</th>
<th className="text-right px-4 py-3">% Comisión</th>
<th className="text-right px-4 py-3">Artículos</th>
<th className="text-right px-4 py-3">Ventas</th>
<th className="text-right px-4 py-3">Base recetada</th>
<th className="text-right px-4 py-3">Comisión</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{commissions.map((c) => (
<tr key={c.doctor_id} className="hover:bg-theme-bg">
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{c.doctor}</p>
<p className="text-xs text-theme-muted">{c.job_title || '—'}</p>
</td>
<td className="px-4 py-3 text-right text-theme-heading">{c.commission_pct}%</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.items}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.sales}</td>
<td className="px-4 py-3 text-right text-theme-heading">${c.base.toLocaleString('es-MX')}</td>
<td className="px-4 py-3 text-right font-semibold text-theme-heading">${c.commission.toLocaleString('es-MX')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</>
)}
</Layout>
);
};
export default Reportes;