fase 3.3: comisiones por reglas y liquidacion (modulo skeen_comisiones: motor con reglas por servicio/categoria/articulo/membresia/paquete, monto fijo y escalonado por metas, base vendido/cobrado con y sin impuestos, neto de devoluciones, metas con bonos, ajustes y liquidacion por periodo); reporte legacy intacto con el motor apagado
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Stethoscope } from 'lucide-react';
|
||||
import { Stethoscope, ChevronDown, ChevronRight, Coins, Target } 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';
|
||||
import { odooApi, type ComisionRow, type ComisionReporteReglas, type ComisionEmpleadoCalc } from '../../services/odoo';
|
||||
import { useReportRange } from '../../lib/reportRange';
|
||||
import { useClinicSettings } from '../../lib/clinic-settings';
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value || 0);
|
||||
|
||||
|
||||
const ComisionesReport: FC = () => {
|
||||
// Rango global de Reportes; los campos Inicio/Fin fijan uno propio
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporte "de siempre": porcentaje plano por empleado (`/reports/comisiones`).
|
||||
// Se conserva intacto para cuando `comisiones.activo=false`.
|
||||
// ---------------------------------------------------------------------------
|
||||
const ReportePlano: FC = () => {
|
||||
const { start, end, setStart, setEnd } = useReportRange('comisiones');
|
||||
const [rows, setRows] = useState<ComisionRow[]>([]);
|
||||
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
||||
@@ -74,7 +77,6 @@ const ComisionesReport: FC = () => {
|
||||
<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">
|
||||
@@ -129,4 +131,216 @@ const ComisionesReport: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporte del motor (`/reports/comisiones_reglas`): desglose por regla/línea,
|
||||
// por servicio/categoría y comparativa contra metas.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FilaEmpleado: FC<{ emp: ComisionEmpleadoCalc }> = ({ emp }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<tr className="hover:bg-theme-bg cursor-pointer" onClick={() => setOpen((v) => !v)}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{open ? <ChevronDown size={15} className="text-theme-muted" /> : <ChevronRight size={15} className="text-theme-muted" />}
|
||||
<div>
|
||||
<p className="font-medium text-theme-heading">{emp.empleado}</p>
|
||||
<p className="text-xs text-theme-muted">{emp.staff_type} · {emp.citas_done} cita(s) · {emp.lineas_count} línea(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.base)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.comision)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.bonos)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.ajustes)}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold tabular-nums">{formatCurrency(emp.total)}</td>
|
||||
</tr>
|
||||
{open && (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-theme-bg px-4 py-4">
|
||||
{emp.metas.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-theme-heading uppercase mb-1 flex items-center gap-1"><Target size={13} />Metas</p>
|
||||
{emp.metas.map((m, i) => (
|
||||
<p key={i} className="text-xs text-theme-muted">
|
||||
{m.periodo}: objetivo {formatCurrency(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {formatCurrency(m.bono)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emp.por_regla.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-theme-heading uppercase mb-1 flex items-center gap-1"><Coins size={13} />Por regla</p>
|
||||
<table className="w-full text-xs">
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{emp.por_regla.map((r, i) => (
|
||||
<tr key={i}><td className="py-1">{r.regla} <span className="text-theme-muted">({r.lineas} línea/s)</span></td><td className="py-1 text-right tabular-nums">{formatCurrency(r.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(r.comision)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Desglose por línea</p>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-theme-muted sticky top-0 bg-theme-bg"><tr><th className="text-left py-1">Referencia</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{emp.desglose.map((d, i) => (
|
||||
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{formatCurrency(d.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(d.comision)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ReporteReglas: FC = () => {
|
||||
const { start, end, setStart, setEnd } = useReportRange('comisiones');
|
||||
const [data, setData] = useState<ComisionReporteReglas | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getComisionesReglasReport(start, end);
|
||||
if (res.status === 'success') setData(res);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al cargar comisiones');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
const filas = data.empleados.flatMap((e) => e.desglose.map((d) => ({ empleado: e.empleado, ...d })));
|
||||
if (filas.length === 0) { toast.info('Sin líneas que exportar'); return; }
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportToExcel({
|
||||
filename: `comisiones-reglas-${start}_${end}`,
|
||||
sheetName: 'Comisiones',
|
||||
title: 'Comisiones por reglas',
|
||||
subtitle: `Del ${start} al ${end} · base ${data.config.base}`,
|
||||
columns: [
|
||||
{ header: 'Empleado', key: 'empleado' },
|
||||
{ header: 'Fecha', key: 'fecha' },
|
||||
{ header: 'Folio', key: 'folio' },
|
||||
{ header: 'Servicio', key: 'servicio' },
|
||||
{ header: 'Categoría', key: 'categoria' },
|
||||
{ header: 'Regla', key: 'regla' },
|
||||
{ header: 'Base', key: 'base', format: 'currency' },
|
||||
{ header: 'Comisión', key: 'comision', format: 'currency' },
|
||||
{ header: 'Referencia', key: 'referencia' },
|
||||
],
|
||||
rows: filas as unknown as Record<string, unknown>[],
|
||||
});
|
||||
toast.success('Excel descargado');
|
||||
} catch { toast.error('Error al exportar'); }
|
||||
finally { setExporting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||
<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>
|
||||
{data && (
|
||||
<p className="text-sm text-theme-muted">
|
||||
Base: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.base)}</span>
|
||||
{' · '}Comisión: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.comision)}</span>
|
||||
{' · '}Total: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.total)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mt-2">
|
||||
Motor activo · base {data?.config.base} · {data?.config.incluir_impuestos ? 'con impuestos' : 'sin impuestos'} ·
|
||||
{' '}{data?.config.neto_de_devoluciones ? 'neto de devoluciones' : 'sin descontar devoluciones'}
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Card><Card.Body><Skeleton count={6} className="h-12 w-full" /></Card.Body></Card>
|
||||
) : !data || data.empleados.length === 0 ? (
|
||||
<Card><Card.Body>
|
||||
<EmptyState title="Sin comisiones" subtitle="No hay líneas recetadas ni reglas aplicables en el periodo." icon={<Stethoscope size={28} />} />
|
||||
</Card.Body></Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Body noPadding>
|
||||
<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">Empleado</th>
|
||||
<th className="text-right px-4 py-3">Base</th>
|
||||
<th className="text-right px-4 py-3">Comisión</th>
|
||||
<th className="text-right px-4 py-3">Bonos</th>
|
||||
<th className="text-right px-4 py-3">Ajustes</th>
|
||||
<th className="text-right px-4 py-3">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.empleados.map((e) => <FilaEmpleado key={e.empleado_id} emp={e} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<p className="text-sm font-semibold text-theme-heading mb-2">Por servicio</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-theme-muted"><tr><th className="text-left py-1">Servicio</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{(data.por_servicio || []).map((s, i) => (
|
||||
<tr key={i}><td className="py-1">{s.servicio}</td><td className="py-1 text-right tabular-nums">{formatCurrency(s.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(s.comision)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<p className="text-sm font-semibold text-theme-heading mb-2">Por categoría</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-theme-muted"><tr><th className="text-left py-1">Categoría</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{(data.por_categoria || []).map((c, i) => (
|
||||
<tr key={i}><td className="py-1">{c.categoria}</td><td className="py-1 text-right tabular-nums">{formatCurrency(c.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(c.comision)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ComisionesReport: FC = () => {
|
||||
const { comisiones } = useClinicSettings();
|
||||
// Con el motor apagado se muestra EXACTAMENTE el reporte de siempre.
|
||||
if (!comisiones.activo) return <ReportePlano />;
|
||||
return <ReporteReglas />;
|
||||
};
|
||||
|
||||
export default ComisionesReport;
|
||||
|
||||
Reference in New Issue
Block a user