Files
SKEEN-Proyecto/frontend/src/components/HomeNestLayout.tsx

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;