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:
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal file
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Stethoscope } from 'lucide-react';
|
||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type ComisionRow } from '../../services/odoo';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
const inicioDeMes = () => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const ComisionesReport: FC = () => {
|
||||
const [start, setStart] = useState(inicioDeMes());
|
||||
const [end, setEnd] = useState(hoy());
|
||||
const [rows, setRows] = useState<ComisionRow[]>([]);
|
||||
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getComisiones(start, end);
|
||||
if (res.status === 'success') {
|
||||
setRows(res.comisiones);
|
||||
setTotales({ vendido: res.total_vendido, comision: res.total_comision });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar comisiones');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const exportar = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
await exportToExcel({
|
||||
filename: `comisiones-${start}_${end}`,
|
||||
sheetName: 'Comisiones',
|
||||
title: 'Comisiones por médico',
|
||||
subtitle: `Del ${start} al ${end}`,
|
||||
columns: [
|
||||
{ header: 'Médico', key: 'doctor' },
|
||||
{ header: 'Puesto', key: 'job_title' },
|
||||
{ header: 'Citas completadas', key: 'citas_done', format: 'number' },
|
||||
{ header: 'Líneas', key: 'items', format: 'number' },
|
||||
{ header: 'Ventas', key: 'sales', format: 'number' },
|
||||
{ header: 'Total vendido', key: 'total_vendido', format: 'currency' },
|
||||
{ header: '% Comisión', key: 'commission_pct', format: 'number' },
|
||||
{ header: 'Comisión estimada', key: 'comision', format: 'currency' },
|
||||
],
|
||||
rows: rows.map((r) => ({ ...r })),
|
||||
totals: { doctor: '', total_vendido: totales.vendido, comision: totales.comision },
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Vendido: <span className="font-semibold text-theme-heading">{formatCurrency(totales.vendido)}</span>
|
||||
{' · '}Comisiones: <span className="font-semibold text-theme-heading">{formatCurrency(totales.comision)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton count={6} className="h-12 w-full" />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin comisiones"
|
||||
subtitle="No hay artículos recetados por médicos en el periodo. Marca las líneas como “Artículo recetado” al crear la venta."
|
||||
icon={<Stethoscope size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<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">Citas completadas</th>
|
||||
<th className="text-right px-4 py-3">Líneas</th>
|
||||
<th className="text-right px-4 py-3 hidden sm:table-cell">Ventas</th>
|
||||
<th className="text-right px-4 py-3">Total vendido</th>
|
||||
<th className="text-right px-4 py-3">% Comisión</th>
|
||||
<th className="text-right px-4 py-3">Comisión estimada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.doctor_id} className="hover:bg-theme-bg">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{r.doctor}</p>
|
||||
<p className="text-xs text-theme-muted">{r.job_title || '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.citas_done}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.items}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{r.sales}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(r.total_vendido)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted">{r.commission_pct}%</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-theme-heading">{formatCurrency(r.comision)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComisionesReport;
|
||||
Reference in New Issue
Block a user