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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View 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;

View File

@@ -0,0 +1,20 @@
import type { FC, ReactNode } from 'react';
import SkeenLayout from './SkeenLayout';
import HomeNestLayout from './HomeNestLayout';
import { useTheme } from '../lib/theme';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
const Layout: FC<LayoutProps> = (props) => {
const { theme } = useTheme();
if (theme === 'homenest') {
return <HomeNestLayout {...props} />;
}
return <SkeenLayout {...props} />;
};
export default Layout;

View File

@@ -0,0 +1,373 @@
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,
ChevronRight,
LogOut,
MessageSquare,
Target,
Cake,
Boxes,
ShieldCheck,
Palette,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { odooApi } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
const menuGroups = [
{
label: 'Principal',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
],
},
{
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',
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
],
},
{
label: 'Comunicación',
items: [
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
],
},
{
label: 'Administración',
items: [
{ 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 SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const { toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const visibleGroups = useMemo(
() =>
menuGroups
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
.filter((g) => g.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 (!sidebarOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [sidebarOpen]);
const isActive = (href: string) => {
if (href === '/') return location.pathname === '/';
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
const SidebarContent = () => (
<>
<div className="h-20 flex items-center px-5 lg:px-6 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
</div>
<nav className="flex-1 overflow-y-auto py-5 px-3">
{visibleGroups.map((group) => (
<div key={group.label} className="mb-6">
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.label}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
active
? 'bg-theme-accent text-theme-inverse shadow-soft'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
<span>{item.label}</span>
{active && <ChevronRight size={14} className="ml-auto" />}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent">
{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 text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
title="Cerrar sesión"
aria-label="Cerrar sesión"
>
<LogOut size={18} />
</button>
</div>
</div>
</>
);
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
<aside className="hidden lg:flex flex-col w-64 fixed h-full z-20" style={{ backgroundColor: 'var(--sidebar-bg)' }}>
<SidebarContent />
</aside>
{sidebarOpen && (
<div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
)}
<aside
className={`fixed inset-y-0 left-0 w-64 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)' }}
>
<div className="h-20 flex items-center justify-between px-5 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
<X size={22} />
</Button>
</div>
<SidebarContent />
</aside>
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
<header className="backdrop-blur border-b sticky top-0 z-10" style={{ backgroundColor: 'var(--header-bg)', 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 space-x-3 min-w-0">
<Button
variant="ghost"
size="sm"
className="lg:hidden"
style={{ color: 'var(--text-heading)' }}
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
>
<Menu size={22} />
</Button>
<div className="min-w-0">
<h2 className="text-lg font-bold truncate font-heading tracking-tight" style={{ color: 'var(--text-heading)' }}>{title}</h2>
{subtitle && <p className="text-xs hidden sm:block truncate" style={{ color: 'var(--text-muted)' }}>{subtitle}</p>}
</div>
</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 md:flex flex-col w-44 xl:w-56 text-left rounded-xl border px-3 py-1.5 shadow-card ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
>
<div className="flex items-center justify-between text-[11px] mb-1" style={{ color: 'var(--text-muted)' }}>
<span className="flex items-center gap-1"><Target size={12} /> Meta del mes</span>
<span className="font-bold" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
</div>
<div className="w-full h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg)' }}>
<div className="h-full rounded-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : 'var(--accent)' }} />
</div>
<div className="flex items-center justify-between text-[11px] mt-1">
<span className="font-semibold" style={{ color: 'var(--text-heading)' }}>{fmtMoney(goal.current)}</span>
<span style={{ color: 'var(--text-muted)' }}>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>
)}
<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 paciente, cita..."
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 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 HomeNest"
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(--surface)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--surface)'}
>
<Palette size={14} />
HomeNest
</button>
</div>
</div>
</div>
</header>
<main className="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{children}
</main>
</div>
<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 SkeenLayout;

View File

@@ -0,0 +1,62 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type BadgeVariant =
| 'confirmed'
| 'pending'
| 'done'
| 'cancelled'
| 'paid'
| 'unpaid'
| 'info'
| 'default'
| 'warning'
| 'danger'
| 'success'
| 'primary';
interface BadgeProps {
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variants: Record<BadgeVariant, string> = {
confirmed: 'bg-theme-success text-theme-success-text',
pending: 'bg-theme-accent-bg text-theme-heading',
done: 'bg-theme-lilac text-theme-heading',
cancelled: 'bg-rose-100 text-rose-700',
paid: 'bg-theme-success text-theme-success-text',
unpaid: 'bg-rose-100 text-rose-700',
info: 'bg-theme-sky text-theme-heading',
default: 'bg-theme-accent-bg text-theme-muted',
primary: 'bg-theme-accent text-theme-inverse',
warning: 'bg-theme-warning text-theme-warning-text',
danger: 'bg-rose-100 text-rose-700',
success: 'bg-theme-success text-theme-success-text',
};
const normalizeVariant = (variant: BadgeVariant): BadgeVariant => {
if (variant === 'confirmed' || variant === 'paid' || variant === 'success') return variant;
if (variant === 'pending' || variant === 'warning') return variant;
if (variant === 'done' || variant === 'info') return variant;
if (variant === 'cancelled' || variant === 'danger' || variant === 'unpaid') return variant;
return 'default';
};
export const Badge: FC<BadgeProps> = ({ children, variant = 'default', className }) => {
const normalized = normalizeVariant(variant);
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
variants[normalized],
className
)}
>
{children}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,60 @@
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/utils';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
fullWidth?: boolean;
children: ReactNode;
}
const variants: Record<NonNullable<ButtonProps['variant']>, string> = {
primary:
'bg-theme-accent text-theme-inverse hover:bg-theme-accent-hover focus:ring-theme-accent disabled:bg-theme-accent/40 shadow-soft',
secondary:
'bg-theme-success text-theme-success-text hover:opacity-90 focus:ring-theme-success disabled:opacity-40',
ghost:
'bg-transparent text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading focus:ring-theme-accent',
danger:
'bg-rose-500 text-white hover:bg-rose-600 focus:ring-rose-500 disabled:bg-rose-300',
outline:
'bg-theme-surface text-theme-heading border border-theme-border-strong hover:border-theme-accent hover:text-theme-accent focus:ring-theme-accent',
};
const sizes: Record<NonNullable<ButtonProps['size']>, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg',
md: 'px-4 py-2 text-sm rounded-xl',
lg: 'px-6 py-3 text-base rounded-xl',
};
export const Button: FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
children,
className,
disabled,
...props
}) => {
return (
<button
className={cn(
'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed rounded-xl',
variants[variant],
sizes[size],
fullWidth && 'w-full',
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 size={size === 'lg' ? 18 : 14} className="mr-2 animate-spin" />}
{children}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,61 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface CardProps {
children: ReactNode;
className?: string;
noPadding?: boolean;
}
interface CardHeaderProps {
children: ReactNode;
className?: string;
}
interface CardBodyProps {
children: ReactNode;
className?: string;
noPadding?: boolean;
}
export const Card: FC<CardProps> & {
Header: FC<CardHeaderProps>;
Body: FC<CardBodyProps>;
} = ({ children, className }) => {
return (
<div
className={cn(
'bg-theme-surface rounded-2xl border border-theme-border shadow-card',
className
)}
>
{children}
</div>
);
};
const CardHeader: FC<CardHeaderProps> = ({ children, className }) => {
return (
<div
className={cn(
'px-4 py-4 sm:px-6 sm:py-5 border-b border-theme-border flex flex-col sm:flex-row sm:items-center justify-between gap-3',
className
)}
>
{children}
</div>
);
};
const CardBody: FC<CardBodyProps> = ({ children, className, noPadding = false }) => {
return (
<div className={cn(!noPadding && 'p-4 sm:p-6', className)}>
{children}
</div>
);
};
Card.Header = CardHeader;
Card.Body = CardBody;
export default Card;

View File

@@ -0,0 +1,39 @@
import type { FC, ReactNode } from 'react';
import { Inbox } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
interface EmptyStateProps {
title?: string;
subtitle?: string;
icon?: ReactNode;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export const EmptyState: FC<EmptyStateProps> = ({
title = 'Sin resultados',
subtitle = 'No hay datos para mostrar en este momento.',
icon,
actionLabel,
onAction,
className,
}) => {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
<div className="w-14 h-14 rounded-full bg-theme-bg flex items-center justify-center text-theme-muted mb-4">
{icon ?? <Inbox size={28} />}
</div>
<h4 className="font-heading text-lg text-theme-heading mb-1">{title}</h4>
<p className="text-sm text-theme-muted max-w-xs mx-auto mb-4">{subtitle}</p>
{actionLabel && onAction && (
<Button variant="outline" size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,34 @@
import type { FC, InputHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
inputClassName?: string;
labelClassName?: string;
}
export const Input: FC<InputProps> = ({ label, error, className, inputClassName, labelClassName, id, ...props }) => {
const inputId = id ?? (label ? `input-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={inputId} className={cn('block text-xs font-medium mb-1.5', labelClassName || 'text-theme-muted')}>
{label}
</label>
)}
<input
id={inputId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading placeholder:text-theme-muted/60 focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong',
inputClassName
)}
{...props}
/>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default Input;

View File

@@ -0,0 +1,50 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface MobileCardProps {
title: ReactNode;
subtitle?: ReactNode;
rows: { label: string; value: ReactNode }[];
actions?: ReactNode;
onClick?: () => void;
className?: string;
}
export const MobileCard: FC<MobileCardProps> = ({
title,
subtitle,
rows,
actions,
onClick,
className,
}) => {
return (
<div
className={cn(
'bg-theme-surface rounded-2xl border border-theme-border shadow-sm p-4 sm:hidden',
onClick && 'cursor-pointer active:bg-theme-bg',
className
)}
onClick={onClick}
role={onClick ? 'button' : undefined}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="text-sm font-medium text-theme-heading truncate">{title}</div>
{subtitle && <div className="text-xs text-theme-muted mt-0.5">{subtitle}</div>}
</div>
{actions && <div className="flex items-center gap-1 shrink-0">{actions}</div>}
</div>
<div className="grid grid-cols-2 gap-y-2 gap-x-3">
{rows.map((row, i) => (
<div key={i} className={cn(i === 0 && rows.length % 2 === 1 ? 'col-span-2' : '')}>
<p className="text-[10px] uppercase tracking-wider text-theme-muted">{row.label}</p>
<div className="text-sm text-theme-heading truncate">{row.value}</div>
</div>
))}
</div>
</div>
);
};
export default MobileCard;

View File

@@ -0,0 +1,86 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
footer?: ReactNode;
}
const maxWidthClasses: Record<NonNullable<ModalProps['maxWidth']>, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
full: 'max-w-[calc(100vw-2rem)]',
};
export const Modal: FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
maxWidth = 'md',
footer,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = originalOverflow;
document.removeEventListener('keydown', handleKey);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
aria-modal="true"
role="dialog"
>
<div
className="absolute inset-0 bg-brand-dark/40"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={contentRef}
className={cn(
'relative bg-theme-surface rounded-2xl shadow-xl w-full flex flex-col max-h-[calc(100vh-2rem)]',
maxWidthClasses[maxWidth]
)}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-theme-border">
<h3 className="font-heading text-lg sm:text-xl text-theme-heading pr-4">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Cerrar">
<X size={18} />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-5 sm:p-6">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-theme-border flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,28 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface PageHeaderProps {
title: string;
subtitle?: string;
children?: ReactNode;
className?: string;
}
export const PageHeader: FC<PageHeaderProps> = ({ title, subtitle, children, className }) => {
return (
<div
className={cn(
'flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6',
className
)}
>
<div>
<h2 className="font-heading text-2xl sm:text-3xl text-theme-heading">{title}</h2>
{subtitle && <p className="text-sm text-theme-muted mt-1">{subtitle}</p>}
</div>
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
</div>
);
};
export default PageHeader;

View File

@@ -0,0 +1,38 @@
import type { FC, SelectHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string; label: string }[];
}
export const Select: FC<SelectProps> = ({ label, error, className, options, id, ...props }) => {
const selectId = id ?? (label ? `select-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={selectId} className="block text-xs font-medium text-theme-muted mb-1.5">
{label}
</label>
)}
<select
id={selectId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition appearance-none',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong'
)}
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default Select;

View File

@@ -0,0 +1,52 @@
import type { FC } from 'react';
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
count?: number;
}
export const Skeleton: FC<SkeletonProps> = ({ className, count = 1 }) => {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={cn('rounded-xl bg-theme-border-strong', className)}
/>
))}
</div>
);
};
export const SkeletonCard: FC<{ lines?: number; className?: string }> = ({
lines = 3,
className,
}) => {
return (
<div className={cn('bg-theme-surface rounded-2xl p-4 sm:p-6 border border-theme-border shadow-sm', className)}>
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-16" />
</div>
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
);
};
export const SkeletonStat: FC = () => (
<div className="bg-theme-surface rounded-2xl p-5 border border-theme-border shadow-sm">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-7 w-24" />
</div>
);
export default Skeleton;

View File

@@ -0,0 +1,31 @@
import type { FC, TextareaHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const TextArea: FC<TextAreaProps> = ({ label, error, className, id, ...props }) => {
const areaId = id ?? (label ? `textarea-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={areaId} className="block text-xs font-medium text-theme-muted mb-1.5">
{label}
</label>
)}
<textarea
id={areaId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading placeholder:text-theme-muted/60 focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition resize-y min-h-[80px]',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong'
)}
{...props}
/>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default TextArea;

View File

@@ -0,0 +1,19 @@
import { Toaster } from 'sonner';
export const ToastProvider = () => {
return (
<Toaster
position="top-right"
richColors
closeButton
toastOptions={{
style: {
fontFamily:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
}}
/>
);
};
export default ToastProvider;

View File

@@ -0,0 +1,19 @@
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';
export { Select } from './Select';
export { TextArea } from './TextArea';
export { Modal } from './Modal';
export { Badge } from './Badge';
export { EmptyState } from './EmptyState';
export { Skeleton, SkeletonCard, SkeletonStat } from './Skeleton';
export { MobileCard } from './MobileCard';
export { PageHeader } from './PageHeader';
export { ToastProvider } from './Toast';
export { toast } from '../../lib/toast';
export {
badgeForAppointmentState,
badgeForPaymentState,
badgeForSaleState,
badgeForCashClosingState,
} from '../../lib/badges';