diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10b7772..f02c100 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; }; +// 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 ; + return ; +}; + const HomeRoute: FC = () => { const { user, canSee } = useAuth(); if (user?.role === 'medico') return ; @@ -193,6 +203,8 @@ const App: FC = () => { } /> {/* Compras solo cuando la clínica la activa (compras.activo). */} } /> + {/* Liquidaciones de comisiones solo con comisiones.activo. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index 435e58e..f027645 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -146,11 +146,11 @@ export const ClasicoLayout: FC = ({ 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(() => { let tree = menuTree; if (comprasCfg.activo) { @@ -161,8 +161,11 @@ export const ClasicoLayout: FC = ({ 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 diff --git a/frontend/src/components/ComisionesPanel.tsx b/frontend/src/components/ComisionesPanel.tsx new file mode 100644 index 0000000..c6ff38e --- /dev/null +++ b/frontend/src/components/ComisionesPanel.tsx @@ -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 }) => ( + +); + +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 = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.comisiones || COMISIONES_DEFAULT); + const [saving, setSaving] = useState(false); + + const [empleados, setEmpleados] = useState([]); + const [reglas, setReglas] = useState([]); + const [metas, setMetas] = useState([]); + const [servicios, setServicios] = useState([]); + const [categorias, setCategorias] = useState([]); + const [items, setItems] = useState([]); + const [planes, setPlanes] = useState([]); + const [cargando, setCargando] = useState(false); + + const [reglaOpen, setReglaOpen] = useState(false); + const [reglaForm, setReglaForm] = useState(emptyRegla); + const [metaOpen, setMetaOpen] = useState(false); + const [metaForm, setMetaForm] = useState(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: 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) => { 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 = { + 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); + else await odooApi.createComisionRegla(payload as Partial); + 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 = { + 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 ( +
+ {/* Ajustes del bloque */} +
+

Comisiones por reglas

+

+ 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. +

+
+ set('activo', v)} + ayuda="Enciende el reporte nuevo, las liquidaciones y el acceso a las pantallas. Apagado, nada cambia." /> +
+
+ set('dia_corte', parseInt(e.target.value, 10) || 1)} /> + set('redondeo', parseInt(e.target.value, 10) || 0)} /> + set('pago_minimo', parseFloat(e.target.value) || 0)} /> +
+
+ set('incluir_impuestos', v)} /> + set('neto_de_devoluciones', v)} /> + set('permitir_ajustes_manuales', v)} /> + set('comisionar_membresias', v)} /> + set('comisionar_paquetes', v)} /> + set('comisionar_productos', v)} /> +
+
+ +
+
+ + {/* Empleados: respaldo commission_pct */} +
+

% de respaldo por empleado

+

+ Sin ninguna regla aplicable, se usa este porcentaje (el comportamiento de siempre). +

+
+ + + + + + {empleados.map((e) => ( + + + + + + + + ))} + {empleados.length === 0 && } + +
EmpleadoTipo% respaldoReglasMetas
{e.name}{e.staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}{e.commission_pct}%{e.reglas_count}{e.metas_count}
Sin empleados.
+
+
+ + {/* Reglas */} +
+
+

Reglas

+
+ + + + +
+
+ {cargando ? ( +

Cargando…

+ ) : reglas.length === 0 ? ( +
+ Sin reglas. Sin reglas, cada empleado comisiona con su % de respaldo. +
+ ) : ( +
+ {reglasPorEmpleado.map((g) => ( +
+

{g.label}

+
+ + + + + + {g.reglas.map((r) => ( + + + + + + + + + ))} + +
ReglaAlcanceTipoPrioridadVigenciaAcciones
{r.name}{!r.activo && (inactiva)}{r.resumen}{r.tipo_label}{r.tipo === 'porcentaje' ? ` ${r.valor}%` : r.tipo === 'monto_fijo' ? ` $${r.valor}` : ''}{r.prioridad}{r.base_override ? ` · base ${r.base_override}` : ''}{(r.fecha_desde || '—')} a {(r.fecha_hasta || '—')} +
+ + {r.activo && } +
+
+
+
+ ))} +
+ )} +
+ + {/* Metas */} +
+
+

Metas y bonos

+ +
+ {metas.length === 0 ? ( +
Sin metas. Una meta paga un bono al alcanzar su objetivo de venta.
+ ) : ( +
+ + + + + + {metas.map((m) => ( + + + + + + + + ))} + +
EmpleadoPeriodoObjetivoBonoAcciones
{nombreEmpleado(m.empleado_id)}{m.periodo}{m.monto_objetivo.toLocaleString('es-MX')}{m.tipo_bono === 'fijo' ? `$${m.bono}` : `${m.bono}%`} +
+ + {m.activo && } +
+
+
+ )} +
+ + {/* Editor de regla */} + setReglaOpen(false)} + title={reglaForm.id ? 'Editar regla de comisión' : 'Nueva regla de comisión'} + footer={
} + > +
+ setReglaForm((f) => ({ ...f, name: e.target.value }))} /> +
+ setReglaForm((f) => ({ ...f, staff_type: e.target.value }))} options={[{ value: '', label: 'Cualquiera' }, { value: 'doctor', label: 'Doctor' }, { value: 'cosmetologa', label: 'Cosmetóloga' }]} /> +
+
+ setReglaForm((f) => ({ ...f, tipo: e.target.value }))} options={TIPOS} /> +
+ {reglaForm.alcance === 'categoria' && ( + 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' && ( + 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' ? ( +