import type { FC, MutableRefObject, ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { LayoutDashboard, Calendar, Users, Stethoscope, FlaskConical, Wallet, ShoppingCart, Store, Receipt, BarChart3, Settings, Menu, X, ChevronRight, LogOut, MessageSquare, Target, Boxes, ClipboardList, ClipboardPlus, FolderClock, Star, Pencil, ChevronUp, ChevronDown, DollarSign, CircleDollarSign, TicketCheck, Repeat, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import GlobalSearch from './GlobalSearch'; import ThemePicker from './ThemePicker'; import NotificationBell from './NotificationBell'; import { LanguageToggle } from './LanguageSelect'; import { useAuth, ROLE_LABELS } from '../lib/auth'; import { useI18n, useClasicoLabel } from '../lib/i18n'; import { useClinicSettings } from '../lib/clinic-settings'; import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi } from '../services/odoo'; interface LayoutProps { children: ReactNode; title: string; subtitle?: string; } // Árbol de menús con las etiquetas y submenús del sistema legacy interface MenuChild { label: string; href: string; } interface MenuEntry { label: string; /** Etiqueta canónica (español) para estado/keys; `label` es la visible. */ id?: string; icon: FC<{ size?: number | string; className?: string }>; href?: string; children?: MenuChild[]; } const menuTree: MenuEntry[] = [ { label: 'Inicio', icon: LayoutDashboard, href: '/' }, { label: 'Agenda', icon: Calendar, href: '/agenda' }, { label: 'Visitas', icon: ClipboardList, href: '/visitas' }, { label: 'Consultas Médicas', icon: ClipboardPlus, href: '/consultas' }, { 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: [ { label: 'Clínica', href: '/configuracion?tab=clinica' }, { label: 'Usuarios', href: '/configuracion?tab=usuarios' }, { label: 'Recetas', href: '/configuracion?tab=recetas' }, { label: 'Diagnósticos', href: '/configuracion?tab=catalogos&sub=diagnosticos' }, { label: 'Procedimientos médicos', href: '/configuracion?tab=catalogos&sub=procedimientos' }, ], }, { label: 'Reportes', icon: BarChart3, children: [ { label: 'Mov. diario', href: '/reportes?tab=diario' }, { label: 'Cortes de caja', href: '/reportes?tab=cortes' }, { label: 'Ingresos', href: '/reportes?tab=ingresos' }, { label: 'Inventario', href: '/reportes?tab=inventario' }, { label: 'Comisiones', href: '/reportes?tab=comisiones' }, { label: 'Exportar correos', href: '/reportes?tab=exportar' }, ], }, { label: 'Administrativo', icon: DollarSign, children: [ { label: 'Adeudos clientes', href: '/reportes?tab=adeudos' }, { label: 'Pagos | Servicios', href: '/reportes?tab=pagos-servicios' }, { label: 'Pagos | Clientes', href: '/reportes?tab=pagos-clientes' }, { label: 'Devoluciones', href: '/reportes?tab=devoluciones' }, { label: 'Vendedores', href: '/reportes?tab=vendedores' }, { label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' }, { label: 'Concentrado', href: '/reportes?tab=concentrado' }, { label: 'Top ventas', href: '/reportes?tab=top-clientes' }, { label: 'Horas agenda', href: '/reportes?tab=horas-agenda' }, { label: 'Paquetes', href: '/reportes?tab=paquetes' }, { label: 'Cortes de caja', href: '/cortes' }, ], }, { label: 'Monedero', icon: Wallet, children: [ { label: 'Monederos', href: '/monedero' }, { label: 'Reporte puntos', href: '/monedero?tab=reporte' }, ], }, { label: 'Comunicación', icon: MessageSquare, children: [ { label: 'Chat', href: '/chat' }, { label: 'Cumpleañeros', href: '/cumpleanos' }, { label: 'Mensajes', href: '/wacrm/messages' }, ], }, { label: 'CRM', icon: Target, href: '/wacrm/leads' }, { label: 'Comprobantes del día', icon: Receipt, href: '/pagos' }, { label: 'Consumibles', icon: Boxes, children: [ { label: 'Inventario actual', href: '/inventario' }, { label: 'Compras', href: '/inventario?tab=compras' }, { label: 'Bajas', href: '/inventario?tab=bajas' }, ], }, { label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' }, { label: 'Tickets', icon: TicketCheck, href: '/tickets' }, { label: 'Punto de Venta', icon: Store, href: '/pos' }, { label: 'Clientes', icon: Users, href: '/pacientes' }, { label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' }, ]; export const ClasicoLayout: FC = ({ children, title: _title, subtitle: _subtitle }) => { const location = useLocation(); const navigate = useNavigate(); const { user, canSee, hasRole, logout, brand } = useAuth(); const { t } = useI18n(); const cl = useClasicoLabel(); const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings(); const [sidebarOpen, setSidebarOpen] = useState(false); const chatUnread = useChatUnread(); // Membresías, Compras y Liquidaciones solo cuando la clínica enciende su bandera. const menuTreeT = useMemo(() => { let tree = menuTree; if (comprasCfg.activo) { tree = tree.map((e) => (e.label === 'Consumibles' && e.children ? { ...e, children: [...e.children, { label: 'Órdenes de compra', href: '/compras' }] } : e)); } 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.map((e) => ({ ...e, id: e.label, label: cl(e.label), children: e.children?.map((c) => ({ ...c, label: cl(c.label) })), })); }, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, cl]); 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( () => menuLeavesT.filter((it) => canSee(it.href)), [canSee, menuLeavesT] ); // Accesos rápidos personalizables (localStorage por usuario) const quick = useQuickLinks(); const dockItems = visibleLeaves; const quickItems = useMemo( () => quick.links .filter((href) => canSee(href)) .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') .split(' ') .filter(Boolean) .slice(0, 2) .map((w) => w[0]?.toUpperCase()) .join(''); const handleLogout = async () => { await logout(); navigate('/login', { replace: true }); }; const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null); const [goalModalOpen, setGoalModalOpen] = useState(false); const [goalInput, setGoalInput] = useState(''); const [goalSaving, setGoalSaving] = useState(false); useEffect(() => { let alive = true; const fetchGoal = () => { odooApi.getSalesGoal() .then((res) => { if (alive && res.status === 'success') { setGoal({ goal: res.goal, current: res.current, pct: res.pct }); } }) .catch(() => {}); }; fetchGoal(); const id = setInterval(fetchGoal, 60000); return () => { alive = false; clearInterval(id); }; }, []); const openGoalModal = () => { setGoalInput(goal?.goal ? String(goal.goal) : ''); setGoalModalOpen(true); }; const saveGoal = async () => { const v = parseFloat(goalInput); if (Number.isNaN(v) || v < 0) return; try { setGoalSaving(true); await odooApi.setSalesGoal(v); setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g)); setGoalModalOpen(false); } catch { // ignore } finally { setGoalSaving(false); } }; const fmtMoney = (n: number) => n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`; const pctClamped = Math.min(goal?.pct ?? 0, 100); useEffect(() => { if (!sidebarOpen) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; }, [sidebarOpen]); const isActive = (href: string) => { const [path, query] = href.split('?'); if (path === '/') { if (location.pathname !== '/') return false; } else if (location.pathname !== path && !location.pathname.startsWith(`${path}/`)) { return false; } if (query) { return query.split('&').every((p) => location.search.includes(p)); } return true; }; // Grupos de submenú abiertos (auto-abre el de la ruta activa) const [openGroups, setOpenGroups] = useState(() => { const current = `${location.pathname}${location.search}`; 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.id as string); }); const toggleGroup = (label: string) => setOpenGroups((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label])); // El menú se renderiza como función (no como componente) a propósito: como // componente definido dentro del layout, cada render crea un tipo nuevo y // React remonta el subárbol entero, perdiendo la posición de scroll (los // últimos items, p. ej. Leads WACRM, quedaban inalcanzables). const navDesktopRef = useRef(null); const navMobileRef = useRef(null); // 1) El grupo que contiene la ruta activa siempre queda abierto useEffect(() => { const current = `${location.pathname}${location.search}`; 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]))); })) .map((e) => e.id as string); if (!abiertos.length) return; setOpenGroups((prev) => { const faltan = abiertos.filter((l) => !prev.includes(l)); return faltan.length ? [...prev, ...faltan] : prev; }); }, [location.pathname, location.search]); // 2) Con el grupo ya abierto (re-render), deja visible el item activo. // Va en un efecto aparte a propósito: en el mismo tick el item todavía no // está en el DOM y no habría nada que desplazar. useEffect(() => { for (const nav of [navDesktopRef.current, navMobileRef.current]) { if (!nav || nav.scrollHeight <= nav.clientHeight) continue; const el = nav.querySelector('[data-active="1"]'); if (el) el.scrollIntoView({ block: 'nearest' }); } }, [location.pathname, location.search, openGroups, visibleLeaves]); const renderSidebar = ({ navRef = null }: { navRef?: MutableRefObject | null } = {}) => ( <> {/* Sucursal, como en el sistema legacy */}

Rosarito

{initials || 'US'}

{user?.name || 'Usuario'}

{user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}

{t('user.language')}
); return (
{/* Header fijo */}
{brand.nombre}
{brand.nombre}
{goal && goal.goal > 0 && ( )} {goal && goal.goal <= 0 && hasRole('admin') && ( )}
{/* Sidebar desktop */} {/* Drawer móvil */} {sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> )} {/* Contenido */}
{children}
setGoalModalOpen(false)} title="Meta de ventas del mes" maxWidth="sm" footer={ <> } >

Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.

setGoalInput(e.target.value)} placeholder="ej. 500000" />
); }; export default ClasicoLayout;