660 lines
28 KiB
TypeScript
660 lines
28 KiB
TypeScript
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<LayoutProps> = ({ 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<MenuEntry[]>(() => {
|
|
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<string[]>(() => {
|
|
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<HTMLElement | null>(null);
|
|
const navMobileRef = useRef<HTMLElement | null>(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<HTMLElement>('[data-active="1"]');
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [location.pathname, location.search, openGroups, visibleLeaves]);
|
|
|
|
const renderSidebar = ({ navRef = null }: { navRef?: MutableRefObject<HTMLElement | null> | null } = {}) => (
|
|
<>
|
|
{/* Sucursal, como en el sistema legacy */}
|
|
<p className="text-center text-sm font-medium text-white py-3 shrink-0" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
|
Rosarito
|
|
</p>
|
|
<nav ref={navRef} className="flex-1 overflow-y-auto py-2">
|
|
{quickItems.length > 0 && quick.mode === 'menu' && (
|
|
<div className="mb-2">
|
|
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
|
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: 'var(--sidebar-group-text)' }}>{t('nav.quickLinks')}</p>
|
|
<span className="flex items-center gap-1">
|
|
<QuickLinksModePicker direction="down" align="right" />
|
|
<button
|
|
type="button"
|
|
onClick={() => quick.setEditing(!quick.editing)}
|
|
className="hover:text-white transition-colors"
|
|
style={{ color: 'var(--sidebar-group-text)' }}
|
|
title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')}
|
|
>
|
|
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<ul>
|
|
{quickItems.map((item, idx) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<li key={item.href} className="flex items-center">
|
|
<Link
|
|
to={item.href}
|
|
onClick={() => setSidebarOpen(false)}
|
|
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
|
style={{
|
|
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
|
|
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
|
|
color: active ? '#fff' : 'var(--sidebar-text)',
|
|
}}
|
|
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
|
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<Icon size={16} />
|
|
<span>{item.label}</span>
|
|
</Link>
|
|
{quick.editing && (
|
|
<span className="flex items-center gap-0.5 shrink-0 pr-2">
|
|
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
|
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.up')}>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
|
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.down')}>
|
|
<ChevronDown size={14} />
|
|
</button>
|
|
<button type="button" onClick={() => quick.remove(item.href)}
|
|
className="hover:text-rose-400 transition-colors" style={{ color: 'var(--sidebar-text)' }} title={t('dock.remove')}>
|
|
<X size={14} />
|
|
</button>
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<ul>
|
|
{menuTreeT.map((entry) => {
|
|
const Icon = entry.icon;
|
|
// Hojas visibles de este entry
|
|
const leaves = entry.children
|
|
? entry.children.filter((c) => canSee(c.href))
|
|
: (entry.href && canSee(entry.href) ? [{ label: entry.label, href: entry.href }] : []);
|
|
if (leaves.length === 0) return null;
|
|
|
|
// Entry simple (sin hijos)
|
|
if (!entry.children) {
|
|
const active = isActive(entry.href as string);
|
|
const pinned = quick.isPinned(entry.href as string);
|
|
return (
|
|
<li key={entry.id} className="group flex items-center">
|
|
<Link
|
|
to={entry.href as string}
|
|
onClick={() => setSidebarOpen(false)}
|
|
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
|
style={{
|
|
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
|
|
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
|
|
color: active ? '#fff' : 'var(--sidebar-text)',
|
|
}}
|
|
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
|
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<Icon size={16} />
|
|
<span>{entry.label}</span>
|
|
{active && <ChevronRight size={13} className="ml-auto" />}
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => quick.toggle(entry.href as string)}
|
|
className={`shrink-0 pr-2 transition-all ${
|
|
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
|
}`}
|
|
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
|
title={pinned ? t('user.unpinQuick') : t('user.pinQuick')}
|
|
>
|
|
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
// Entry con submenú (treeview AdminLTE)
|
|
const open = openGroups.includes(entry.id as string);
|
|
const childActive = leaves.some((c) => isActive(c.href));
|
|
return (
|
|
<li key={entry.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleGroup(entry.id as string)}
|
|
className="w-full flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
|
|
style={{
|
|
backgroundColor: childActive ? 'var(--sidebar-active-bg)' : 'transparent',
|
|
borderLeft: `3px solid ${childActive ? 'var(--sidebar-accent)' : 'transparent'}`,
|
|
color: childActive ? '#fff' : 'var(--sidebar-text)',
|
|
}}
|
|
onMouseEnter={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
|
onMouseLeave={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<Icon size={16} />
|
|
<span>{entry.label}</span>
|
|
{entry.children?.some((c) => c.href === '/chat') && chatUnread > 0 && (
|
|
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
|
|
{chatUnread}
|
|
</span>
|
|
)}
|
|
<ChevronDown size={14} className={`${entry.children?.some((c) => c.href === '/chat') && chatUnread > 0 ? '' : 'ml-auto'} transition-transform ${open ? '' : '-rotate-90'}`} />
|
|
</button>
|
|
{open && (
|
|
<ul style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
|
|
{leaves.map((child) => {
|
|
const active = isActive(child.href);
|
|
const pinned = quick.isPinned(child.href);
|
|
return (
|
|
<li key={child.href} className="group flex items-center">
|
|
<Link
|
|
to={child.href}
|
|
onClick={() => setSidebarOpen(false)}
|
|
data-active={active ? '1' : undefined}
|
|
className="flex-1 flex items-center pl-11 pr-4 py-2 text-[13px] transition-colors"
|
|
style={{
|
|
backgroundColor: active ? 'rgba(0,0,0,0.25)' : 'transparent',
|
|
color: active ? '#fff' : 'var(--sidebar-text)',
|
|
}}
|
|
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
|
|
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: active ? 'var(--sidebar-accent)' : 'var(--sidebar-text)' }} />
|
|
<span>{child.label}</span>
|
|
{child.href === '/chat' && chatUnread > 0 && (
|
|
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-brand-coral text-white text-[10px] font-bold">
|
|
{chatUnread}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => quick.toggle(child.href)}
|
|
className={`shrink-0 pr-2 transition-all ${
|
|
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
|
}`}
|
|
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
|
|
title={pinned ? t('user.unpinQuick') : t('user.pinQuick')}
|
|
>
|
|
<Star size={13} fill={pinned ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
<div className="p-3 shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
|
<div className="flex items-center space-x-3">
|
|
<div
|
|
className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold"
|
|
style={{ backgroundColor: 'var(--sidebar-accent)', color: '#3E454C' }}
|
|
>
|
|
{initials || 'US'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
|
|
<p className="text-xs truncate" style={{ color: 'var(--sidebar-text)' }}>{user ? (ROLE_LABELS[user.role] ? t(`role.${user.role}`) : (user.role_name || user.role)) : ''}</p>
|
|
</div>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="p-2 transition-colors hover:text-white"
|
|
style={{ color: 'var(--sidebar-text)' }}
|
|
title={t('user.logout')}
|
|
aria-label={t('user.logout')}
|
|
>
|
|
<LogOut size={17} />
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<span className="text-[11px]" style={{ color: 'var(--sidebar-text)' }}>{t('user.language')}</span>
|
|
<LanguageToggle color="var(--sidebar-text)" />
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
|
|
{/* Header fijo */}
|
|
<header
|
|
className="fixed top-0 left-0 right-0 z-30 h-[50px] flex items-stretch"
|
|
style={{ backgroundColor: 'var(--header-bg)' }}
|
|
>
|
|
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
|
|
<span className="text-[26px] font-bold tracking-tight truncate" style={{ color: '#333333' }}>
|
|
{brand.nombre}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0">
|
|
<div className="flex items-center space-x-3 min-w-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="lg:hidden text-white"
|
|
onClick={() => setSidebarOpen(true)}
|
|
aria-label={t('user.openMenu')}
|
|
>
|
|
<Menu size={20} />
|
|
</Button>
|
|
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>{brand.nombre}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
|
|
{goal && goal.goal > 0 && (
|
|
<button
|
|
onClick={hasRole('admin') ? openGoalModal : undefined}
|
|
className={`hidden xl:flex flex-col w-44 text-left rounded-[2px] px-3 py-1 ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
|
|
style={{ backgroundColor: 'rgba(255,255,255,0.25)' }}
|
|
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
|
|
>
|
|
<div className="flex items-center justify-between text-[11px] mb-0.5 text-white/80">
|
|
<span className="flex items-center gap-1"><Target size={11} /> Meta del mes</span>
|
|
<span className="font-bold text-white">{Math.round(goal.pct)}%</span>
|
|
</div>
|
|
<div className="w-full h-1.5 overflow-hidden" style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
|
|
<div className="h-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : '#fff' }} />
|
|
</div>
|
|
<div className="flex items-center justify-between text-[11px] mt-0.5 text-white/80">
|
|
<span className="font-semibold text-white">{fmtMoney(goal.current)}</span>
|
|
<span>de {fmtMoney(goal.goal)}</span>
|
|
</div>
|
|
</button>
|
|
)}
|
|
{goal && goal.goal <= 0 && hasRole('admin') && (
|
|
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
|
|
<Target size={14} className="mr-1.5" /> Definir meta
|
|
</Button>
|
|
)}
|
|
<GlobalSearch className="hidden md:block" />
|
|
<NotificationBell buttonClassName="p-2 rounded-[2px] relative transition text-white/90 hover:bg-black/10" />
|
|
<ThemePicker triggerClassName="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-[2px] text-xs font-medium text-white/90 transition hover:bg-black/10" />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Sidebar desktop */}
|
|
<aside
|
|
className="hidden lg:flex flex-col w-[230px] fixed top-[50px] bottom-0 left-0 z-20"
|
|
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
|
>
|
|
{renderSidebar({ navRef: navDesktopRef })}
|
|
</aside>
|
|
|
|
{/* Drawer móvil */}
|
|
{sidebarOpen && (
|
|
<div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
|
|
)}
|
|
<aside
|
|
className={`fixed top-[50px] bottom-0 left-0 w-[230px] z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col ${
|
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
|
>
|
|
{renderSidebar({ navRef: navMobileRef })}
|
|
</aside>
|
|
|
|
{/* Contenido */}
|
|
<main
|
|
className={`lg:ml-[230px] pt-[50px] min-h-screen ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}
|
|
>
|
|
<div className="px-4 sm:px-6 py-5">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
|
|
<QuickLinksDock items={dockItems} />
|
|
|
|
<Modal
|
|
isOpen={goalModalOpen}
|
|
onClose={() => setGoalModalOpen(false)}
|
|
title="Meta de ventas del mes"
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
|
|
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
|
|
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ClasicoLayout;
|