Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
475
frontend/src/components/HomeNestLayout.tsx
Normal file
475
frontend/src/components/HomeNestLayout.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
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,
|
||||
Package,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Target,
|
||||
Cake,
|
||||
Boxes,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Palette,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
|
||||
|
||||
const topNavItems = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
|
||||
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
|
||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
];
|
||||
|
||||
const menuSections = [
|
||||
{
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Productos', icon: Package, href: '/productos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operaciones',
|
||||
items: [
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||
{ 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: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
|
||||
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
|
||||
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'WACRM',
|
||||
items: [
|
||||
{ label: 'Mensajes', icon: MessageSquare, href: '/wacrm/messages' },
|
||||
{ label: 'Leads', icon: Target, href: '/wacrm/leads' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { toggleTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
menuSections
|
||||
.map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) }))
|
||||
.filter((s) => s.items.length > 0),
|
||||
[canSee, user]
|
||||
);
|
||||
|
||||
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="Abrir menú">
|
||||
<Menu size={22} />
|
||||
</Button>
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src={LOGO_HOMENEST} alt="SKEEN" 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)';
|
||||
}}
|
||||
>
|
||||
Más <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}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-40 placeholder:text-theme-muted/60"
|
||||
style={{ color: 'var(--text-heading)' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title="Cambiar a SKEEN"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
|
||||
style={{ backgroundColor: 'var(--bg)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--bg)'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
SKEEN
|
||||
</button>
|
||||
<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="Cerrar sesión"
|
||||
>
|
||||
<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" 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)' }}>Meta del mes</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" /> Definir meta
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{visibleSections.map((section) => (
|
||||
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.label)}
|
||||
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.label) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
{expanded.includes(section.label) && (
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="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>
|
||||
</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">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 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 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={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
|
||||
<X size={22} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{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}
|
||||
</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} /> Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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.</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 HomeNestLayout;
|
||||
Reference in New Issue
Block a user