import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } 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 ImpuestosReport from '../components/reportes/ImpuestosReport'; 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'; import { useAuth } from '../lib/auth'; import { REPORT_PRESETS, RangeOverrideHint, ReportRangeProvider, useGlobalReportRange, useReportFavorites, } from '../lib/reportRange'; const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; interface ReportTab { key: string; label: string; desc: string; } interface ReportFamily { key: string; label: string; hint: string; icon: typeof Wallet; tabs: ReportTab[]; } // Familias de reportes: misma URL ?tab=xxx de siempre, navegación agrupada. const FAMILIAS: ReportFamily[] = [ { key: 'dinero', label: 'Dinero', hint: 'Ingresos, cobros y cartera', icon: Wallet, tabs: [ { key: 'general', label: 'General', desc: 'Resumen del periodo, citas y comisiones' }, { key: 'diario', label: 'Movimientos diarios', desc: 'Citas, ventas, cobros e inventario del día' }, { key: 'ingresos', label: 'Ingresos', desc: 'Ventas y formas de pago' }, { key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' }, { key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' }, { key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' }, { key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' }, ], }, { key: 'clientes', label: 'Clientes', hint: 'Quién compra y cuánto', icon: Users, tabs: [ { key: 'top-clientes', label: 'Top clientes', desc: 'Pacientes con mayor consumo' }, { key: 'pagos-clientes', label: 'Pagos · Clientes', desc: 'Cobros agrupados por paciente' }, { key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' }, { key: 'paquetes', label: 'Paquetes', desc: 'Ventas y avance de paquetes' }, ], }, { key: 'operacion', label: 'Operación', hint: 'Agenda, inventario y servicios', icon: Activity, tabs: [ { key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' }, { key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' }, { key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' }, { key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' }, ], }, { key: 'equipo', label: 'Equipo', hint: 'Rendimiento del personal', icon: Stethoscope, tabs: [ { key: 'comisiones', label: 'Comisiones', desc: 'Comisión por médico en el periodo' }, { key: 'vendedores', label: 'Vendedores', desc: 'Ventas atribuidas por vendedor' }, { key: 'kpis', label: 'KPIs', desc: 'No-show, ocupación y conversión' }, ], }, ]; // 'exportar' no vive en ninguna familia: se abre desde el botón del encabezado // y se renderiza en la cadena de condiciones de abajo. Se lista aquí para que // el control de acceso por pestana la trate igual que las demas. const TABS_FUERA_DE_FAMILIA = ['exportar']; const TODOS: ReportTab[] = FAMILIAS.flatMap((f) => f.tabs); const ETIQUETA_TAB: Record = Object.fromEntries(TODOS.map((t) => [t.key, t.label])); interface SalesReport { total_sales: number; total_paid: number; total_due: number; count: number; } interface AppointmentsReport { total: number; by_state: Record; } interface CashReport { total: number; by_method: Record; } const ReportesContent: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { hasRole, canSee, can, brand } = useAuth(); const esAdmin = hasRole('admin'); // PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves // por pestana y, cuando no existe la del tab, canSee cae al prefijo // '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una // concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa. const puedeTab = useCallback((k: string) => canSee(`/reportes?tab=${k}`), [canSee]); const familiasVisibles = useMemo( () => FAMILIAS .map((f) => ({ ...f, tabs: f.tabs.filter((t) => puedeTab(t.key)) })) .filter((f) => f.tabs.length > 0), [puedeTab] ); const tabParam = searchParams.get('tab') || ''; const esTabValido = (k: string) => k !== '' && (TABS_FUERA_DE_FAMILIA.includes(k) || TODOS.some((t) => t.key === k)) && puedeTab(k); const tab = esTabValido(tabParam) ? tabParam : familiasVisibles[0]?.tabs[0]?.key || (esTabValido('exportar') ? 'exportar' : 'general'); const setTab = (t: string) => setSearchParams({ tab: t }); const familiaKey = familiasVisibles.find((f) => f.tabs.some((t) => t.key === tab))?.key || familiasVisibles[0]?.key || FAMILIAS[0].key; const familia = familiasVisibles.find((f) => f.key === familiaKey) || FAMILIAS[0]; const { favs, toggle, isFav } = useReportFavorites(); // Un favorito fijado antes de cambiar la matriz no debe ofrecer una pestana // que el rol ya no tiene asignada. const favsVisibles = favs.filter((k) => puedeTab(k)); // Si el URL pedía una pestana no concedida, la página renderiza otra: se // reescribe el query para que la dirección refleje lo que se está viendo. useEffect(() => { if (tab !== tabParam) setSearchParams({ tab }); }, [tab, tabParam, setSearchParams]); // Rango global del módulo (persistente, compartido por todas las pestañas) const { start, end, preset, setRange } = useGlobalReportRange(); const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]); const [salesReport, setSalesReport] = useState(null); const [appointmentsReport, setAppointmentsReport] = useState(null); const [cashReport, setCashReport] = useState(null); const [commissions, setCommissions] = useState([]); const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const tabsFamilia = familia.tabs; const tabInfo = useMemo(() => TODOS.find((t) => t.key === tab), [tab]); useEffect(() => { if (tab !== 'general') return; const load = async () => { try { setLoading(true); setError(null); const [salesRes, aptRes, cashRes] = await Promise.all([ odooApi.getSalesReport(start, end), odooApi.getAppointmentsReport(start, end), odooApi.getCashReport(today), ]); 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 }); // Comisiones viajan en petición aparte y solo para admin: el endpoint // exige rol admin y un 403 no debe tumbar (ni ensuciar la consola del) // resto del tab General para los demás roles. if (esAdmin) { odooApi.getCommissions(start, end) .then((commRes) => { if (commRes.status === 'success') { setCommissions(commRes.commissions || []); setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 }); } }) .catch(() => { /* sin permiso: sección de comisiones queda vacía */ }); } else { setCommissions([]); setCommTotals({ base: 0, 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, esAdmin]); 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[] = []; 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-${start}_${end}`, sheetName: 'General', title: `Reporte general ${brand.nombre}`, 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 ( {tab === 'general' && can('reportes.exportar') && ( )} {puedeTab('exportar') && can('reportes.exportar') && ( )} {/* Rango de fecha global (aplica a todas las pestañas) */}
Periodo {REPORT_PRESETS.map((p) => ( ))}
setRange('custom', e.target.value, end)} className="sm:max-w-[170px]" /> setRange('custom', start, e.target.value)} className="sm:max-w-[170px]" />

Aplica a todas las pestañas. Cada reporte puede ajustar su propio rango con sus campos Inicio/Fin.

{/* Favoritos por usuario */} {favsVisibles.length > 0 && (

Favoritos

{favsVisibles.map((k) => ( ))}
)} {/* Familias */}
{familiasVisibles.map((f) => { const Icono = f.icon; const activa = f.key === familiaKey; return ( ); })}
{/* Pestañas de la familia activa */}
{tabsFamilia.map((t) => { const activo = tab === t.key; return ( ); })}
{tab === 'diario' ? ( ) : tab === 'cortes' ? ( ) : tab === 'ingresos' ? ( ) : tab === 'impuestos' ? ( ) : tab === 'inventario' ? ( ) : tab === 'adeudos' ? ( ) : tab === 'comisiones' ? ( ) : tab === 'pagos-servicios' ? ( ) : tab === 'pagos-clientes' ? ( ) : tab === 'devoluciones' ? ( ) : tab === 'top-clientes' ? ( ) : tab === 'exportar' ? ( ) : tab === 'horas-agenda' ? ( ) : tab === 'paquetes' ? ( ) : tab === 'vendedores' ? ( ) : tab === 'concentrado' ? ( ) : tab === 'recomendaciones' ? ( ) : tab === 'kpis' ? ( ) : ( <>
setToday(e.target.value)} className="sm:max-w-[180px]" />

Ventas, citas y comisiones usan el periodo global de arriba ({start} a {end}).

{error &&

{error}

} {loading ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : salesReport ? (

Ventas

${salesReport.total_sales}

Pagado

${salesReport.total_paid}

Por cobrar

${salesReport.total_due}

Cantidad

{salesReport.count}

) : null}

Citas por estado

{loading ? ( ) : aptData.length === 0 ? ( } /> ) : ( )}

Efectivo por método

{loading ? ( ) : cashData.length === 0 ? ( } /> ) : ( {cashData.map((_, index) => ( ))} )}
{esAdmin && (

Comisiones por médico

Comisión = % del médico sobre la venta de artículos recetados por él.

Base recetada: ${commTotals.base.toLocaleString('es-MX')} {' · '}Comisión total: ${commTotals.commission.toLocaleString('es-MX')}
{loading ? ( ) : commissions.length === 0 ? (

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.

) : (
{commissions.map((c) => ( ))}
Médico % Comisión Artículos Ventas Base recetada Comisión

{c.doctor}

{c.job_title || '—'}

{c.commission_pct}% {c.items} {c.sales} ${c.base.toLocaleString('es-MX')} ${c.commission.toLocaleString('es-MX')}
)}
)} )}
); }; /** Envuelve la vista con el proveedor del rango global. */ const Reportes: FC = () => ( ); export default Reportes;