640 lines
27 KiB
TypeScript
640 lines
27 KiB
TypeScript
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<string, string> = 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<string, number>;
|
|
}
|
|
|
|
interface CashReport {
|
|
total: number;
|
|
by_method: Record<string, number>;
|
|
}
|
|
|
|
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<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);
|
|
|
|
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<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-${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 (
|
|
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
|
<PageHeader title="Reportes" subtitle={tabInfo?.desc || 'Análisis de la operación'}>
|
|
{tab === 'general' && can('reportes.exportar') && (
|
|
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
|
|
<Download size={16} className="mr-2" />
|
|
Exportar
|
|
</Button>
|
|
)}
|
|
{puedeTab('exportar') && can('reportes.exportar') && (
|
|
<Button
|
|
variant={tab === 'exportar' ? 'primary' : 'outline'}
|
|
onClick={() => setTab('exportar')}
|
|
title="Exportar listas de pacientes (correos, VIP, etiquetas)"
|
|
>
|
|
<Share2 size={16} className="mr-2" />
|
|
Exportar datos
|
|
</Button>
|
|
)}
|
|
</PageHeader>
|
|
|
|
{/* Rango de fecha global (aplica a todas las pestañas) */}
|
|
<Card className="mb-4">
|
|
<Card.Body className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-xs font-medium text-theme-muted uppercase mr-1">Periodo</span>
|
|
{REPORT_PRESETS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
onClick={() => setRange(p.value)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
|
|
preset === p.value
|
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<Input
|
|
label="Inicio"
|
|
type="date"
|
|
value={start}
|
|
onChange={(e) => setRange('custom', e.target.value, end)}
|
|
className="sm:max-w-[170px]"
|
|
/>
|
|
<Input
|
|
label="Fin"
|
|
type="date"
|
|
value={end}
|
|
onChange={(e) => setRange('custom', start, e.target.value)}
|
|
className="sm:max-w-[170px]"
|
|
/>
|
|
<RangeOverrideHint reportKey={tab} />
|
|
<p className="text-xs text-theme-muted sm:mb-2">
|
|
Aplica a todas las pestañas. Cada reporte puede ajustar su propio rango con sus campos Inicio/Fin.
|
|
</p>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Favoritos por usuario */}
|
|
{favsVisibles.length > 0 && (
|
|
<div className="mb-4">
|
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2 inline-flex items-center gap-1.5">
|
|
<Star size={12} className="text-amber-500" fill="currentColor" />
|
|
Favoritos
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{favsVisibles.map((k) => (
|
|
<span
|
|
key={k}
|
|
className={`inline-flex items-center rounded-full border overflow-hidden text-xs font-medium ${
|
|
tab === k ? 'border-theme-accent bg-theme-accent-bg' : 'border-theme-border bg-theme-bg'
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTab(k)}
|
|
className={`px-3 py-1.5 ${tab === k ? 'text-theme-heading' : 'text-theme-muted hover:text-theme-heading'}`}
|
|
>
|
|
{ETIQUETA_TAB[k] || k}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(k)}
|
|
title="Quitar de favoritos"
|
|
className="px-2 py-1.5 text-amber-500 hover:text-amber-600"
|
|
>
|
|
<Star size={12} fill="currentColor" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Familias */}
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{familiasVisibles.map((f) => {
|
|
const Icono = f.icon;
|
|
const activa = f.key === familiaKey;
|
|
return (
|
|
<button
|
|
key={f.key}
|
|
type="button"
|
|
onClick={() => setTab(f.tabs[0].key)}
|
|
title={f.hint}
|
|
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold rounded-xl transition ${
|
|
activa
|
|
? 'bg-theme-accent text-theme-inverse shadow-card'
|
|
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
|
}`}
|
|
>
|
|
<Icono size={15} />
|
|
{f.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Pestañas de la familia activa */}
|
|
<div className="mb-6 pb-3 border-b border-theme-border">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{tabsFamilia.map((t) => {
|
|
const activo = tab === t.key;
|
|
return (
|
|
<span
|
|
key={t.key}
|
|
className={`inline-flex items-center rounded-full border overflow-hidden text-sm font-medium transition ${
|
|
activo
|
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
<button type="button" onClick={() => setTab(t.key)} title={t.desc} className="px-4 py-2">
|
|
{t.label}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(t.key)}
|
|
title={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
|
|
aria-label={isFav(t.key) ? 'Quitar de favoritos' : 'Fijar en favoritos'}
|
|
className={`px-2.5 py-2 border-l ${
|
|
activo ? 'border-theme-inverse/30' : 'border-theme-border'
|
|
} ${isFav(t.key) ? 'text-amber-500' : activo ? 'text-theme-inverse/60 hover:text-theme-inverse' : 'text-theme-muted/60 hover:text-theme-heading'}`}
|
|
>
|
|
<Star size={13} fill={isFav(t.key) ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{tab === 'diario' ? (
|
|
<DailyReport />
|
|
) : tab === 'cortes' ? (
|
|
<CortesReport />
|
|
) : tab === 'ingresos' ? (
|
|
<IngresosReport />
|
|
) : tab === 'impuestos' ? (
|
|
<ImpuestosReport />
|
|
) : 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="Día efectivo"
|
|
type="date"
|
|
value={today}
|
|
onChange={(e) => setToday(e.target.value)}
|
|
className="sm:max-w-[180px]"
|
|
/>
|
|
<p className="text-xs text-theme-muted sm:mb-2">
|
|
Ventas, citas y comisiones usan el <span className="font-medium text-theme-heading">periodo global</span> de arriba
|
|
({start} a {end}).
|
|
</p>
|
|
</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>
|
|
|
|
{esAdmin && (
|
|
<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>
|
|
);
|
|
};
|
|
|
|
/** Envuelve la vista con el proveedor del rango global. */
|
|
const Reportes: FC = () => (
|
|
<ReportRangeProvider>
|
|
<ReportesContent />
|
|
</ReportRangeProvider>
|
|
);
|
|
|
|
export default Reportes;
|