diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f6b27e..92db06b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { ServiceCategoriesProvider } from './lib/service-categories'; import { QuickLinksProvider } from './lib/quicklinks'; import { PatientDrawerProvider } from './components/PatientDrawer'; import ChatNotifications from './lib/chat-notifications'; +import { useClinicSettings } from './lib/clinic-settings'; import Dashboard from './pages/Dashboard'; import Agenda from './pages/Agenda'; import Pacientes from './pages/Pacientes'; @@ -18,6 +19,7 @@ import Visitas from './pages/Visitas'; import Consultas from './pages/Consultas'; import Servicios from './pages/Servicios'; import Paquetes from './pages/Paquetes'; +import Membresias from './pages/Membresias'; import Medicos from './pages/Medicos'; import Monedero from './pages/Monedero'; import Pagos from './pages/Pagos'; @@ -109,6 +111,15 @@ const LANDING_CANDIDATOS = [ '/wacrm/leads', '/configuracion', ]; +// Membresías vive detrás de `membresias.activo`. Si la clínica la apagó, la +// ruta rebota a "/" aunque el rol tenga permiso de menú (así apagarlo vuelve +// todo al estado anterior, incluso entrando por URL). +const MembresiasRoute: FC = () => { + const { membresias } = useClinicSettings(); + if (!membresias.activo) return ; + return ; +}; + const HomeRoute: FC = () => { const { user, canSee } = useAuth(); if (user?.role === 'medico') return ; @@ -155,6 +166,8 @@ const App: FC = () => { } /> } /> } /> + {/* Membresías solo cuando la clínica la activa (membresias.activo). */} + } /> } /> } /> } /> diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index 98460d4..72743d9 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -30,6 +30,7 @@ import { DollarSign, CircleDollarSign, TicketCheck, + Repeat, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -37,6 +38,7 @@ import GlobalSearch from './GlobalSearch'; import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; import { useAuth, ROLE_LABELS } from '../lib/auth'; +import { useClinicSettings } from '../lib/clinic-settings'; import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi } from '../services/odoo'; @@ -140,21 +142,32 @@ const menuTree: MenuEntry[] = [ { label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' }, ]; -// Hojas planas (para accesos rápidos y dock) -const menuLeaves = menuTree.flatMap((e) => - e.children ? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href })) : [{ label: e.label, icon: e.icon, href: e.href as string }] -); - export const ClasicoLayout: FC = ({ children, title: _title, subtitle: _subtitle }) => { const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); + const { membresias: membresiasCfg } = useClinicSettings(); const [sidebarOpen, setSidebarOpen] = useState(false); const chatUnread = useChatUnread(); + // Membresías solo cuando la clínica enciende `membresias.activo`. + const menuTreeT = useMemo( + () => (membresiasCfg.activo + ? [...menuTree, { label: 'Membresías', icon: Repeat, href: '/membresias' }] + : menuTree), + [membresiasCfg.activo] + ); + const menuLeavesT = useMemo( + () => menuTreeT.flatMap((e) => + e.children + ? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href })) + : [{ label: e.label, icon: e.icon, href: e.href as string }]), + [menuTreeT] + ); + const visibleLeaves = useMemo( - () => menuLeaves.filter((it) => canSee(it.href)), - [canSee] + () => menuLeavesT.filter((it) => canSee(it.href)), + [canSee, menuLeavesT] ); // Accesos rápidos personalizables (localStorage por usuario) @@ -164,9 +177,9 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit () => quick.links .filter((href) => canSee(href)) - .map((href) => menuLeaves.find((it) => it.href === href) ?? null) - .filter((it): it is (typeof menuLeaves)[number] => it !== null), - [quick.links, canSee] + .map((href) => menuLeavesT.find((it) => it.href === href) ?? null) + .filter((it): it is (typeof menuLeavesT)[number] => it !== null), + [quick.links, canSee, menuLeavesT] ); const initials = (user?.name || 'US') @@ -251,7 +264,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit // Grupos de submenú abiertos (auto-abre el de la ruta activa) const [openGroups, setOpenGroups] = useState(() => { const current = `${location.pathname}${location.search}`; - return menuTree + return menuTreeT .filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])))) .map((e) => e.label); }); @@ -269,7 +282,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit // 1) El grupo que contiene la ruta activa siempre queda abierto useEffect(() => { const current = `${location.pathname}${location.search}`; - const abiertos = menuTree + const abiertos = menuTreeT .filter((e) => e.children?.some((c) => { const base = c.href.split('?')[0]; return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))); @@ -361,7 +374,7 @@ export const ClasicoLayout: FC = ({ children, title: _title, subtit )}
    - {menuTree.map((entry) => { + {menuTreeT.map((entry) => { const Icon = entry.icon; // Hojas visibles de este entry const leaves = entry.children diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index 04cdf54..03f0340 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -36,6 +36,7 @@ import { TicketCheck, Layers, Download, + Repeat, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -44,6 +45,7 @@ import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; import { useAuth } from '../lib/auth'; import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario'; +import { useClinicSettings } from '../lib/clinic-settings'; import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi } from '../services/odoo'; @@ -110,16 +112,26 @@ const MENU_SECTIONS = [ }, ]; +const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' }; + export const HomeNestLayout: FC = ({ children, title: _title, subtitle: _subtitle }) => { const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); const { term } = useGlosario(); + const { membresias: membresiasCfg } = useClinicSettings(); const topNavItems = useMemo( () => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })), [term] ); - const menuSections = useMemo(() => traducirMenu(MENU_SECTIONS, term), [term]); + const menuSections = useMemo(() => { + const base = membresiasCfg.activo + ? MENU_SECTIONS.map((s) => (s.label === 'Clínica' + ? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] } + : s)) + : MENU_SECTIONS; + return traducirMenu(base, term); + }, [term, membresiasCfg.activo]); const [menuOpen, setMenuOpen] = useState(false); const [expanded, setExpanded] = useState(['Clínica']); const chatUnread = useChatUnread(); diff --git a/frontend/src/components/MembresiasPaciente.tsx b/frontend/src/components/MembresiasPaciente.tsx new file mode 100644 index 0000000..edc026d --- /dev/null +++ b/frontend/src/components/MembresiasPaciente.tsx @@ -0,0 +1,223 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Repeat, Plus } from 'lucide-react'; +import { Button, Input, Select, Modal, Badge, toast } from './ui'; +import { odooApi, type Membresia, type MembresiaPlan } from '../services/odoo'; +import { useAuth } from '../lib/auth'; +import { useGlosario } from '../lib/glosario'; +import { useClinicSettings } from '../lib/clinic-settings'; + +// Bloque "Membresías" del expediente del paciente: membresías activas, saldo de +// sesiones por periodo y acciones (alta, usar sesión, cobrar, renovar). Solo se +// usa cuando la clínica tiene `membresias.activo=true`. + +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); + +const ESTADO_CLS: Record = { + activa: 'bg-emerald-100 text-emerald-700 border-emerald-200', + congelada: 'bg-sky-100 text-sky-700 border-sky-200', + vencida: 'bg-amber-100 text-amber-700 border-amber-200', + pausada: 'bg-theme-bg text-theme-muted border-theme-border', + cancelada: 'bg-rose-100 text-rose-700 border-rose-200', +}; + +interface Props { + partnerId: number; +} + +const MembresiasPaciente: FC = ({ partnerId }) => { + const { can } = useAuth(); + const { term } = useGlosario(); + const { membresias: cfg } = useClinicSettings(); + const [membresias, setMembresias] = useState([]); + const [planes, setPlanes] = useState([]); + const [cargando, setCargando] = useState(false); + const [nuevaOpen, setNuevaOpen] = useState(false); + const [nueva, setNueva] = useState({ plan_id: '', fecha_inicio: '', renovacion_automatica: false, descuento: '' }); + const [guardando, setGuardando] = useState(false); + const [consumo, setConsumo] = useState<{ m: Membresia; servicio_id: string } | null>(null); + + const cargar = useCallback(async () => { + if (!partnerId) return; + try { + setCargando(true); + const res = await odooApi.getMembresiasPorPaciente(partnerId); + if (res.status === 'success') setMembresias(res.membresias); + } catch { + /* silencioso: el bloque es informativo */ + } finally { + setCargando(false); + } + }, [partnerId]); + + useEffect(() => { cargar(); }, [cargar]); + + const abrirNueva = async () => { + if (planes.length === 0) { + try { + const res = await odooApi.getMembresiaPlanes(); + if (res.status === 'success') setPlanes(res.planes); + } catch { /* ignore */ } + } + setNueva({ plan_id: '', fecha_inicio: new Date().toISOString().slice(0, 10), renovacion_automatica: cfg.renovacion_automatica_default, descuento: '' }); + setNuevaOpen(true); + }; + + const crear = async () => { + if (!nueva.plan_id) { toast.error('Selecciona un plan'); return; } + try { + setGuardando(true); + const res = await odooApi.crearMembresia({ + partner_id: partnerId, + plan_id: parseInt(nueva.plan_id, 10), + fecha_inicio: nueva.fecha_inicio || undefined, + renovacion_automatica: nueva.renovacion_automatica, + ...(nueva.descuento !== '' ? { descuento_porcentaje: parseFloat(nueva.descuento) } : {}), + }); + if (res.status !== 'success') { toast.error(res.message || 'No se pudo crear'); return; } + toast.success('Membresía creada'); + setNuevaOpen(false); + cargar(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al crear la membresía'); + } finally { + setGuardando(false); + } + }; + + const usarSesion = async () => { + if (!consumo || !consumo.servicio_id) { toast.error('Selecciona el servicio'); return; } + try { + const res = await odooApi.consumirMembresia(consumo.m.id, { + sesiones: 1, servicio_id: parseInt(consumo.servicio_id, 10), + notas: 'Consumido desde el expediente', + }); + if (res.status !== 'success') { toast.error(res.message || 'No se pudo consumir'); return; } + toast.success('Sesión de membresía registrada'); + setConsumo(null); + cargar(); + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al consumir la sesión'); + } + }; + + if (!cfg.activo) return null; + + return ( +
    +
    +

    + Membresías +

    + {can('membresias.crear') && ( + + )} +
    + + {cargando ? ( +

    Cargando…

    + ) : membresias.length === 0 ? ( +

    Sin membresías activas.

    + ) : ( +
      + {membresias.map((m) => ( +
    • +
      +
      +

      {m.plan}

      +

      + {m.folio} · {m.periodo_actual_inicio} a {m.periodo_actual_fin} + {m.proximo_cobro ? ` · próximo cobro ${m.proximo_cobro}` : ''} +

      +
      + + {m.estado_label} + +
      +
      +

      + {m.ilimitadas + ? 'Sesiones ilimitadas' + : `${m.sesiones_usadas_periodo}/${m.sesiones_incluidas_periodo} sesiones · quedan ${m.sesiones_restantes}`} + {m.adeudo > 0 && · adeudo {fmtMoney(m.adeudo)}} +

      + {m.estado === 'activa' && can('membresias.cobrar') && !m.ilimitadas && m.sesiones_restantes > 0 && m.servicios_incluidos.length > 0 && ( + + )} +
      +
    • + ))} +
    + )} + + setNuevaOpen(false)} title={`Nueva membresía para el ${term('patient_singular').toLowerCase()}`}> +
    +
    + + setNueva({ ...nueva, fecha_inicio: e.target.value })} /> +
    +
    + + setNueva({ ...nueva, descuento: e.target.value })} placeholder="Por defecto del plan" /> +
    +
    + +
    + + +
    +
    + + + setConsumo(null)} title="Usar sesión de membresía"> + {consumo && ( +
    +

    + Se descontará 1 sesión del periodo. {consumo.m.ilimitadas ? 'El plan es ilimitado.' : `Quedan ${consumo.m.sesiones_restantes}.`} +

    +
    + + setAvisos(e.target.value)} placeholder="7, 1" /> +

    Días antes del cobro en que se avisa (separados por coma).

    +
    +
    + + set('dias_max_congelamiento_anio', parseInt(e.target.value, 10) || 0)} /> +
    +
    + + set('dias_gracia', parseInt(e.target.value, 10) || 0)} /> +
    +
    + + set('descuento_porcentaje_default', parseFloat(e.target.value) || 0)} /> +
    +
    +
    + set('permitir_congelamiento', v)} /> + set('renovacion_automatica_default', v)} /> + set('generar_cargo_al_renovar', v)} + ayuda="El cron crea el adeudo del nuevo periodo (no cobra)." /> + set('permitir_pausa_pago', v)} /> +
    +
    + +
    + + +
    +

    Planes

    +
    + + +
    +
    + + {cargandoPlanes ? ( +

    Cargando planes…

    + ) : planes.length === 0 ? ( +

    Todavía no hay planes de membresía.

    + ) : ( +
    + + + + + + + + + + + + + + {planes.map((p) => ( + + + + + + + + + + + ))} + +
    PlanPeriodicidadPrecioSesionesInscripciónMembresíasEstado +
    +

    {p.name}

    + {p.servicio &&

    Cobra con: {p.servicio}

    } +
    {p.periodicidad}{p.precio.toLocaleString('es-MX', { style: 'currency', currency: p.moneda, maximumFractionDigits: 2 })}{p.sesiones_incluidas === 0 ? 'Ilimitadas' : p.sesiones_incluidas}{p.cargo_inscripcion ? p.cargo_inscripcion.toLocaleString('es-MX', { style: 'currency', currency: p.moneda }) : '—'}{p.membresias_count} + + +
    + + +
    +
    +
    + )} + + setFormOpen(false)} + title={planForm.id ? 'Editar plan de membresía' : 'Nuevo plan de membresía'}> +
    +
    + + setPlanForm({ ...planForm, name: e.target.value })} + placeholder="Ej. Plan mensual 4 masajes" /> +
    +
    +
    + + setPlanForm({ ...planForm, moneda: e.target.value as 'MXN' | 'USD' })} /> +
    +
    + + setPlanForm({ ...planForm, precio: e.target.value })} /> +
    +
    + + setPlanForm({ ...planForm, sesiones_incluidas: e.target.value })} /> +
    +
    + + setPlanForm({ ...planForm, cargo_inscripcion: e.target.value })} /> +
    +
    + + setPlanForm({ ...planForm, descuento_porcentaje: e.target.value })} /> +
    +
    + + setPlanForm({ ...planForm, meses_minimos: e.target.value })} /> +
    +
    + + setPlanForm({ ...planForm, servicios_incluidos: toggleId(planForm.servicios_incluidos, s.id) })} + className="rounded border-theme-border-strong" /> + {s.name} + + ))} +
    +
    + +
    + +
    + {categorias.map((c) => ( + + ))} +
    +
    + +
    + setPlanForm({ ...planForm, permitir_congelamiento: v })} /> + setPlanForm({ ...planForm, activo: v })} /> +
    + +
    + +