690 lines
31 KiB
TypeScript
690 lines
31 KiB
TypeScript
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<LayoutProps> = ({ 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<string[]>(['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<string>();
|
|
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 (
|
|
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
|
|
{/* Header horizontal */}
|
|
<header className="sticky top-0 z-30 border-b" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
|
<div className="px-4 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between h-16">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label={t('user.openMenu')}>
|
|
<Menu size={22} />
|
|
</Button>
|
|
<Link to="/" className="flex items-center gap-2">
|
|
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
|
|
</Link>
|
|
</div>
|
|
|
|
<nav className="hidden lg:flex items-center gap-1">
|
|
{topNavItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<Link
|
|
key={item.label}
|
|
to={item.href}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
|
e.currentTarget.style.color = 'var(--text-heading)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--text-muted)';
|
|
}
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
<div className="relative group">
|
|
<button className="flex items-center gap-1 px-4 py-2 rounded-full text-sm font-medium transition" style={{ color: 'var(--text-muted)' }}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
|
e.currentTarget.style.color = 'var(--text-heading)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--text-muted)';
|
|
}}
|
|
>
|
|
{t('user.more')} <ChevronDown size={14} />
|
|
</button>
|
|
<div className="absolute top-full left-0 mt-2 w-56 rounded-2xl shadow-card border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition z-40" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
|
<div className="p-2">
|
|
{visibleSections.flatMap((s) => s.items).filter((it) => !topNavItems.some((t) => t.href === it.href)).map((item) => {
|
|
const Icon = item.icon;
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
to={item.href}
|
|
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
|
style={{ color: 'var(--text-heading)' }}
|
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
>
|
|
<Icon size={16} /> {item.label}
|
|
{item.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>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<GlobalSearch className="hidden md:block" />
|
|
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
|
|
<ThemePicker />
|
|
<LanguageToggle color="var(--text-muted)" />
|
|
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
|
|
{initials || 'US'}
|
|
</div>
|
|
<button onClick={handleLogout} className="p-2 rounded-full transition" style={{ color: 'var(--text-muted)' }}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
|
e.currentTarget.style.color = 'var(--text-heading)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--text-muted)';
|
|
}}
|
|
title={t('user.logout')}
|
|
>
|
|
<LogOut size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex flex-1">
|
|
{/* Sidebar workspace */}
|
|
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6 overflow-y-auto max-h-screen sticky top-0" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
|
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
|
|
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>{t('user.goalMonth')}</span>
|
|
</div>
|
|
{goal && goal.goal > 0 ? (
|
|
<button onClick={hasRole('admin') ? openGoalModal : undefined} className={`w-full text-left ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}>
|
|
<div className="flex items-end justify-between mb-2">
|
|
<span className="text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
|
|
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>{fmtMoney(goal.current)} / {fmtMoney(goal.goal)}</span>
|
|
</div>
|
|
<div className="w-full h-2 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
|
<div className="h-full rounded-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : 'var(--accent)' }} />
|
|
</div>
|
|
</button>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="w-full" onClick={openGoalModal}>
|
|
<Target size={14} className="mr-1.5" /> {t('user.goalDefine')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2">
|
|
{quickItems.length > 0 && quick.mode === 'menu' && (
|
|
<div className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
|
<div className="w-full flex items-center justify-between px-4 py-3">
|
|
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
|
|
<Star size={14} style={{ color: 'var(--accent)' }} />
|
|
{t('nav.quickLinks')}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<QuickLinksModePicker direction="down" align="right" />
|
|
<button
|
|
type="button"
|
|
onClick={() => quick.setEditing(!quick.editing)}
|
|
style={{ color: 'var(--text-muted)' }}
|
|
title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')}
|
|
>
|
|
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<ul className="px-2 pb-2 space-y-1">
|
|
{quickItems.map((item, idx) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<li key={item.href} className="flex items-center gap-1">
|
|
<Link
|
|
to={item.href}
|
|
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
|
e.currentTarget.style.color = 'var(--text-heading)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--text-muted)';
|
|
}
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
{item.label}
|
|
</Link>
|
|
{quick.editing && (
|
|
<span className="flex items-center gap-0.5 shrink-0">
|
|
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
|
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.up')}>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
|
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.down')}>
|
|
<ChevronDown size={14} />
|
|
</button>
|
|
<button type="button" onClick={() => quick.remove(item.href)}
|
|
style={{ color: 'var(--text-muted)' }} title={t('dock.remove')}>
|
|
<X size={14} />
|
|
</button>
|
|
</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{visibleSections.map((section) => (
|
|
<div key={section.id} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
|
<button
|
|
onClick={() => toggleSection(section.id)}
|
|
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
|
|
style={{ color: 'var(--text-heading)' }}
|
|
>
|
|
{section.label}
|
|
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.id) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
|
|
</button>
|
|
{expanded.includes(section.id) && (
|
|
<ul className="px-2 pb-2 space-y-1">
|
|
{section.items.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
const pinned = quick.isPinned(item.href);
|
|
return (
|
|
<li key={item.href} className="group flex items-center gap-1">
|
|
<Link
|
|
to={item.href}
|
|
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
|
e.currentTarget.style.color = 'var(--text-heading)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active) {
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
e.currentTarget.style.color = 'var(--text-muted)';
|
|
}
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
{item.label}
|
|
{item.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(item.href)}
|
|
className={`shrink-0 transition-all ${pinned ? '' : 'opacity-0 group-hover:opacity-100'}`}
|
|
style={{ color: pinned ? 'var(--accent)' : 'var(--text-muted)' }}
|
|
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
|
>
|
|
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<main className={`flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8 ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
|
|
<QuickLinksDock items={dockItems} />
|
|
|
|
{/* Mobile drawer */}
|
|
{menuOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-40 lg:hidden" style={{ backgroundColor: 'rgba(62,44,28,0.4)' }} onClick={() => setMenuOpen(false)} aria-hidden="true" />
|
|
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
|
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
|
|
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label={t('user.closeMenu')}>
|
|
<X size={22} />
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
|
{quickItems.length > 0 && (
|
|
<>
|
|
<p className="px-2 pt-1 text-[10px] uppercase tracking-wider font-medium flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
|
<Star size={11} style={{ color: 'var(--accent)' }} /> {t('nav.quickLinks')}
|
|
</p>
|
|
{quickItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<Link
|
|
key={`quick-${item.href}`}
|
|
to={item.href}
|
|
onClick={() => setMenuOpen(false)}
|
|
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
|
|
}}
|
|
>
|
|
<Icon size={18} /> {item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
<div className="border-b my-2" style={{ borderColor: 'var(--border)' }} />
|
|
</>
|
|
)}
|
|
{topNavItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
to={item.href}
|
|
onClick={() => setMenuOpen(false)}
|
|
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
|
|
}}
|
|
>
|
|
<Icon size={18} /> {item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
{visibleSections.flatMap((s) => s.items).filter((it) => !topNavItems.some((t) => t.href === it.href)).map((item) => {
|
|
const Icon = item.icon;
|
|
const active = isActive(item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
to={item.href}
|
|
onClick={() => setMenuOpen(false)}
|
|
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
|
|
style={{
|
|
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
|
|
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
|
|
}}
|
|
>
|
|
<Icon size={18} /> {item.label}
|
|
{item.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>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="p-4 border-t" style={{ borderColor: 'var(--border)' }}>
|
|
<button onClick={handleLogout} className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium w-full transition" style={{ color: 'var(--text-heading)' }}
|
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
>
|
|
<LogOut size={18} /> {t('user.logout')}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</>
|
|
)}
|
|
|
|
<Modal
|
|
isOpen={goalModalOpen}
|
|
onClose={() => setGoalModalOpen(false)}
|
|
title={t('user.goalTitle')}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
|
|
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
|
|
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HomeNestLayout;
|