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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Download, BarChart3 } from 'lucide-react';
|
||||
import {
|
||||
BarChart,
|
||||
@@ -14,13 +15,51 @@ import {
|
||||
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 { downloadCsv } from '../lib/utils';
|
||||
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;
|
||||
@@ -39,6 +78,9 @@ interface CashReport {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -55,6 +97,7 @@ const Reportes: FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'general') return;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -89,7 +132,7 @@ const Reportes: FC = () => {
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [start, end, today]);
|
||||
}, [start, end, today, tab]);
|
||||
|
||||
const aptData = appointmentsReport
|
||||
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
|
||||
@@ -98,54 +141,107 @@ const Reportes: FC = () => {
|
||||
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
|
||||
: [];
|
||||
|
||||
const exportCSV = () => {
|
||||
const rows: (string | number)[][] = [];
|
||||
rows.push(['Reporte SKEEN']);
|
||||
rows.push(['Rango', `${start} a ${end}`]);
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Ventas']);
|
||||
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
|
||||
if (salesReport) {
|
||||
rows.push([
|
||||
salesReport.total_sales,
|
||||
salesReport.total_paid,
|
||||
salesReport.total_due,
|
||||
salesReport.count,
|
||||
]);
|
||||
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);
|
||||
}
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Citas por estado']);
|
||||
rows.push(['Estado', 'Cantidad']);
|
||||
aptData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Efectivo por método']);
|
||||
rows.push(['Método', 'Monto']);
|
||||
cashData.forEach((row) => rows.push([row.name, row.value]));
|
||||
rows.push([]);
|
||||
|
||||
rows.push(['Comisiones por médico']);
|
||||
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
|
||||
commissions.forEach((c) =>
|
||||
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
|
||||
);
|
||||
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
|
||||
|
||||
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
|
||||
toast.success('CSV descargado');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Reportes" subtitle="Análisis del mes y día">
|
||||
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
|
||||
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
{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">
|
||||
@@ -295,6 +391,8 @@ const Reportes: FC = () => {
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user