diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67400af..675dfc8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import Pos from './pages/Pos'; import Visitas from './pages/Visitas'; import Consultas from './pages/Consultas'; import Servicios from './pages/Servicios'; +import Paquetes from './pages/Paquetes'; import Medicos from './pages/Medicos'; import Monedero from './pages/Monedero'; import Pagos from './pages/Pagos'; @@ -58,7 +59,7 @@ const LoginRoute: FC = () => { // lugar de redirigir a "/" en bucle (pantalla en blanco). const LANDING_CANDIDATOS = [ '/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas', - '/medicos', '/servicios', '/ventas', '/pagos', '/monedero', + '/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero', '/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages', '/wacrm/leads', '/configuracion', ]; @@ -99,6 +100,7 @@ const App: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AuditoriaPanel.tsx b/frontend/src/components/AuditoriaPanel.tsx index c8e6caa..bf766a4 100644 --- a/frontend/src/components/AuditoriaPanel.tsx +++ b/frontend/src/components/AuditoriaPanel.tsx @@ -13,6 +13,11 @@ const MODULOS = [ { value: 'roles', label: 'Roles y permisos' }, { value: 'usuarios', label: 'Usuarios' }, { value: 'catalogos', label: 'Catálogos' }, + { value: 'pacientes', label: 'Expedientes de pacientes' }, + { value: 'agenda', label: 'Agenda' }, + { value: 'paquetes', label: 'Paquetes' }, + { value: 'monedero', label: 'Monedero' }, + { value: 'clinica', label: 'Clínica' }, ]; const PAGE_SIZE = 25; diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index a675f78..5d2e150 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -70,6 +70,7 @@ const menuTree: MenuEntry[] = [ { label: 'Expedientes', icon: FolderClock, href: '/expedientes' }, { label: 'Médicos', icon: Stethoscope, href: '/medicos' }, { label: 'Cosmetología', icon: FlaskConical, href: '/servicios' }, + { label: 'Paquetes', icon: Boxes, href: '/paquetes' }, { label: 'Configuración', icon: Settings, children: [ diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index 7b37a20..440bed0 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -34,6 +34,7 @@ import { MessageCircle, CircleDollarSign, TicketCheck, + Layers, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -71,6 +72,7 @@ const menuSections = [ { label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' }, { label: 'Médicos', icon: Stethoscope, href: '/medicos' }, { label: 'Servicios', icon: Briefcase, href: '/servicios' }, + { label: 'Paquetes', icon: Layers, href: '/paquetes' }, ], }, { diff --git a/frontend/src/components/SkeenLayout.tsx b/frontend/src/components/SkeenLayout.tsx index 2af130a..ea65541 100644 --- a/frontend/src/components/SkeenLayout.tsx +++ b/frontend/src/components/SkeenLayout.tsx @@ -35,6 +35,7 @@ import { PanelLeftClose, PanelLeftOpen, Ticket, + Layers, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -71,6 +72,7 @@ const menuGroups = [ { label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' }, { label: 'Médicos', icon: Stethoscope, href: '/medicos' }, { label: 'Servicios', icon: Briefcase, href: '/servicios' }, + { label: 'Paquetes', icon: Layers, href: '/paquetes' }, ], }, { diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index b3f59ab..4401cd9 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -33,6 +33,7 @@ const MENU_ROLES: Record = { '/consultas': ['admin', 'recepcion', 'medico', 'gerencia'], '/medicos': ['admin', 'recepcion', 'gerencia'], '/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], + '/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'], '/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario '/ventas': ['admin', 'recepcion', 'gerencia'], '/tickets': ['admin', 'gerencia'], diff --git a/frontend/src/pages/Paquetes.tsx b/frontend/src/pages/Paquetes.tsx new file mode 100644 index 0000000..fa69e7c --- /dev/null +++ b/frontend/src/pages/Paquetes.tsx @@ -0,0 +1,830 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Layers, Plus, Pencil, Search, XCircle, SlidersHorizontal, RefreshCw, + CheckCircle2, Clock, AlertTriangle, ChevronRight, +} from 'lucide-react'; +import Layout from '../components/Layout'; +import { + Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton, + MobileCard, toast, +} from '../components/ui'; +import { odooApi } from '../services/odoo'; +import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo'; +import { useAuth } from '../lib/auth'; + +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); + +const STATE_META: Record = { + active: { label: 'Vigente', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200' }, + finished: { label: 'Completado', cls: 'bg-theme-bg text-theme-muted border-theme-border' }, + expired: { label: 'Vencido', cls: 'bg-amber-100 text-amber-700 border-amber-200' }, + cancelled: { label: 'Anulado', cls: 'bg-rose-100 text-rose-700 border-rose-200' }, +}; + +const FILTROS_SALDO = [ + { key: '', label: 'Todos' }, + { key: 'active', label: 'Vigentes', icon: CheckCircle2 }, + { key: 'expired', label: 'Por vencer / vencidos', icon: AlertTriangle }, + { key: 'finished', label: 'Completados', icon: Clock }, + { key: 'cancelled', label: 'Anulados', icon: XCircle }, +]; + +interface FormPaquete { + id?: number; + name: string; + code: string; + service_id: string; + sessions: string; + price: string; + validity_days: string; + duration_min: string; + notes: string; + genera_monedero: boolean; +} + +const emptyForm: FormPaquete = { + name: '', code: '', service_id: '', sessions: '3', price: '', + validity_days: '90', duration_min: '0', notes: '', genera_monedero: true, +}; + +const Paquetes: FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo'; + const setTab = (key: string) => + setSearchParams(key === 'catalogo' ? {} : { tab: key }, { replace: true }); + const { hasRole } = useAuth(); + const esAdmin = hasRole('admin'); + + // ---- Catálogo ---- + const [paquetes, setPaquetes] = useState([]); + const [servicios, setServicios] = useState([]); + const [cargando, setCargando] = useState(true); + const [busqueda, setBusqueda] = useState(''); + const [mostrarBaja, setMostrarBaja] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [form, setForm] = useState(emptyForm); + const [guardando, setGuardando] = useState(false); + + // ---- Saldos ---- + const [compras, setCompras] = useState([]); + const [totales, setTotales] = useState>({}); + const [cargandoSaldos, setCargandoSaldos] = useState(false); + const [filtroEstado, setFiltroEstado] = useState(''); + const [busquedaSaldo, setBusquedaSaldo] = useState(''); + const [detalle, setDetalle] = useState<{ compra: PaqueteCompra; consumos: PaqueteConsumo[] } | null>(null); + const [cargandoDetalle, setCargandoDetalle] = useState(false); + const [ajuste, setAjuste] = useState<{ compra: PaqueteCompra; total: string; usadas: string } | null>(null); + + const loadCatalogo = useCallback(async () => { + try { + setCargando(true); + const res = await odooApi.getPaqueteCatalogo({ includeInactive: mostrarBaja }); + if (res.status === 'success') setPaquetes(res.paquetes); + } catch (err) { + console.error(err); + toast.error('Error al cargar los paquetes'); + } finally { + setCargando(false); + } + }, [mostrarBaja]); + + const loadSaldos = useCallback(async () => { + try { + setCargandoSaldos(true); + const res = await odooApi.getPaqueteCompras({ + state: filtroEstado || undefined, + search: busquedaSaldo.trim() || undefined, + includeAll: filtroEstado === 'cancelled' || mostrarBaja, + }); + if (res.status === 'success') { + setCompras(res.compras); + setTotales(res.totales || {}); + } + } catch (err) { + console.error(err); + toast.error('Error al cargar los saldos'); + } finally { + setCargandoSaldos(false); + } + }, [filtroEstado, busquedaSaldo, mostrarBaja]); + + useEffect(() => { + odooApi.getServices({ page_size: 200 }) + .then((res) => { if (res.status === 'success') setServicios(res.services); }) + .catch(() => {}); + }, []); + + useEffect(() => { if (tab === 'catalogo') loadCatalogo(); }, [tab, loadCatalogo]); + useEffect(() => { + if (tab !== 'saldos') return; + const t = setTimeout(loadSaldos, 300); + return () => clearTimeout(t); + }, [tab, loadSaldos]); + + const filtrados = useMemo(() => { + const q = busqueda.trim().toLowerCase(); + if (!q) return paquetes; + return paquetes.filter((p) => + p.name.toLowerCase().includes(q) || (p.service || '').toLowerCase().includes(q) + || (p.code || '').toLowerCase().includes(q)); + }, [paquetes, busqueda]); + + const sinBaja = useMemo(() => filtrados.filter((p) => p.active), [filtrados]); + const dadosDeBaja = useMemo(() => filtrados.filter((p) => !p.active), [filtrados]); + const visibles = mostrarBaja ? filtrados : sinBaja; + + const abrirNuevo = () => { + setForm(emptyForm); + setFormOpen(true); + }; + + const abrirEditar = (p: Paquete) => { + setForm({ + id: p.id, name: p.name, code: p.code, service_id: String(p.service_id ?? ''), + sessions: String(p.sessions), price: String(p.price), + validity_days: String(p.validity_days || 0), duration_min: String(p.duration_min || 0), + notes: p.notes || '', genera_monedero: p.genera_monedero, + }); + setFormOpen(true); + }; + + const guardar = async () => { + if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; } + if (!form.service_id) { toast.error('Selecciona el servicio'); return; } + const sesiones = parseInt(form.sessions, 10); + const precio = parseFloat(form.price); + if (!sesiones || sesiones < 1) { toast.error('Debe tener al menos 1 sesión'); return; } + if (Number.isNaN(precio) || precio <= 0) { toast.error('El precio debe ser mayor a 0'); return; } + const payload = { + name: form.name.trim(), + code: form.code.trim(), + service_id: parseInt(form.service_id, 10), + sessions: sesiones, + price: precio, + validity_days: parseInt(form.validity_days, 10) || 0, + duration_min: parseInt(form.duration_min, 10) || 0, + notes: form.notes.trim(), + genera_monedero: form.genera_monedero, + }; + try { + setGuardando(true); + const res = form.id + ? await odooApi.updatePaquete(form.id, payload) + : await odooApi.createPaquete(payload); + if (res.status !== 'success') { + toast.error(res.message || 'No se pudo guardar'); + return; + } + toast.success(form.id ? 'Paquete actualizado' : 'Paquete creado'); + setFormOpen(false); + loadCatalogo(); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al guardar el paquete'); + } finally { + setGuardando(false); + } + }; + + const cambiarActivo = async (p: Paquete) => { + const confirmar = p.active + ? `¿Dar de baja "${p.name}? Dejará de ofrecerse en el punto de venta. Los paquetes ya vendidos no se tocan.` + : `¿Reactivar "${p.name}"?`; + if (!window.confirm(confirmar)) return; + try { + const res = await odooApi.updatePaquete(p.id, { active: !p.active }); + if (res.status !== 'success') { toast.error(res.message || 'No se pudo cambiar el estado'); return; } + toast.success(p.active ? 'Paquete dado de baja' : 'Paquete reactivado'); + loadCatalogo(); + } catch { + toast.error('Error al cambiar el estado'); + } + }; + + const abrirDetalle = async (c: PaqueteCompra) => { + setCargandoDetalle(true); + setDetalle(null); + try { + const res = await odooApi.getPaqueteConsumos(c.id); + if (res.status === 'success') setDetalle({ compra: res.compra, consumos: res.consumos }); + else toast.error(res.message || 'No se pudo abrir el paquete'); + } catch { + toast.error('Error al abrir el paquete'); + } finally { + setCargandoDetalle(false); + } + }; + + const guardarAjuste = async () => { + if (!ajuste) return; + const total = parseInt(ajuste.total, 10); + const usadas = parseInt(ajuste.usadas, 10); + if (Number.isNaN(total) || total < 0 || Number.isNaN(usadas) || usadas < 0) { + toast.error('Las sesiones deben ser números'); + return; + } + if (usadas > total) { toast.error('Las usadas no pueden ser mayores al total'); return; } + try { + const res = await odooApi.ajustarPaqueteCompra(ajuste.compra.id, { + sessions_total: total, sessions_used: usadas, + }); + if (res.status !== 'success') { toast.error(res.message || 'No se pudo ajustar'); return; } + toast.success('Saldo ajustado'); + setAjuste(null); + loadSaldos(); + } catch { + toast.error('Error al ajustar el saldo'); + } + }; + + const anular = async (c: PaqueteCompra) => { + const motivo = window.prompt( + `¿Anular "${c.paquete}" de ${c.patient}? Se liberan las sesiones consumidas.\n\nMotivo (obligatorio):` + ); + if (!motivo || !motivo.trim()) { + if (motivo !== null) toast.error('Indica el motivo de la anulación'); + return; + } + try { + const res = await odooApi.anularPaqueteCompra(c.id, motivo.trim()); + if (res.status !== 'success') { toast.error(res.message || 'No se pudo anular'); return; } + toast.success('Paquete anulado'); + setDetalle(null); + loadSaldos(); + } catch { + toast.error('Error al anular el paquete'); + } + }; + + const progreso = (c: PaqueteCompra) => + c.sessions_total > 0 ? Math.round((c.sessions_used / c.sessions_total) * 100) : 0; + + const diasParaVencer = (c: PaqueteCompra) => { + if (!c.expiry_date) return null; + const diff = Math.ceil((new Date(c.expiry_date + 'T00:00:00').getTime() - Date.now()) / 86400000); + return diff; + }; + + const FilaSaldo: FC<{ c: PaqueteCompra }> = ({ c }) => { + const dias = diasParaVencer(c); + const meta = STATE_META[c.state] || STATE_META.active; + const cercaDeVencer = c.state === 'active' && dias !== null && dias <= 15; + return ( + abrirDetalle(c)}> + {c.date || '—'} + +

{c.patient}

+ {c.phone &&

{c.phone}

} + + +

{c.paquete}

+

{c.service}

+ + +
+
+
+
+ + {c.sessions_used}/{c.sessions_total} + +
+

+ {c.sessions_remaining} restantes +

+ + + {c.expiry_date ? ( + + {c.expiry_date} + {cercaDeVencer && ( + + {dias >= 0 ? `en ${dias} días` : 'vencido'} + + )} + + ) : ( + Sin límite + )} + + {fmtMoney(c.price)} + + + {meta.label} + + + + + + + ); + }; + + return ( + + + + {tab === 'catalogo' && esAdmin && ( + + )} + + + {/* Tabs */} +
+ {[{ key: 'catalogo', label: 'Catálogo' }, { key: 'saldos', label: 'Paquetes vendidos' }].map((t) => ( + + ))} +
+ + {tab === 'catalogo' ? ( + + +
+ + setBusqueda(e.target.value)} + placeholder="Buscar por nombre, servicio o código" + className="pl-9" + /> +
+ +
+ + {cargando ? ( +
+ ) : visibles.length === 0 ? ( + } + /> + ) : ( + <> + {/* Escritorio */} +
+ + + + + + + + + + + + + + + + {visibles.map((p) => { + const ahorro = p.session_price && p.price_per_session + ? Math.round((1 - p.price_per_session / p.session_price) * 100) : 0; + return ( + + + + + + + + + + + + + ); + })} + +
PaqueteServicioSesionesPrecioPor sesiónAhorroVigenciaVendidosEstado +
+

{p.name}

+ {p.code &&

{p.code}

} +
{p.service}{p.sessions}{fmtMoney(p.price)}{fmtMoney(p.price_per_session)} + {ahorro > 0 ? ( + −{ahorro}% + ) : ( + — + )} + + {p.validity_days > 0 ? `${p.validity_days} días` : 'Sin límite'} + {p.compras_count} + + + +
+
+ + {/* Móvil */} +
+ {visibles.map((p) => ( + abrirEditar(p)} disabled={!esAdmin}> + Editar + + } + /> + ))} +
+ + )} +
+
+ ) : ( + + +
+ {FILTROS_SALDO.map((f) => ( + + ))} +
+
+ + setBusquedaSaldo(e.target.value)} + placeholder="Buscar paciente o paquete" + className="pl-9" + /> +
+
+ + {tab === 'saldos' && ( +
+
+ Vigentes: + {totales.vigentes ?? 0} +
+
+ Sesiones por usar: + {totales.sesiones_restantes ?? 0} +
+
+ Por vencer: + {totales.vencidos ?? 0} +
+
+ Vendido: + {fmtMoney(totales.importe ?? 0)} +
+
+ )} + + + {cargandoSaldos ? ( +
+ ) : compras.length === 0 ? ( + } + /> + ) : ( + <> +
+ + + + + + + + + + + + + + {compras.map((c) => )} + +
CompraPacientePaqueteSesionesVencePagadoEstado +
+
+
+ {compras.map((c) => ( + abrirDetalle(c)}> + Ver detalle + + } + onClick={() => abrirDetalle(c)} + /> + ))} +
+ + )} +
+
+ )} + + {/* Alta / edición de paquete */} + setFormOpen(false)} + title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'} + > +
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="Ej. Lumecca paquete 3 sesiones" + /> +
+
+ + setForm({ ...form, sessions: e.target.value })} + /> +
+
+ + setForm({ ...form, price: e.target.value })} + /> +
+
+ + setForm({ ...form, validity_days: e.target.value })} + placeholder="0 = sin límite" + /> +
+
+ + setForm({ ...form, duration_min: e.target.value })} + /> +
+
+ {(parseInt(form.sessions, 10) > 0 && parseFloat(form.price) > 0) && ( +

+ Sale a {fmtMoney(parseFloat(form.price) / parseInt(form.sessions, 10))} por sesión. +

+ )} +
+
+ + setForm({ ...form, code: e.target.value })} /> +
+ +
+
+ +