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:
2026-10-11 12:57:19 +00:00
parent a916753ebe
commit 3c4f1b14d9
27 changed files with 3399 additions and 17 deletions

View File

@@ -32,6 +32,7 @@ import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario';
import Compras from './pages/Compras';
import Liquidaciones from './pages/Liquidaciones';
import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads';
import Chat from './pages/Chat';
@@ -131,6 +132,15 @@ const ComprasRoute: FC = () => {
return <Compras />;
};
// Liquidaciones vive detrás de `comisiones.activo`. Apagado, la ruta rebota a
// "/" aunque el rol tenga permiso de menú (así apagarlo vuelve todo al estado
// anterior, incluso entrando por URL).
const LiquidacionesRoute: FC = () => {
const { comisiones } = useClinicSettings();
if (!comisiones.activo) return <Navigate to="/" replace />;
return <Liquidaciones />;
};
const HomeRoute: FC = () => {
const { user, canSee } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
@@ -193,6 +203,8 @@ const App: FC = () => {
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
{/* Compras solo cuando la clínica la activa (compras.activo). */}
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
{/* Liquidaciones de comisiones solo con comisiones.activo. */}
<Route path="/comisiones/liquidaciones" element={<RequireAuth href="/comisiones/liquidaciones"><LiquidacionesRoute /></RequireAuth>} />
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />

View File

@@ -146,11 +146,11 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Membresías y Compras solo cuando la clínica enciende su bandera.
// Membresías, Compras y Liquidaciones solo cuando la clínica enciende su bandera.
const menuTreeT = useMemo<MenuEntry[]>(() => {
let tree = menuTree;
if (comprasCfg.activo) {
@@ -161,8 +161,11 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
if (membresiasCfg.activo) {
tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }];
}
if (comisionesCfg.activo) {
tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }];
}
return tree;
}, [membresiasCfg.activo, comprasCfg.activo]);
}, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
const menuLeavesT = useMemo(
() => menuTreeT.flatMap((e) =>
e.children

View File

@@ -0,0 +1,492 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Coins, Plus, Pencil, Save, Trash2, Target, Wand2 } from 'lucide-react';
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
import {
odooApi, COMISIONES_DEFAULT,
type ClinicSettings, type ComisionesSettings, type ComisionRegla, type ComisionMeta,
type ComisionEmpleado, type Service, type ServiceCategory, type InventoryItem, type MembresiaPlan,
} from '../services/odoo';
// Configuración → Comisiones: ajustes del bloque + reglas + metas.
// Este panel es la llave para encender el motor (`comisiones.activo`). Las rutas
// de configuración funcionan aunque el motor esté apagado para poder dejar todo
// listo antes de activarlo.
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
const ALCANCES = [
{ value: 'global', label: 'Global (todo)' },
{ value: 'categoria', label: 'Categoría de servicio' },
{ value: 'servicio', label: 'Servicio' },
{ value: 'articulo', label: 'Artículo de inventario' },
{ value: 'membresia', label: 'Membresía / plan' },
{ value: 'paquete', label: 'Paquete' },
];
const TIPOS = [
{ value: 'porcentaje', label: 'Porcentaje' },
{ value: 'monto_fijo', label: 'Monto fijo' },
{ value: 'escalonado', label: 'Escalonado por metas' },
];
interface FormRegla {
id?: number;
name: string;
empleado_id: string;
staff_type: string;
alcance: string;
categoria_code: string;
servicio_id: string;
item_id: string;
plan_id: string;
tipo: string;
valor: string;
escalones: string;
base_override: string;
prioridad: string;
fecha_desde: string;
fecha_hasta: string;
activo: boolean;
notas: string;
}
const emptyRegla: FormRegla = {
name: '', empleado_id: '', staff_type: '', alcance: 'global', categoria_code: '',
servicio_id: '', item_id: '', plan_id: '', tipo: 'porcentaje', valor: '', escalones: '',
base_override: '', prioridad: '10', fecha_desde: '', fecha_hasta: '', activo: true, notas: '',
};
interface FormMeta {
id?: number;
empleado_id: string;
periodo: string;
monto_objetivo: string;
bono: string;
tipo_bono: string;
activo: boolean;
notas: string;
}
const emptyMeta: FormMeta = {
empleado_id: '', periodo: 'mensual', monto_objetivo: '', bono: '', tipo_bono: 'fijo',
activo: true, notas: '',
};
const ComisionesPanel: FC<Props> = ({ settings, onSaved }) => {
const [form, setForm] = useState<ComisionesSettings>(settings.comisiones || COMISIONES_DEFAULT);
const [saving, setSaving] = useState(false);
const [empleados, setEmpleados] = useState<ComisionEmpleado[]>([]);
const [reglas, setReglas] = useState<ComisionRegla[]>([]);
const [metas, setMetas] = useState<ComisionMeta[]>([]);
const [servicios, setServicios] = useState<Service[]>([]);
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
const [cargando, setCargando] = useState(false);
const [reglaOpen, setReglaOpen] = useState(false);
const [reglaForm, setReglaForm] = useState<FormRegla>(emptyRegla);
const [metaOpen, setMetaOpen] = useState(false);
const [metaForm, setMetaForm] = useState<FormMeta>(emptyMeta);
const [guardando, setGuardando] = useState(false);
useEffect(() => { setForm(settings.comisiones || COMISIONES_DEFAULT); }, [settings.comisiones]);
const cargar = useCallback(async () => {
setCargando(true);
try {
const [emp, reg, met] = await Promise.all([
odooApi.getComisionEmpleados(),
odooApi.getComisionesReglas(),
odooApi.getComisionesMetas(),
]);
setEmpleados(emp.empleados || []);
setReglas(reg.reglas || []);
setMetas(met.metas || []);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cargar la configuración de comisiones');
} finally {
setCargando(false);
}
}, []);
useEffect(() => { cargar(); }, [cargar]);
useEffect(() => {
odooApi.getServices({ page_size: 500 }).then((r) => { if (r.status === 'success') setServicios(r.services); }).catch(() => {});
odooApi.getServiceCategories().then((r) => { if (r.status === 'success') setCategorias(r.categories); }).catch(() => {});
odooApi.getInventarioItems().then((r) => { if (r.status === 'success') setItems(r.items); }).catch(() => {});
odooApi.getMembresiaPlanes().then((r) => { if (r.status === 'success') setPlanes(r.planes); }).catch(() => {});
}, []);
const set = <K extends keyof ComisionesSettings>(k: K, v: ComisionesSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const guardarConfig = async () => {
setSaving(true);
try {
const res = await odooApi.putClinicSettings({ comisiones: form, motivo: 'Configuración de comisiones' });
if (res.status === 'success') { onSaved(res.settings); toast.success('Configuración de comisiones guardada'); }
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la configuración');
} finally { setSaving(false); }
};
// ---- Reglas ----
const abrirNueva = (prefill?: Partial<FormRegla>) => { setReglaForm({ ...emptyRegla, ...prefill }); setReglaOpen(true); };
const abrirEditarRegla = (r: ComisionRegla) => {
setReglaForm({
id: r.id, name: r.name, empleado_id: r.empleado_id ? String(r.empleado_id) : '',
staff_type: r.staff_type || '', alcance: r.alcance, categoria_code: r.categoria_code || '',
servicio_id: r.servicio_id ? String(r.servicio_id) : '', item_id: r.item_id ? String(r.item_id) : '',
plan_id: r.plan_id ? String(r.plan_id) : '', tipo: r.tipo, valor: String(r.valor ?? ''),
escalones: r.escalones && r.escalones.length ? JSON.stringify(r.escalones) : '',
base_override: r.base_override || '', prioridad: String(r.prioridad ?? 10),
fecha_desde: r.fecha_desde || '', fecha_hasta: r.fecha_hasta || '', activo: r.activo, notas: r.notas || '',
});
setReglaOpen(true);
};
const guardarRegla = async () => {
if (!reglaForm.name.trim()) { toast.error('El nombre de la regla es obligatorio'); return; }
let escalones: unknown = undefined;
if (reglaForm.tipo === 'escalonado') {
try { escalones = reglaForm.escalones ? JSON.parse(reglaForm.escalones) : []; }
catch { toast.error('Los escalones deben ser JSON válido'); return; }
}
const payload: Record<string, unknown> = {
name: reglaForm.name.trim(),
empleado_id: reglaForm.empleado_id ? Number(reglaForm.empleado_id) : null,
staff_type: reglaForm.staff_type || null,
alcance: reglaForm.alcance,
categoria_code: reglaForm.categoria_code || '',
servicio_id: reglaForm.servicio_id ? Number(reglaForm.servicio_id) : null,
item_id: reglaForm.item_id ? Number(reglaForm.item_id) : null,
plan_id: reglaForm.plan_id ? Number(reglaForm.plan_id) : null,
tipo: reglaForm.tipo,
valor: reglaForm.tipo === 'escalonado' ? 0 : (parseFloat(reglaForm.valor) || 0),
base_override: reglaForm.base_override || null,
prioridad: parseInt(reglaForm.prioridad, 10) || 0,
fecha_desde: reglaForm.fecha_desde || null,
fecha_hasta: reglaForm.fecha_hasta || null,
activo: reglaForm.activo,
notas: reglaForm.notas,
...(escalones !== undefined ? { escalones } : {}),
};
setGuardando(true);
try {
if (reglaForm.id) await odooApi.updateComisionRegla(reglaForm.id, payload as Partial<ComisionRegla>);
else await odooApi.createComisionRegla(payload as Partial<ComisionRegla>);
toast.success('Regla guardada');
setReglaOpen(false);
await cargar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la regla');
} finally { setGuardando(false); }
};
const desactivarRegla = async (r: ComisionRegla) => {
try { await odooApi.deleteComisionRegla(r.id); toast.success('Regla desactivada'); await cargar(); }
catch { toast.error('No se pudo desactivar la regla'); }
};
// ---- Metas ----
const abrirNuevaMeta = () => { setMetaForm(emptyMeta); setMetaOpen(true); };
const abrirEditarMeta = (m: ComisionMeta) => {
setMetaForm({
id: m.id, empleado_id: String(m.empleado_id), periodo: m.periodo,
monto_objetivo: String(m.monto_objetivo ?? ''), bono: String(m.bono ?? ''),
tipo_bono: m.tipo_bono, activo: m.activo, notas: m.notas || '',
});
setMetaOpen(true);
};
const guardarMeta = async () => {
if (!metaForm.empleado_id || !metaForm.periodo.trim()) { toast.error('Empleado y periodo son obligatorios'); return; }
const payload: Partial<ComisionMeta> = {
empleado_id: Number(metaForm.empleado_id),
periodo: metaForm.periodo.trim(),
monto_objetivo: parseFloat(metaForm.monto_objetivo) || 0,
bono: parseFloat(metaForm.bono) || 0,
tipo_bono: metaForm.tipo_bono as ComisionMeta['tipo_bono'],
activo: metaForm.activo,
notas: metaForm.notas,
};
setGuardando(true);
try {
if (metaForm.id) await odooApi.updateComisionMeta(metaForm.id, payload);
else await odooApi.createComisionMeta(payload);
toast.success('Meta guardada');
setMetaOpen(false);
await cargar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la meta');
} finally { setGuardando(false); }
};
const desactivarMeta = async (m: ComisionMeta) => {
try { await odooApi.deleteComisionMeta(m.id); toast.success('Meta desactivada'); await cargar(); }
catch { toast.error('No se pudo desactivar la meta'); }
};
const empleadoOptions = useMemo(
() => [{ value: '', label: 'Todos los empleados' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))],
[empleados],
);
const nombreEmpleado = (id: number) => empleados.find((e) => e.id === id)?.name || `#${id}`;
const reglasPorEmpleado = useMemo(() => {
const grupos: { key: string; label: string; reglas: ComisionRegla[] }[] = [];
for (const e of empleados) {
const rs = reglas.filter((r) => r.empleado_id === e.id);
if (rs.length) grupos.push({ key: `e${e.id}`, label: e.name, reglas: rs });
}
const generales = reglas.filter((r) => !r.empleado_id);
if (generales.length) grupos.push({ key: 'general', label: 'Generales (todos)', reglas: generales });
return grupos;
}, [reglas, empleados]);
return (
<div className="space-y-6">
{/* Ajustes del bloque */}
<div>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center"><Coins size={20} className="mr-2" />Comisiones por reglas</h3>
<p className="text-sm text-theme-muted mb-4">
Motor de reglas configurable y liquidación por periodo. Con el módulo apagado, el reporte de comisiones de
siempre (porcentaje plano por empleado) sigue intacto.
</p>
<div className="mb-4 p-4 bg-theme-bg rounded-xl">
<Toggle label="Activar comisiones por reglas" checked={form.activo} onChange={(v) => set('activo', v)}
ayuda="Enciende el reporte nuevo, las liquidaciones y el acceso a las pantallas. Apagado, nada cambia." />
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Select label="Base" value={form.base} onChange={(e) => set('base', e.target.value as ComisionesSettings['base'])}
options={[{ value: 'cobrado', label: 'Cobrado (lo pagado)' }, { value: 'vendido', label: 'Vendido (subtotal)' }]} />
<Input label="Día de corte (1-28)" type="number" min={1} max={28} value={String(form.dia_corte)} onChange={(e) => set('dia_corte', parseInt(e.target.value, 10) || 1)} />
<Input label="Redondeo (decimales)" type="number" min={0} max={6} value={String(form.redondeo)} onChange={(e) => set('redondeo', parseInt(e.target.value, 10) || 0)} />
<Input label="Pago mínimo" type="number" min={0} step="0.01" value={String(form.pago_minimo)} onChange={(e) => set('pago_minimo', parseFloat(e.target.value) || 0)} />
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-4">
<Toggle label="Incluir impuestos" checked={form.incluir_impuestos} onChange={(v) => set('incluir_impuestos', v)} />
<Toggle label="Neto de devoluciones" checked={form.neto_de_devoluciones} onChange={(v) => set('neto_de_devoluciones', v)} />
<Toggle label="Permitir ajustes manuales" checked={form.permitir_ajustes_manuales} onChange={(v) => set('permitir_ajustes_manuales', v)} />
<Toggle label="Comisionar membresías" checked={form.comisionar_membresias} onChange={(v) => set('comisionar_membresias', v)} />
<Toggle label="Comisionar paquetes" checked={form.comisionar_paquetes} onChange={(v) => set('comisionar_paquetes', v)} />
<Toggle label="Comisionar productos" checked={form.comisionar_productos} onChange={(v) => set('comisionar_productos', v)} />
</div>
<div className="mt-4">
<Button onClick={guardarConfig} loading={saving}><Save size={16} className="mr-2" />Guardar configuración</Button>
</div>
</div>
{/* Empleados: respaldo commission_pct */}
<div>
<h3 className="font-heading text-lg text-theme-heading mb-2">% de respaldo por empleado</h3>
<p className="text-xs text-theme-muted mb-2">
Sin ninguna regla aplicable, se usa este porcentaje (el comportamiento de siempre).
</p>
<div className="overflow-x-auto rounded-xl border border-theme-border">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr><th className="text-left px-4 py-2">Empleado</th><th className="text-left px-4 py-2">Tipo</th><th className="text-right px-4 py-2">% respaldo</th><th className="text-right px-4 py-2">Reglas</th><th className="text-right px-4 py-2">Metas</th></tr>
</thead>
<tbody className="divide-y divide-theme-border">
{empleados.map((e) => (
<tr key={e.id}>
<td className="px-4 py-2 text-theme-heading">{e.name}</td>
<td className="px-4 py-2 text-theme-muted">{e.staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}</td>
<td className="px-4 py-2 text-right tabular-nums">{e.commission_pct}%</td>
<td className="px-4 py-2 text-right tabular-nums">{e.reglas_count}</td>
<td className="px-4 py-2 text-right tabular-nums">{e.metas_count}</td>
</tr>
))}
{empleados.length === 0 && <tr><td colSpan={5} className="px-4 py-4 text-center text-theme-muted">Sin empleados.</td></tr>}
</tbody>
</table>
</div>
</div>
{/* Reglas */}
<div>
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<h3 className="font-heading text-lg text-theme-heading">Reglas</h3>
<div className="flex flex-wrap gap-1.5">
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: '10% en todos los servicios', alcance: 'global', tipo: 'porcentaje', valor: '10' })}><Wand2 size={14} className="mr-1" />10% servicios</Button>
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: '5% en productos', alcance: 'articulo', tipo: 'porcentaje', valor: '5' })}><Wand2 size={14} className="mr-1" />5% productos</Button>
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: 'Escalonado por venta mensual', alcance: 'global', tipo: 'escalonado', escalones: JSON.stringify([{ desde: 0, hasta: 50000, porcentaje: 5 }, { desde: 50000, hasta: null, porcentaje: 8 }]) })}><Wand2 size={14} className="mr-1" />Escalonado</Button>
<Button size="sm" onClick={() => abrirNueva()}><Plus size={14} className="mr-1" />Nueva regla</Button>
</div>
</div>
{cargando ? (
<p className="text-sm text-theme-muted">Cargando…</p>
) : reglas.length === 0 ? (
<div className="rounded-xl border border-theme-border p-4 text-sm text-theme-muted">
Sin reglas. Sin reglas, cada empleado comisiona con su % de respaldo.
</div>
) : (
<div className="space-y-4">
{reglasPorEmpleado.map((g) => (
<div key={g.key}>
<p className="text-xs font-semibold text-theme-muted uppercase mb-1">{g.label}</p>
<div className="overflow-x-auto rounded-xl border border-theme-border">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr><th className="text-left px-3 py-2">Regla</th><th className="text-left px-3 py-2">Alcance</th><th className="text-left px-3 py-2">Tipo</th><th className="text-left px-3 py-2 hidden md:table-cell">Prioridad</th><th className="text-left px-3 py-2">Vigencia</th><th className="text-right px-3 py-2">Acciones</th></tr>
</thead>
<tbody className="divide-y divide-theme-border">
{g.reglas.map((r) => (
<tr key={r.id} className={r.activo ? '' : 'opacity-50'}>
<td className="px-3 py-2 text-theme-heading">{r.name}{!r.activo && <span className="ml-2 text-xs text-theme-muted">(inactiva)</span>}</td>
<td className="px-3 py-2 text-theme-muted">{r.resumen}</td>
<td className="px-3 py-2 text-theme-muted">{r.tipo_label}{r.tipo === 'porcentaje' ? ` ${r.valor}%` : r.tipo === 'monto_fijo' ? ` $${r.valor}` : ''}</td>
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{r.prioridad}{r.base_override ? ` · base ${r.base_override}` : ''}</td>
<td className="px-3 py-2 text-theme-muted text-xs">{(r.fecha_desde || '—')} a {(r.fecha_hasta || '—')}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button type="button" title="Editar" onClick={() => abrirEditarRegla(r)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
{r.activo && <button type="button" title="Desactivar" onClick={() => desactivarRegla(r)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Trash2 size={15} /></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
{/* Metas */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-heading text-lg text-theme-heading flex items-center"><Target size={18} className="mr-2" />Metas y bonos</h3>
<Button size="sm" onClick={abrirNuevaMeta}><Plus size={14} className="mr-1" />Nueva meta</Button>
</div>
{metas.length === 0 ? (
<div className="rounded-xl border border-theme-border p-4 text-sm text-theme-muted">Sin metas. Una meta paga un bono al alcanzar su objetivo de venta.</div>
) : (
<div className="overflow-x-auto rounded-xl border border-theme-border">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr><th className="text-left px-3 py-2">Empleado</th><th className="text-left px-3 py-2">Periodo</th><th className="text-right px-3 py-2">Objetivo</th><th className="text-left px-3 py-2">Bono</th><th className="text-right px-3 py-2">Acciones</th></tr>
</thead>
<tbody className="divide-y divide-theme-border">
{metas.map((m) => (
<tr key={m.id} className={m.activo ? '' : 'opacity-50'}>
<td className="px-3 py-2 text-theme-heading">{nombreEmpleado(m.empleado_id)}</td>
<td className="px-3 py-2 text-theme-muted">{m.periodo}</td>
<td className="px-3 py-2 text-right tabular-nums">{m.monto_objetivo.toLocaleString('es-MX')}</td>
<td className="px-3 py-2 text-theme-muted">{m.tipo_bono === 'fijo' ? `$${m.bono}` : `${m.bono}%`}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button type="button" title="Editar" onClick={() => abrirEditarMeta(m)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
{m.activo && <button type="button" title="Desactivar" onClick={() => desactivarMeta(m)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Trash2 size={15} /></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Editor de regla */}
<Modal
isOpen={reglaOpen}
onClose={() => setReglaOpen(false)}
title={reglaForm.id ? 'Editar regla de comisión' : 'Nueva regla de comisión'}
footer={<div className="flex justify-end gap-2"><Button variant="outline" onClick={() => setReglaOpen(false)}>Cancelar</Button><Button onClick={guardarRegla} loading={guardando}>Guardar</Button></div>}
>
<div className="space-y-3">
<Input label="Nombre" value={reglaForm.name} onChange={(e) => setReglaForm((f) => ({ ...f, name: e.target.value }))} />
<div className="grid grid-cols-2 gap-3">
<Select label="Empleado" value={reglaForm.empleado_id} onChange={(e) => setReglaForm((f) => ({ ...f, empleado_id: e.target.value }))} options={empleadoOptions} />
<Select label="Tipo de staff" value={reglaForm.staff_type} onChange={(e) => setReglaForm((f) => ({ ...f, staff_type: e.target.value }))} options={[{ value: '', label: 'Cualquiera' }, { value: 'doctor', label: 'Doctor' }, { value: 'cosmetologa', label: 'Cosmetóloga' }]} />
</div>
<div className="grid grid-cols-2 gap-3">
<Select label="Alcance" value={reglaForm.alcance} onChange={(e) => setReglaForm((f) => ({ ...f, alcance: e.target.value }))} options={ALCANCES} />
<Select label="Tipo" value={reglaForm.tipo} onChange={(e) => setReglaForm((f) => ({ ...f, tipo: e.target.value }))} options={TIPOS} />
</div>
{reglaForm.alcance === 'categoria' && (
<Select label="Categoría" value={reglaForm.categoria_code} onChange={(e) => setReglaForm((f) => ({ ...f, categoria_code: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...categorias.map((c) => ({ value: c.code, label: c.name }))]} />
)}
{reglaForm.alcance === 'servicio' && (
<Select label="Servicio" value={reglaForm.servicio_id} onChange={(e) => setReglaForm((f) => ({ ...f, servicio_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...servicios.map((s) => ({ value: String(s.id), label: s.name }))]} />
)}
{reglaForm.alcance === 'articulo' && (
<Select label="Artículo" value={reglaForm.item_id} onChange={(e) => setReglaForm((f) => ({ ...f, item_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...items.map((i) => ({ value: String(i.id), label: i.name }))]} />
)}
{reglaForm.alcance === 'membresia' && (
<Select label="Plan" value={reglaForm.plan_id} onChange={(e) => setReglaForm((f) => ({ ...f, plan_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...planes.map((p) => ({ value: String(p.id), label: p.name }))]} />
)}
{reglaForm.tipo === 'escalonado' ? (
<TextArea label="Escalones (JSON)" value={reglaForm.escalones} onChange={(e) => setReglaForm((f) => ({ ...f, escalones: e.target.value }))}
placeholder='[{"desde":0,"hasta":50000,"porcentaje":5},{"desde":50000,"hasta":null,"porcentaje":8}]' />
) : (
<Input label={reglaForm.tipo === 'porcentaje' ? 'Porcentaje (%)' : 'Monto fijo (m.n.)'} type="number" step="0.5" value={reglaForm.valor} onChange={(e) => setReglaForm((f) => ({ ...f, valor: e.target.value }))} />
)}
<div className="grid grid-cols-2 gap-3">
<Select label="Forzar base" value={reglaForm.base_override} onChange={(e) => setReglaForm((f) => ({ ...f, base_override: e.target.value }))} options={[{ value: '', label: 'Usar la del bloque' }, { value: 'vendido', label: 'Vendido' }, { value: 'cobrado', label: 'Cobrado' }]} />
<Input label="Prioridad" type="number" value={reglaForm.prioridad} onChange={(e) => setReglaForm((f) => ({ ...f, prioridad: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Vigente desde" type="date" value={reglaForm.fecha_desde} onChange={(e) => setReglaForm((f) => ({ ...f, fecha_desde: e.target.value }))} />
<Input label="Vigente hasta" type="date" value={reglaForm.fecha_hasta} onChange={(e) => setReglaForm((f) => ({ ...f, fecha_hasta: e.target.value }))} />
</div>
<TextArea label="Notas" value={reglaForm.notas} onChange={(e) => setReglaForm((f) => ({ ...f, notas: e.target.value }))} />
<Toggle label="Activa" checked={reglaForm.activo} onChange={(v) => setReglaForm((f) => ({ ...f, activo: v }))} />
</div>
</Modal>
{/* Editor de meta */}
<Modal
isOpen={metaOpen}
onClose={() => setMetaOpen(false)}
title={metaForm.id ? 'Editar meta' : 'Nueva meta'}
footer={<div className="flex justify-end gap-2"><Button variant="outline" onClick={() => setMetaOpen(false)}>Cancelar</Button><Button onClick={guardarMeta} loading={guardando}>Guardar</Button></div>}
>
<div className="space-y-3">
<Select label="Empleado" value={metaForm.empleado_id} onChange={(e) => setMetaForm((f) => ({ ...f, empleado_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))]} />
<Input label="Periodo (AAAA-MM o 'mensual')" value={metaForm.periodo} onChange={(e) => setMetaForm((f) => ({ ...f, periodo: e.target.value }))} />
<div className="grid grid-cols-2 gap-3">
<Input label="Monto objetivo" type="number" step="0.01" value={metaForm.monto_objetivo} onChange={(e) => setMetaForm((f) => ({ ...f, monto_objetivo: e.target.value }))} />
<Select label="Tipo de bono" value={metaForm.tipo_bono} onChange={(e) => setMetaForm((f) => ({ ...f, tipo_bono: e.target.value }))} options={[{ value: 'fijo', label: 'Monto fijo' }, { value: 'porcentaje', label: 'Porcentaje de la venta' }]} />
</div>
<Input label={metaForm.tipo_bono === 'fijo' ? 'Bono (m.n.)' : 'Bono (%)'} type="number" step="0.5" value={metaForm.bono} onChange={(e) => setMetaForm((f) => ({ ...f, bono: e.target.value }))} />
<TextArea label="Notas" value={metaForm.notas} onChange={(e) => setMetaForm((f) => ({ ...f, notas: e.target.value }))} />
<Toggle label="Activa" checked={metaForm.activo} onChange={(v) => setMetaForm((f) => ({ ...f, activo: v }))} />
</div>
</Modal>
</div>
);
};
export default ComisionesPanel;

View File

@@ -115,13 +115,14 @@ const MENU_SECTIONS = [
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
const HOME_COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
@@ -138,8 +139,13 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] }
: s));
}
if (comisionesCfg.activo) {
base = base.map((s) => (s.label === 'Analítica y más'
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
: s));
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View File

@@ -127,13 +127,15 @@ const menuGroups = [
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
// La sección Compras solo existe con `compras.activo=true`.
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
const COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
let base = menuGroups;
if (membresiasCfg.activo) {
@@ -146,8 +148,13 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
? { ...g, items: [...g.items, COMPRAS_ITEM] }
: g));
}
if (comisionesCfg.activo) {
base = base.map((g) => (g.label === 'Analítica'
? { ...g, items: [...g.items, COMISIONES_ITEM] }
: g));
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -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;

View File

@@ -40,6 +40,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
// Membresías (apagado por defecto: la sección solo se pinta con
// membresias.activo=true). Se lista aquí para que canSee la reconozca.
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
// Liquidaciones de comisiones (solo se pinta con comisiones.activo=true).
'/comisiones/liquidaciones': ['admin', 'recepcion', 'gerencia', 'medico'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -13,6 +13,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -32,6 +32,9 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Recursos', href: '/reportes?tab=recursos', termKey: 'room_plural' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Membresías', href: '/membresias' },
// Liquidaciones de comisiones (solo se pinta en el menú con
// `comisiones.activo=true`; de lo contrario queda idéntico al de siempre).
{ label: 'Liquidaciones', href: '/comisiones/liquidaciones' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },

View File

@@ -22,6 +22,7 @@ export const PERM_KEYS: string[] = [
'membresias.cancelar',
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
];
@@ -50,6 +51,7 @@ const READ_PERM_KEYS = new Set([
'membresias.ver',
'compras.ver',
'proveedores.ver',
'comisiones.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -80,6 +82,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
};

View File

@@ -20,6 +20,7 @@ import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPan
import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import ComisionesPanel from '../components/ComisionesPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
@@ -36,6 +37,7 @@ const TABS = [
{ key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'compras', label: 'Compras' },
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
@@ -46,7 +48,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -72,6 +74,7 @@ const Configuracion: FC = () => {
formularios: 'formularios.ver',
membresias: 'membresias.editar',
compras: 'compras.editar',
comisiones: 'comisiones.editar',
recursos: 'recursos.editar',
};
const tabsVisibles = useMemo(
@@ -406,6 +409,12 @@ const Configuracion: FC = () => {
<ComprasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'comisiones' ? (
<Card>
<Card.Body>
<ComisionesPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>

View File

@@ -0,0 +1,601 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Coins, Download, Eye, HandCoins, Plus, RefreshCw, Receipt, Wallet, XCircle } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, Modal, PageHeader, EmptyState, toast } from '../components/ui';
import {
odooApi,
type ComisionCalculo,
type ComisionEmpleado,
type ComisionEmpleadoCalc,
type ComisionLiquidacion,
} from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { exportToExcel } from '../lib/exporter';
const money = (v: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }).format(v || 0);
const mesActual = () => {
const d = new Date();
return {
start: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`,
end: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()).padStart(2, '0')}`,
};
};
const apiMessage = (err: unknown, fallback: string): string =>
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback;
const ESTADO_CHIP: Record<string, string> = {
borrador: 'bg-amber-100 text-amber-800',
pagada: 'bg-emerald-100 text-emerald-800',
cancelada: 'bg-rose-100 text-rose-700',
};
// ---------------------------------------------------------------------------
// Mi recibo (rol médico): solo sus liquidaciones pagadas.
// ---------------------------------------------------------------------------
const MiRecibo: FC = () => {
const [rows, setRows] = useState<ComisionLiquidacion[]>([]);
const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<ComisionLiquidacion | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getMisLiquidaciones();
setRows(res.liquidaciones || []);
} catch {
toast.error('No se pudieron cargar tus liquidaciones');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<Layout title="Mi liquidación" subtitle="Recibos de comisión pagados">
<PageHeader title="Mi liquidación" subtitle="Recibos de comisión de tu empleado">
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
</PageHeader>
<Card>
<Card.Body noPadding>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Cargando…</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState title="Sin recibos" subtitle="Aún no tienes liquidaciones pagadas." icon={<Receipt size={28} />} />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Folio</th>
<th className="text-left px-4 py-3">Periodo</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-left px-4 py-3">Pagada</th>
<th className="text-right px-4 py-3">Recibo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((l) => (
<tr key={l.id}>
<td className="px-4 py-3 font-medium text-theme-heading">{l.folio}</td>
<td className="px-4 py-3 text-theme-muted">{l.periodo_inicio} a {l.periodo_fin}</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(l.total)}</td>
<td className="px-4 py-3 text-theme-muted">{l.fecha_pago || '—'}</td>
<td className="px-4 py-3 text-right">
<button type="button" title="Ver recibo" onClick={() => setSel(l)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<DetalleLiquidacion liq={sel} onClose={() => setSel(null)} />
</Layout>
);
};
// ---------------------------------------------------------------------------
// Detalle de una liquidación (desglose congelado + ajustes)
// ---------------------------------------------------------------------------
const DetalleLiquidacion: FC<{ liq: ComisionLiquidacion | null; onClose: () => void }> = ({ liq, onClose }) => {
const det = liq?.detalle;
return (
<Modal
isOpen={!!liq}
onClose={onClose}
title={`Liquidación ${liq?.folio || ''} · ${liq?.empleado || ''}`}
footer={<div className="flex justify-end"><Button variant="outline" onClick={onClose}>Cerrar</Button></div>}
>
{liq && (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div><p className="text-xs text-theme-muted">Periodo</p><p className="text-sm font-medium text-theme-heading">{liq.periodo_inicio} a {liq.periodo_fin}</p></div>
<div><p className="text-xs text-theme-muted">Comisión calculada</p><p className="text-sm tabular-nums">{money(liq.total_calculado)}</p></div>
<div><p className="text-xs text-theme-muted">Ajustes</p><p className="text-sm tabular-nums">{money(liq.total_ajustes)}</p></div>
<div><p className="text-xs text-theme-muted">Total</p><p className="text-sm font-semibold tabular-nums">{money(liq.total)}</p></div>
</div>
{liq.estado === 'pagada' && (
<p className="text-xs text-theme-muted">
Pagada el {liq.fecha_pago} · {liq.metodo_pago || 'sin método'} {liq.referencia ? `· ref ${liq.referencia}` : ''} · por {liq.pagada_por_login || '—'}
</p>
)}
{det?.por_regla && det.por_regla.length > 0 && (
<div>
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Por regla</p>
<table className="w-full text-xs">
<thead className="text-theme-muted"><tr><th className="text-left py-1">Regla</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">
{det.por_regla.map((r, i) => (
<tr key={i}><td className="py-1">{r.regla}</td><td className="py-1 text-right tabular-nums">{money(r.base)}</td><td className="py-1 text-right tabular-nums">{money(r.comision)}</td></tr>
))}
</tbody>
</table>
</div>
)}
{det?.desglose && det.desglose.length > 0 && (
<div>
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Desglose por línea</p>
<div className="max-h-56 overflow-y-auto">
<table className="w-full text-xs">
<thead className="text-theme-muted sticky top-0 bg-theme-surface"><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">
{det.desglose.map((d, i) => (
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{money(d.base)}</td><td className="py-1 text-right tabular-nums">{money(d.comision)}</td></tr>
))}
</tbody>
</table>
</div>
</div>
)}
{det?.metas && det.metas.length > 0 && (
<div>
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Metas</p>
{det.metas.map((m, i) => (
<p key={i} className="text-xs text-theme-muted">
{m.periodo} · objetivo {money(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {money(m.bono)}
</p>
))}
</div>
)}
{liq.ajustes && liq.ajustes.length > 0 && (
<div>
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Ajustes</p>
{liq.ajustes.map((a) => (
<p key={a.id} className="text-xs text-theme-muted">{money(a.monto)} · {a.motivo || 'sin motivo'} · {a.usuario || '—'}</p>
))}
</div>
)}
</div>
)}
</Modal>
);
};
// ---------------------------------------------------------------------------
// Página principal (admin/gerencia)
// ---------------------------------------------------------------------------
const Liquidaciones: FC = () => {
const { user, can } = useAuth();
const { comisiones } = useClinicSettings();
const esMedico = user?.role === 'medico';
const [rango, setRango] = useState(mesActual);
const { start, end } = rango;
const [calc, setCalc] = useState<ComisionCalculo | null>(null);
const [empleados, setEmpleados] = useState<ComisionEmpleado[]>([]);
const [empFiltro, setEmpFiltro] = useState('');
const [liqs, setLiqs] = useState<ComisionLiquidacion[]>([]);
const [estadoFiltro, setEstadoFiltro] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [detalleCalc, setDetalleCalc] = useState<ComisionEmpleadoCalc | null>(null);
const [detalleLiq, setDetalleLiq] = useState<ComisionLiquidacion | null>(null);
const [ajustar, setAjustar] = useState<ComisionLiquidacion | null>(null);
const [ajusteMonto, setAjusteMonto] = useState('');
const [ajusteMotivo, setAjusteMotivo] = useState('');
const [pagar, setPagar] = useState<ComisionLiquidacion | null>(null);
const [pagoFecha, setPagoFecha] = useState('');
const [pagoMetodo, setPagoMetodo] = useState('transferencia');
const [pagoRef, setPagoRef] = useState('');
const loadCalc = useCallback(async () => {
try {
const res = await odooApi.getComisionCalculo(start, end, empFiltro ? Number(empFiltro) : undefined);
if (res.status === 'success') setCalc(res);
} catch (err) {
toast.error(apiMessage(err, 'No se pudo calcular el periodo'));
}
}, [start, end, empFiltro]);
const loadLiqs = useCallback(async () => {
try {
const res = await odooApi.getComisionesLiquidaciones({
...(empFiltro ? { empleado_id: Number(empFiltro) } : {}),
...(estadoFiltro ? { estado: estadoFiltro as ComisionLiquidacion['estado'] } : {}),
});
setLiqs(res.liquidaciones || []);
} catch (err) {
toast.error(apiMessage(err, 'No se pudieron cargar las liquidaciones'));
}
}, [empFiltro, estadoFiltro]);
const load = useCallback(async () => {
setLoading(true);
try {
const [emps] = await Promise.all([odooApi.getComisionEmpleados()]);
setEmpleados(emps.empleados || []);
await Promise.all([loadCalc(), loadLiqs()]);
} catch (err) {
toast.error(apiMessage(err, 'No se pudo cargar la información de comisiones'));
} finally {
setLoading(false);
}
}, [loadCalc, loadLiqs]);
useEffect(() => { if (!esMedico) load(); }, [load, esMedico]);
const generar = async () => {
setBusy(true);
try {
const res = await odooApi.generarComisionesLiquidaciones({ start, end });
if (res.total_creadas > 0) toast.success(`Se generaron ${res.total_creadas} borrador(es)`);
else toast.info('No había nada nuevo que liquidar');
if (res.omitidas?.length) toast.info(`${res.omitidas.length} empleado(s) ya tenían liquidación del periodo`);
await Promise.all([loadLiqs(), loadCalc()]);
} catch (err) {
toast.error(apiMessage(err, 'No se pudieron generar las liquidaciones'));
} finally {
setBusy(false);
}
};
const hacerAjuste = async () => {
if (!ajustar) return;
const monto = parseFloat(ajusteMonto);
if (Number.isNaN(monto) || monto === 0) { toast.error('Indica un monto distinto de cero'); return; }
setBusy(true);
try {
await odooApi.ajustarComisionLiquidacion(ajustar.id, { monto, motivo: ajusteMotivo });
toast.success('Ajuste registrado');
setAjustar(null); setAjusteMonto(''); setAjusteMotivo('');
await Promise.all([loadLiqs(), loadCalc()]);
} catch (err) {
toast.error(apiMessage(err, 'No se pudo registrar el ajuste'));
} finally { setBusy(false); }
};
const hacerPago = async () => {
if (!pagar) return;
setBusy(true);
try {
await odooApi.pagarComisionLiquidacion(pagar.id, { fecha: pagoFecha || undefined, metodo_pago: pagoMetodo, referencia: pagoRef });
toast.success('Liquidación marcada como pagada');
setPagar(null); setPagoRef('');
await loadLiqs();
} catch (err) {
toast.error(apiMessage(err, 'No se pudo pagar'));
} finally { setBusy(false); }
};
const cancelarLiq = async (l: ComisionLiquidacion) => {
const motivo = window.prompt('Motivo de cancelación', '') || '';
try {
await odooApi.cancelarComisionLiquidacion(l.id, motivo);
toast.success('Liquidación cancelada');
await loadLiqs();
} catch (err) {
toast.error(apiMessage(err, 'No se pudo cancelar'));
}
};
const verDetalle = async (l: ComisionLiquidacion) => {
try {
const res = await odooApi.getComisionLiquidacion(l.id);
setDetalleLiq(res.liquidacion);
} catch (err) {
toast.error(apiMessage(err, 'No se pudo abrir la liquidación'));
}
};
const exportarCalc = async () => {
if (!calc) return;
try {
await exportToExcel({
filename: `comisiones-calculo-${start}_${end}`,
sheetName: 'Cálculo',
title: 'Cálculo de comisiones del periodo',
subtitle: `Del ${start} al ${end} · base ${comisiones.base}${comisiones.incluir_impuestos ? ' con impuestos' : ''}`,
columns: [
{ header: 'Empleado', key: 'empleado' },
{ header: 'Base', key: 'base', format: 'currency' },
{ header: 'Comisión', key: 'comision', format: 'currency' },
{ header: 'Bonos', key: 'bonos', format: 'currency' },
{ header: 'Ajustes', key: 'ajustes', format: 'currency' },
{ header: 'Total', key: 'total', format: 'currency' },
],
rows: calc.empleados as unknown as Record<string, unknown>[],
totals: { empleado: 'Totales', base: calc.totales.base, comision: calc.totales.comision, bonos: calc.totales.bonos, ajustes: calc.totales.ajustes, total: calc.totales.total },
});
toast.success('Excel descargado');
} catch { toast.error('No se pudo exportar'); }
};
const exportarDesglose = async () => {
if (!calc) return;
const filas = calc.empleados.flatMap((e) => e.desglose.map((d) => ({ empleado: e.empleado, ...d })));
if (filas.length === 0) { toast.info('Sin líneas que exportar'); return; }
try {
await exportToExcel({
filename: `comisiones-desglose-${start}_${end}`,
sheetName: 'Desglose',
title: 'Desglose de comisiones por línea',
subtitle: `Del ${start} al ${end}`,
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('No se pudo exportar'); }
};
const empleadoOptions = useMemo(
() => [{ value: '', label: 'Todos los empleados' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))],
[empleados],
);
if (esMedico) return <MiRecibo />;
return (
<Layout title="Liquidaciones" subtitle="Cálculo del periodo, ajustes y pago de comisiones">
<PageHeader title="Liquidaciones de comisiones" subtitle="Genera el borrador del periodo, ajusta y marca lo pagado">
{can('comisiones.liquidar') && (
<Button onClick={generar} loading={busy}><Plus size={16} className="mr-1.5" />Generar borradores</Button>
)}
{can('reportes.exportar') && (
<>
<Button variant="outline" onClick={exportarCalc} disabled={!calc || calc.empleados.length === 0}><Download size={16} className="mr-1.5" />Excel cálculo</Button>
<Button variant="outline" onClick={exportarDesglose} disabled={!calc}><Download size={16} className="mr-1.5" />Excel desglose</Button>
</>
)}
</PageHeader>
<div className="flex flex-wrap items-end gap-3 mb-4">
<Input label="Inicio" type="date" value={start} onChange={(e) => setRango((r) => ({ ...r, start: e.target.value }))} className="sm:max-w-[170px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setRango((r) => ({ ...r, end: e.target.value }))} className="sm:max-w-[170px]" />
<Select label="Empleado" options={empleadoOptions} value={empFiltro} onChange={(e) => setEmpFiltro(e.target.value)} className="sm:max-w-[240px]" />
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
<p className="text-xs text-theme-muted ml-auto">
Base: <span className="font-medium text-theme-heading">{comisiones.base}</span>
{' · '}Impuestos: <span className="font-medium text-theme-heading">{comisiones.incluir_impuestos ? 'incluidos' : 'no'}</span>
{' · '}Devoluciones: <span className="font-medium text-theme-heading">{comisiones.neto_de_devoluciones ? 'netas' : 'brutas'}</span>
</p>
</div>
{/* Cálculo del periodo */}
<Card className="mb-6">
<Card.Body noPadding>
<div className="px-4 py-3 border-b border-theme-border flex items-center justify-between">
<p className="text-sm font-semibold text-theme-heading flex items-center gap-2"><Coins size={16} />Cálculo del periodo ({start} a {end})</p>
{calc && (
<p className="text-xs text-theme-muted">
Base {money(calc.totales.base)} · Comisión {money(calc.totales.comision)} · Bonos {money(calc.totales.bonos)} · Total {money(calc.totales.total)}
</p>
)}
</div>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Calculando…</div>
) : !calc || calc.empleados.length === 0 ? (
<div className="p-6"><EmptyState title="Sin comisiones en el periodo" subtitle="No hay líneas recetadas ni reglas aplicables en el rango." icon={<Wallet size={28} />} /></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] 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 hidden sm:table-cell">Bonos</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Ajustes</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-right px-4 py-3">Detalle</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{calc.empleados.map((e) => (
<tr key={e.empleado_id}>
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{e.empleado}</p>
<p className="text-xs text-theme-muted">{e.staff_type} · {e.lineas_count} línea(s){e.pagable ? '' : ' · bajo el mínimo'}</p>
</td>
<td className="px-4 py-3 text-right tabular-nums">{money(e.base)}</td>
<td className="px-4 py-3 text-right tabular-nums">{money(e.comision)}</td>
<td className="px-4 py-3 text-right tabular-nums hidden sm:table-cell">{money(e.bonos)}</td>
<td className="px-4 py-3 text-right tabular-nums hidden sm:table-cell">{money(e.ajustes)}</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(e.total)}</td>
<td className="px-4 py-3 text-right">
<button type="button" title="Ver desglose" onClick={() => setDetalleCalc(e)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Liquidaciones */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<p className="text-sm font-semibold text-theme-heading flex items-center gap-2"><Receipt size={16} />Liquidaciones</p>
{['', 'borrador', 'pagada', 'cancelada'].map((e) => (
<button
key={e || 'todas'}
type="button"
onClick={() => setEstadoFiltro(e)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${estadoFiltro === e ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:text-theme-heading'}`}
>
{e === '' ? 'Todas' : e.charAt(0).toUpperCase() + e.slice(1)}
</button>
))}
</div>
<Card>
<Card.Body noPadding>
{liqs.length === 0 ? (
<div className="p-6"><EmptyState title="Sin liquidaciones" subtitle="Genera el borrador del periodo para comenzar." icon={<HandCoins size={28} />} /></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Folio</th>
<th className="text-left px-4 py-3">Empleado</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Periodo</th>
<th className="text-right px-4 py-3">Calculado</th>
<th className="text-right px-4 py-3">Ajustes</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="text-right px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{liqs.map((l) => (
<tr key={l.id}>
<td className="px-4 py-3 font-medium text-theme-heading">{l.folio}</td>
<td className="px-4 py-3 text-theme-heading">{l.empleado}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{l.periodo_inicio} a {l.periodo_fin}</td>
<td className="px-4 py-3 text-right tabular-nums">{money(l.total_calculado)}</td>
<td className="px-4 py-3 text-right tabular-nums">{money(l.total_ajustes)}</td>
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(l.total)}</td>
<td className="px-4 py-3"><span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${ESTADO_CHIP[l.estado] || 'bg-theme-bg text-theme-muted'}`}>{l.estado_label}</span></td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button type="button" title="Ver" onClick={() => verDetalle(l)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
{can('comisiones.liquidar') && l.estado === 'borrador' && comisiones.permitir_ajustes_manuales && (
<button type="button" title="Ajustar" onClick={() => { setAjustar(l); setAjusteMonto(''); setAjusteMotivo(''); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Coins size={15} /></button>
)}
{can('comisiones.liquidar') && l.estado === 'borrador' && (
<button type="button" title="Pagar" onClick={() => { setPagar(l); setPagoFecha(l.periodo_fin || ''); setPagoMetodo('transferencia'); setPagoRef(''); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><HandCoins size={15} /></button>
)}
{can('comisiones.liquidar') && l.estado === 'borrador' && (
<button type="button" title="Cancelar" onClick={() => cancelarLiq(l)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><XCircle size={15} /></button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Detalle del cálculo en vivo */}
<Modal
isOpen={!!detalleCalc}
onClose={() => setDetalleCalc(null)}
title={`Comisión de ${detalleCalc?.empleado || ''}`}
footer={<div className="flex justify-end"><Button variant="outline" onClick={() => setDetalleCalc(null)}>Cerrar</Button></div>}
>
{detalleCalc && (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div><p className="text-xs text-theme-muted">Base</p><p className="text-sm tabular-nums">{money(detalleCalc.base)}</p></div>
<div><p className="text-xs text-theme-muted">Comisión</p><p className="text-sm tabular-nums">{money(detalleCalc.comision)}</p></div>
<div><p className="text-xs text-theme-muted">Bonos</p><p className="text-sm tabular-nums">{money(detalleCalc.bonos)}</p></div>
<div><p className="text-xs text-theme-muted">Total</p><p className="text-sm font-semibold tabular-nums">{money(detalleCalc.total)}</p></div>
</div>
{detalleCalc.por_regla.length > 0 && (
<div>
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Por regla</p>
<table className="w-full text-xs">
<tbody className="divide-y divide-theme-border">
{detalleCalc.por_regla.map((r, i) => (
<tr key={i}><td className="py-1">{r.regla} <span className="text-theme-muted">({r.lineas})</span></td><td className="py-1 text-right tabular-nums">{money(r.base)}</td><td className="py-1 text-right tabular-nums">{money(r.comision)}</td></tr>
))}
</tbody>
</table>
</div>
)}
<div className="max-h-60 overflow-y-auto">
<table className="w-full text-xs">
<thead className="text-theme-muted"><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">
{detalleCalc.desglose.map((d, i) => (
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{money(d.base)}</td><td className="py-1 text-right tabular-nums">{money(d.comision)}</td></tr>
))}
</tbody>
</table>
</div>
{detalleCalc.metas.map((m, i) => (
<p key={i} className="text-xs text-theme-muted">Meta {m.periodo}: objetivo {money(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {money(m.bono)}</p>
))}
</div>
)}
</Modal>
<DetalleLiquidacion liq={detalleLiq} onClose={() => setDetalleLiq(null)} />
{/* Ajuste */}
<Modal
isOpen={!!ajustar}
onClose={() => setAjustar(null)}
title={`Ajuste para ${ajustar?.empleado || ''}`}
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAjustar(null)}>Cancelar</Button>
<Button onClick={hacerAjuste} loading={busy}>Guardar ajuste</Button>
</div>
}
>
<p className="text-sm text-theme-muted mb-3">Usa un monto positivo para sumar o negativo para descontar.</p>
<Input label="Monto" type="number" value={ajusteMonto} onChange={(e) => setAjusteMonto(e.target.value)} placeholder="Ej. 500 o -200" />
<div className="mt-3"><Input label="Motivo" value={ajusteMotivo} onChange={(e) => setAjusteMotivo(e.target.value)} /></div>
</Modal>
{/* Pago */}
<Modal
isOpen={!!pagar}
onClose={() => setPagar(null)}
title={`Pagar ${pagar?.folio || ''} · ${pagar?.empleado || ''}`}
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setPagar(null)}>Cancelar</Button>
<Button onClick={hacerPago} loading={busy}>Marcar pagada ({money(pagar?.total || 0)})</Button>
</div>
}
>
<div className="grid grid-cols-2 gap-3">
<Input label="Fecha de pago" type="date" value={pagoFecha} onChange={(e) => setPagoFecha(e.target.value)} />
<Select label="Método" value={pagoMetodo} onChange={(e) => setPagoMetodo(e.target.value)} options={[
{ value: 'efectivo', label: 'Efectivo' },
{ value: 'transferencia', label: 'Transferencia' },
{ value: 'tarjeta', label: 'Tarjeta' },
{ value: 'otro', label: 'Otro' },
]} />
</div>
<div className="mt-3"><Input label="Referencia" value={pagoRef} onChange={(e) => setPagoRef(e.target.value)} /></div>
</Modal>
</Layout>
);
};
export default Liquidaciones;

View File

@@ -3,8 +3,9 @@ import { useEffect, useMemo, useState } from 'react';
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia, type ComisionEmpleadoCalc } from '../services/odoo';
import { useGlosario } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
@@ -190,12 +191,16 @@ const Medicos: FC = () => {
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
const [savingPct, setSavingPct] = useState<number | null>(null);
const { comisiones } = useClinicSettings();
// Modal de detalle del médico
const [selected, setSelected] = useState<Doctor | null>(null);
const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info');
const [hist, setHist] = useState<DoctorHistory | null>(null);
const [histLoading, setHistLoading] = useState(false);
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
// Comisión del mes en curso calculada con el motor (solo con comisiones.activo).
const [comisionCalc, setComisionCalc] = useState<ComisionEmpleadoCalc | null>(null);
// Meta mensual (editable en el modal) y horario semanal
const [goalDraft, setGoalDraft] = useState('');
@@ -214,6 +219,7 @@ const Medicos: FC = () => {
setModalTab('info');
setHistSection('citas');
setHist(null);
setComisionCalc(null);
setGoalDraft(String(d.monthly_goal ?? 0));
setDias(draftDesdeHorario(d.horario));
setHistLoading(true);
@@ -221,6 +227,16 @@ const Medicos: FC = () => {
.then((res) => { if (res.status === 'success') setHist(res); })
.catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); })
.finally(() => setHistLoading(false));
if (comisiones.activo) {
const hoy = new Date();
const start = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-01`;
const end = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(new Date(hoy.getFullYear(), hoy.getMonth() + 1, 0).getDate()).padStart(2, '0')}`;
odooApi.getComisionCalculo(start, end, d.id)
.then((res) => {
if (res.status === 'success') setComisionCalc(res.empleados[0] || null);
})
.catch(() => { /* motor apagado o sin permiso: se omite el bloque */ });
}
};
const saveGoal = async (d: Doctor) => {
@@ -638,6 +654,56 @@ const Medicos: FC = () => {
</div>
</div>
</div>
{comisiones.activo && comisionCalc && (
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Comisión del mes (motor de reglas)</p>
<div className="p-4 bg-theme-bg rounded-xl space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<p className="text-xs text-theme-muted">Base</p>
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.base)}</p>
</div>
<div>
<p className="text-xs text-theme-muted">Comisión</p>
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.comision)}</p>
</div>
<div>
<p className="text-xs text-theme-muted">Bonos</p>
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.bonos)}</p>
</div>
<div>
<p className="text-xs text-theme-muted">Total</p>
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.total)}</p>
</div>
</div>
{comisionCalc.por_regla.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-theme-muted uppercase mb-1">Reglas aplicadas</p>
{comisionCalc.por_regla.map((r, i) => (
<p key={i} className="text-xs text-theme-muted">
{r.regla} · {fmtMoney(r.comision)} <span className="text-theme-muted">({r.lineas} línea/s)</span>
</p>
))}
</div>
) : (
<p className="text-xs text-theme-muted">
Sin reglas aplicables: se usa su % de respaldo ({selected.commission_pct ?? 0}%).
</p>
)}
{comisionCalc.metas.length > 0 && (
<div>
<p className="text-[11px] font-medium text-theme-muted uppercase mb-1">Metas</p>
{comisionCalc.metas.map((m, i) => (
<p key={i} className="text-xs text-theme-muted">
{m.periodo}: objetivo {fmtMoney(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {fmtMoney(m.bono)}
</p>
))}
</div>
)}
</div>
</div>
)}
</div>
) : modalTab === 'horario' ? (
<div className="space-y-4">

View File

@@ -1075,6 +1075,7 @@ export interface ClinicSettings {
membresias: MembresiasSettings;
recursos: RecursosSettings;
compras: ComprasSettings;
comisiones: ComisionesSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1268,6 +1269,211 @@ export const COMPRAS_DEFAULT: ComprasSettings = {
permitir_compras_sin_orden: false,
};
// ---- Comisiones por reglas y liquidación ----
export interface ComisionesSettings {
/** Enciende el motor de reglas, el reporte nuevo y las liquidaciones. */
activo: boolean;
/** 'cobrado' (sobre lo pagado) o 'vendido' (sobre el subtotal de la línea). */
base: 'cobrado' | 'vendido';
/** La base suma el impuesto de la línea. */
incluir_impuestos: boolean;
/** Resta la parte devuelta de la venta. */
neto_de_devoluciones: boolean;
comisionar_membresias: boolean;
comisionar_paquetes: boolean;
comisionar_productos: boolean;
/** Habilita sumar/restar ajustes en la liquidación. */
permitir_ajustes_manuales: boolean;
/** Día del mes de corte sugerido (1-28). */
dia_corte: number;
/** Decimales a los que se redondea la comisión. */
redondeo: number;
/** Total mínimo a liquidar (por debajo no es pagable). */
pago_minimo: number;
}
export const COMISIONES_DEFAULT: ComisionesSettings = {
activo: false,
base: 'cobrado',
incluir_impuestos: false,
neto_de_devoluciones: true,
comisionar_membresias: true,
comisionar_paquetes: true,
comisionar_productos: true,
permitir_ajustes_manuales: true,
dia_corte: 1,
redondeo: 2,
pago_minimo: 0,
};
export type AlcanceComision = 'global' | 'categoria' | 'servicio' | 'articulo' | 'membresia' | 'paquete';
export type TipoComision = 'porcentaje' | 'monto_fijo' | 'escalonado';
export type TipoBonoComision = 'fijo' | 'porcentaje';
export type EstadoLiquidacion = 'borrador' | 'pagada' | 'cancelada';
export interface EscalonComision {
desde: number;
hasta: number | null;
porcentaje: number;
}
export interface ComisionRegla {
id: number;
name: string;
activo: boolean;
prioridad: number;
empleado_id: number | null;
empleado: string;
staff_type: '' | 'doctor' | 'cosmetologa';
alcance: AlcanceComision;
alcance_label: string;
categoria_code: string;
servicio_id: number | null;
servicio: string;
item_id: number | null;
item: string;
plan_id: number | null;
plan: string;
tipo: TipoComision;
tipo_label: string;
valor: number;
escalones: EscalonComision[];
base_override: '' | 'cobrado' | 'vendido';
fecha_desde: string | null;
fecha_hasta: string | null;
notas: string;
resumen: string;
}
export interface ComisionMeta {
id: number;
empleado_id: number;
empleado: string;
periodo: string;
monto_objetivo: number;
bono: number;
tipo_bono: TipoBonoComision;
activo: boolean;
notas: string;
}
export interface ComisionEmpleado {
id: number;
name: string;
staff_type: 'doctor' | 'cosmetologa';
job_title: string;
commission_pct: number;
reglas_count: number;
metas_count: number;
}
export interface ComisionDesgloseLinea {
venta_id: number;
folio: string;
fecha: string | null;
linea_id: number;
servicio: string;
categoria: string;
tipo_linea: string;
base: number;
regla_id: number | null;
regla: string;
tipo: TipoComision;
porcentaje: number | null;
comision: number;
referencia: string;
}
export interface ComisionPorRegla {
regla_id: number | null;
regla: string;
base: number;
comision: number;
lineas: number;
}
export interface ComisionMetaEval {
meta_id: number;
periodo: string;
monto_objetivo: number;
alcanzada: boolean;
bono: number;
tipo_bono: TipoBonoComision;
bono_config: number;
}
export interface ComisionAjuste {
id: number;
liquidacion_id: number | null;
empleado_id: number;
empleado: string;
periodo: string;
monto: number;
motivo: string;
usuario: string;
fecha: string | null;
}
export interface ComisionEmpleadoCalc {
empleado_id: number;
empleado: string;
staff_type: 'doctor' | 'cosmetologa';
job_title: string;
commission_pct: number;
citas_done: number;
lineas_count: number;
base: number;
comision: number;
bonos: number;
ajustes: number;
total: number;
pagable: boolean;
desglose: ComisionDesgloseLinea[];
por_regla: ComisionPorRegla[];
metas: ComisionMetaEval[];
ajustes_detalle: ComisionAjuste[];
}
export interface ComisionCalculo {
status: string;
start: string;
end: string;
config: ComisionesSettings;
empleados: ComisionEmpleadoCalc[];
totales: { base: number; comision: number; bonos: number; ajustes: number; total: number };
medico_sin_employee?: boolean;
message?: string;
}
export interface ComisionReporteReglas extends ComisionCalculo {
por_servicio: { servicio: string; base: number; comision: number; lineas: number }[];
por_categoria: { categoria: string; base: number; comision: number; lineas: number }[];
}
export interface ComisionLiquidacion {
id: number;
folio: string;
empleado_id: number;
empleado: string;
staff_type: 'doctor' | 'cosmetologa';
periodo_inicio: string | null;
periodo_fin: string | null;
periodo: string;
total_calculado: number;
total_ajustes: number;
total: number;
estado: EstadoLiquidacion;
estado_label: string;
fecha_pago: string | null;
metodo_pago: string;
referencia: string;
notas: string;
creado_por_login: string;
pagada_por_login: string;
ajustes: ComisionAjuste[];
detalle?: ComisionEmpleadoCalc;
}
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
export type MonedaCompra = 'MXN' | 'USD';
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
@@ -1875,6 +2081,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
};
export interface FrontendUser {
@@ -2659,6 +2866,113 @@ export const odooApi = {
return data;
},
// ---- Comisiones por reglas y liquidación ----
// Config (reglas y metas) disponible aunque el motor esté apagado; las rutas
// operativas (cálculo, liquidaciones) responden 403 con `comisiones.activo=false`.
async getComisionesReglas(params?: { empleado_id?: number; alcance?: string; activo?: boolean; q?: string }): Promise<{ status: string; reglas: ComisionRegla[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas`, {
params: {
...(params?.empleado_id ? { empleado_id: params.empleado_id } : {}),
...(params?.alcance ? { alcance: params.alcance } : {}),
...(params?.activo ? { activo: 1 } : {}),
...(params?.q ? { q: params.q } : {}),
},
});
return data;
},
async createComisionRegla(payload: Partial<ComisionRegla>): Promise<{ status: string; regla: ComisionRegla; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas`, payload);
return data;
},
async updateComisionRegla(id: number, payload: Partial<ComisionRegla>): Promise<{ status: string; regla: ComisionRegla; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas/${id}`, payload);
return data;
},
async deleteComisionRegla(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas/${id}`);
return data;
},
async getComisionesMetas(params?: { empleado_id?: number; activo?: boolean }): Promise<{ status: string; metas: ComisionMeta[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas`, {
params: {
...(params?.empleado_id ? { empleado_id: params.empleado_id } : {}),
...(params?.activo ? { activo: 1 } : {}),
},
});
return data;
},
async createComisionMeta(payload: Partial<ComisionMeta>): Promise<{ status: string; meta: ComisionMeta; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas`, payload);
return data;
},
async updateComisionMeta(id: number, payload: Partial<ComisionMeta>): Promise<{ status: string; meta: ComisionMeta; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas/${id}`, payload);
return data;
},
async deleteComisionMeta(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas/${id}`);
return data;
},
async getComisionEmpleados(): Promise<{ status: string; empleados: ComisionEmpleado[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/empleados`);
return data;
},
async getComisionCalculo(start: string, end: string, empleadoId?: number): Promise<ComisionCalculo> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/calculo`, {
params: { start, end, ...(empleadoId ? { empleado_id: empleadoId } : {}) },
});
return data;
},
async getComisionesReglasReport(start: string, end: string): Promise<ComisionReporteReglas> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/comisiones_reglas`, { params: { start, end } });
return data;
},
async getComisionesLiquidaciones(params?: { empleado_id?: number; periodo?: string; estado?: EstadoLiquidacion }): Promise<{ status: string; liquidaciones: ComisionLiquidacion[]; total: number; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones`, { params });
return data;
},
async getComisionLiquidacion(id: number): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}`);
return data;
},
async getMisLiquidaciones(): Promise<{ status: string; empleado_id?: number; empleado?: string; liquidaciones: ComisionLiquidacion[]; medico_sin_employee?: boolean; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/mis-liquidaciones`);
return data;
},
async generarComisionesLiquidaciones(payload: { start: string; end: string; empleado_id?: number; empleado_ids?: number[]; notas?: string }): Promise<{ status: string; liquidaciones: ComisionLiquidacion[]; omitidas: { empleado_id: number; empleado: string; folio: string; motivo: string }[]; total_creadas: number; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones`, payload);
return data;
},
async ajustarComisionLiquidacion(id: number, payload: { monto: number; motivo?: string }): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/ajustar`, payload);
return data;
},
async pagarComisionLiquidacion(id: number, payload: { fecha?: string; metodo_pago?: string; referencia?: string }): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/pagar`, payload);
return data;
},
async cancelarComisionLiquidacion(id: number, motivo?: string): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/cancelar`, { motivo });
return data;
},
// ---- Membresías y suscripciones ----
// Con `membresias.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).