import type { FC, ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { LayoutDashboard, Calendar, Users, Stethoscope, Briefcase, Wallet, CreditCard, ShoppingCart, Receipt, Store, BarChart3, Settings, Menu, X, ChevronDown, LogOut, MessageSquare, Target, Cake, Boxes, ClipboardList, ClipboardPlus, Banknote, Percent, FolderClock, Sparkles, Star, Pencil, ChevronUp, MessageCircle, CircleDollarSign, TicketCheck, Layers, Download, Repeat, Truck, Megaphone, Smile, } 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 } from '../lib/auth'; import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario'; import { useI18n, useNavLabel } from '../lib/i18n'; import { useClinicSettings } from '../lib/clinic-settings'; import { useChatUnread } from '../lib/chat'; import { useQuickLinks } from '../lib/quicklinks'; import { odooApi, licenciaModuloIncluido } from '../services/odoo'; interface LayoutProps { children: ReactNode; title: string; subtitle?: string; } const TOP_NAV_ITEMS = [ { label: 'Dashboard', icon: LayoutDashboard, href: '/' }, { label: 'Agenda', icon: Calendar, href: '/agenda' }, { label: 'Pacientes', icon: Users, href: '/pacientes' }, { label: 'Ventas', icon: ShoppingCart, href: '/ventas' }, ]; const MENU_SECTIONS = [ { label: 'Clínica', items: [ { label: 'Pacientes', icon: Users, href: '/pacientes' }, { label: 'Visitas', icon: ClipboardList, href: '/visitas' }, { label: 'Consultas', icon: ClipboardPlus, href: '/consultas' }, { label: 'Expedientes', icon: FolderClock, href: '/expedientes' }, { 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' }, ], }, { label: 'Operaciones', items: [ { label: 'Punto de Venta', icon: Store, href: '/pos' }, { label: 'Ventas', icon: ShoppingCart, href: '/ventas' }, { label: 'Tickets', icon: TicketCheck, href: '/tickets' }, { label: 'Pagos', icon: CreditCard, href: '/pagos' }, { label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' }, { label: 'Monedero', icon: Wallet, href: '/monedero' }, { label: 'Inventario', icon: Boxes, href: '/inventario' }, { label: 'Cortes de Caja', icon: Receipt, href: '/cortes' }, ], }, { label: 'Analítica y más', items: [ { label: 'Reportes', icon: BarChart3, href: '/reportes' }, { label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' }, // Pestanas sueltas que la matriz puede conceder sin dar /reportes completo { label: 'Horas agenda', icon: FolderClock, href: '/reportes?tab=horas-agenda' }, { label: 'Exportar datos', icon: Download, href: '/reportes?tab=exportar' }, { label: 'Chat', icon: MessageCircle, href: '/chat' }, { label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' }, { label: 'Configuración', icon: Settings, href: '/configuracion' }, ], }, { label: 'WACRM', items: [ { label: 'Mensajes', icon: MessageSquare, href: '/wacrm/messages' }, { label: 'Leads', icon: Target, href: '/wacrm/leads' }, ], }, ]; 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' }; const HOME_CAMPANAS_ITEM = { label: 'Campañas', icon: Megaphone, href: '/campanas' }; const HOME_SATISFACCION_ITEM = { label: 'Satisfacción', icon: Smile, href: '/satisfaccion' }; 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 { t } = useI18n(); const navLabel = useNavLabel(); const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg, campanas: campanasCfg, encuestas: encuestasCfg } = useClinicSettings(); const topNavItems = useMemo( () => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })), [term, navLabel] ); const menuSections = useMemo(() => { let base = MENU_SECTIONS; if (membresiasCfg.activo) { base = base.map((s) => (s.label === 'Clínica' ? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] } : s)); } if (comprasCfg.activo) { base = base.map((s) => (s.label === 'Operaciones' ? { ...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)); } if (campanasCfg.activo) { base = base.map((s) => (s.label === 'Analítica y más' ? { ...s, items: [...s.items, HOME_CAMPANAS_ITEM] } : s)); } if (encuestasCfg.activo) { base = base.map((s) => (s.label === 'Analítica y más' ? { ...s, items: [...s.items, HOME_SATISFACCION_ITEM] } : s)); } // Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM. if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) { base = base .filter((s) => s.label !== 'WACRM') .map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') })) .filter((s) => s.items.length > 0); } return traducirMenu(base, term).map((s, idx) => ({ ...s, // `id` conserva la etiqueta canónica en español para el estado de // expansión y las claves de React; `label` es la etiqueta visible. id: base[idx].label, label: navLabel(s.label), items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })), })); }, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, campanasCfg.activo, encuestasCfg.activo, licenciaCfg]); const [menuOpen, setMenuOpen] = useState(false); const [expanded, setExpanded] = useState(['Clínica']); const chatUnread = useChatUnread(); const visibleSections = useMemo( () => menuSections .map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) })) .filter((s) => s.items.length > 0), [canSee, user, menuSections] ); // Accesos rápidos personalizables (localStorage por usuario) const quick = useQuickLinks(); const dockItems = useMemo(() => { const all = [...topNavItems, ...menuSections.flatMap((s) => s.items)]; const seen = new Set(); return all.filter((it) => { if (seen.has(it.href) || !canSee(it.href)) return false; seen.add(it.href); return true; }); }, [canSee, topNavItems, menuSections]); const quickItems = useMemo( () => quick.links .filter((href) => canSee(href)) .map((href) => { for (const s of menuSections) { const found = s.items.find((it) => it.href === href); if (found) return found; } return null; }) .filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null), [quick.links, canSee, menuSections] ); 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 (!menuOpen) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; }, [menuOpen]); const isActive = (href: string) => { if (href === '/') return location.pathname === '/'; return location.pathname === href || location.pathname.startsWith(`${href}/`); }; const toggleSection = (label: string) => { setExpanded((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label])); }; return (
{/* Header horizontal */}
{brand.nombre}
{initials || 'US'}
{/* Sidebar workspace */}
{children}
{/* Mobile drawer */} {menuOpen && ( <>
setMenuOpen(false)} aria-hidden="true" /> )} setGoalModalOpen(false)} title={t('user.goalTitle')} maxWidth="sm" footer={ <> } >

{t('user.goalBody')}

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