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:
376
frontend-homenest/src/components/Layout.tsx
Normal file
376
frontend-homenest/src/components/Layout.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import { useAuth, ROLE_LABELS } from '../lib/auth';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const LOGO_BROWN = '/skeen-brand/logos/Logo%20Completo%20Negro.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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Layout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
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 });
|
||||
};
|
||||
|
||||
// Meta de ventas mensual (header widget)
|
||||
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-homenest-sand shrink-0">
|
||||
<img
|
||||
src={LOGO_BROWN}
|
||||
alt="SKEEN"
|
||||
className="h-8 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-homenest-bark-muted/60 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-homenest-brown text-homenest-cream-light shadow-soft'
|
||||
: 'text-homenest-bark-muted hover:bg-homenest-sand-light hover:text-homenest-bark'
|
||||
}`}
|
||||
>
|
||||
<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-homenest-sand shrink-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-homenest-sage-light flex items-center justify-center text-sm font-bold text-homenest-sage-dark">
|
||||
{initials || 'US'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-homenest-bark truncate">{user?.name || 'Usuario'}</p>
|
||||
<p className="text-xs text-homenest-bark-muted truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-homenest-bark-muted hover:text-homenest-bark hover:bg-homenest-sand-light"
|
||||
title="Cerrar sesión"
|
||||
aria-label="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-homenest-cream text-homenest-bark flex">
|
||||
{/* Sidebar desktop */}
|
||||
<aside className="hidden lg:flex flex-col w-64 bg-homenest-cream-light fixed h-full z-20 border-r border-homenest-sand shadow-soft">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-homenest-bark/40 z-30 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 w-64 bg-homenest-cream-light z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col border-r border-homenest-sand shadow-soft ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="h-20 flex items-center justify-between px-5 border-b border-homenest-sand shrink-0">
|
||||
<img src={LOGO_BROWN} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
|
||||
<X size={22} />
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
|
||||
<header className="bg-homenest-cream-light/80 backdrop-blur border-b border-homenest-sand sticky top-0 z-10">
|
||||
<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 text-homenest-bark"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Abrir menú"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-bold text-homenest-bark truncate font-heading tracking-tight">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-homenest-bark-muted hidden sm:block truncate">{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 border-homenest-sand bg-homenest-cream-light px-3 py-1.5 shadow-card ${hasRole('admin') ? 'hover:border-homenest-brown cursor-pointer' : 'cursor-default'}`}
|
||||
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-homenest-bark-muted mb-1">
|
||||
<span className="flex items-center gap-1"><Target size={12} /> Meta del mes</span>
|
||||
<span className="font-bold text-homenest-bark">{Math.round(goal.pct)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-homenest-cream rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${goal.pct >= 100 ? 'bg-homenest-sage' : 'bg-homenest-brown'}`}
|
||||
style={{ width: `${pctClamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] mt-1">
|
||||
<span className="font-semibold text-homenest-bark">{fmtMoney(goal.current)}</span>
|
||||
<span className="text-homenest-bark-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 bg-homenest-cream rounded-full px-3 py-1.5 border border-homenest-sand focus-within:border-homenest-brown">
|
||||
<Search size={14} className="text-homenest-bark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar paciente, cita..."
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 text-homenest-bark placeholder:text-homenest-bark-muted/60"
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 rounded-full text-homenest-bark-muted hover:bg-homenest-cream-dark relative">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-homenest-brown rounded-full" />
|
||||
</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 text-homenest-bark-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 Layout;
|
||||
62
frontend-homenest/src/components/ui/Badge.tsx
Normal file
62
frontend-homenest/src/components/ui/Badge.tsx
Normal 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-homenest-sage-light text-homenest-bark',
|
||||
pending: 'bg-homenest-cream-dark text-homenest-bark',
|
||||
done: 'bg-homenest-sand-light text-homenest-bark',
|
||||
cancelled: 'bg-rose-100 text-rose-700',
|
||||
paid: 'bg-homenest-sage-light text-homenest-bark',
|
||||
unpaid: 'bg-rose-100 text-rose-700',
|
||||
info: 'bg-homenest-sand-light text-homenest-bark',
|
||||
default: 'bg-homenest-sand-light text-homenest-bark-muted',
|
||||
primary: 'bg-homenest-brown text-white',
|
||||
warning: 'bg-homenest-cream-dark text-homenest-bark',
|
||||
danger: 'bg-rose-100 text-rose-700',
|
||||
success: 'bg-homenest-sage-light text-homenest-bark',
|
||||
};
|
||||
|
||||
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;
|
||||
59
frontend-homenest/src/components/ui/Button.tsx
Normal file
59
frontend-homenest/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ButtonProps extends React.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-homenest-brown text-white hover:bg-homenest-brown-dark focus:ring-homenest-brown disabled:bg-homenest-brown/40 shadow-soft',
|
||||
secondary:
|
||||
'bg-homenest-sage text-homenest-bark hover:bg-homenest-sage-dark focus:ring-homenest-sage disabled:bg-homenest-sage/40',
|
||||
ghost:
|
||||
'bg-transparent text-homenest-bark-muted hover:bg-homenest-brown/10 hover:text-homenest-bark focus:ring-homenest-brown',
|
||||
danger:
|
||||
'bg-homenest-rose text-white hover:bg-rose-600 focus:ring-rose-500 disabled:bg-rose-300',
|
||||
outline:
|
||||
'bg-homenest-cream-light text-homenest-bark border border-homenest-sand hover:border-homenest-brown hover:text-homenest-brown focus:ring-homenest-brown',
|
||||
};
|
||||
|
||||
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 && <span className="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
61
frontend-homenest/src/components/ui/Card.tsx
Normal file
61
frontend-homenest/src/components/ui/Card.tsx
Normal 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-homenest-cream-light rounded-2xl border border-homenest-sand 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-homenest-sand 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;
|
||||
39
frontend-homenest/src/components/ui/EmptyState.tsx
Normal file
39
frontend-homenest/src/components/ui/EmptyState.tsx
Normal 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-[#FEF3C7] flex items-center justify-center text-[#A87B5D] mb-4">
|
||||
{icon ?? <Inbox size={28} />}
|
||||
</div>
|
||||
<h4 className="font-heading text-lg text-homenest-bark mb-1">{title}</h4>
|
||||
<p className="text-sm text-[#7A5C44] max-w-xs mx-auto mb-4">{subtitle}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
34
frontend-homenest/src/components/ui/Input.tsx
Normal file
34
frontend-homenest/src/components/ui/Input.tsx
Normal 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-homenest-bark-muted')}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark placeholder:text-homenest-bark-muted/60 focus:outline-none focus:ring-2 focus:ring-homenest-brown/30 focus:border-homenest-brown transition',
|
||||
error ? 'border-homenest-rose focus:border-homenest-rose focus:ring-homenest-rose/20' : 'border-homenest-sand',
|
||||
inputClassName
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1.5 text-xs text-homenest-rose">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
50
frontend-homenest/src/components/ui/MobileCard.tsx
Normal file
50
frontend-homenest/src/components/ui/MobileCard.tsx
Normal 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-homenest-cream-light rounded-2xl border border-[#F5EBD8] shadow-sm p-4 sm:hidden',
|
||||
onClick && 'cursor-pointer active:bg-[#FEF3C7]',
|
||||
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-homenest-bark truncate">{title}</div>
|
||||
{subtitle && <div className="text-xs text-[#7A5C44] 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-[#A87B5D]">{row.label}</p>
|
||||
<div className="text-sm text-homenest-bark truncate">{row.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileCard;
|
||||
86
frontend-homenest/src/components/ui/Modal.tsx
Normal file
86
frontend-homenest/src/components/ui/Modal.tsx
Normal 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-homenest-bark/40"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'relative bg-homenest-cream-light 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-homenest-sand">
|
||||
<h3 className="font-heading text-lg sm:text-xl text-homenest-bark 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-homenest-sand flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
28
frontend-homenest/src/components/ui/PageHeader.tsx
Normal file
28
frontend-homenest/src/components/ui/PageHeader.tsx
Normal 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-homenest-bark">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-homenest-bark-muted mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
38
frontend-homenest/src/components/ui/Select.tsx
Normal file
38
frontend-homenest/src/components/ui/Select.tsx
Normal 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-homenest-bark-muted mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark focus:outline-none focus:ring-2 focus:ring-homenest-brown/30 focus:border-homenest-brown transition appearance-none',
|
||||
error ? 'border-homenest-rose focus:border-homenest-rose focus:ring-homenest-rose/20' : 'border-homenest-sand'
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1.5 text-xs text-homenest-rose">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
52
frontend-homenest/src/components/ui/Skeleton.tsx
Normal file
52
frontend-homenest/src/components/ui/Skeleton.tsx
Normal 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-[#F5EBD8]', className)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkeletonCard: FC<{ lines?: number; className?: string }> = ({
|
||||
lines = 3,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('bg-homenest-cream-light rounded-2xl p-4 sm:p-6 border border-[#F5EBD8] 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-homenest-cream-light rounded-2xl p-5 border border-[#F5EBD8] 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;
|
||||
31
frontend-homenest/src/components/ui/TextArea.tsx
Normal file
31
frontend-homenest/src/components/ui/TextArea.tsx
Normal 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-[#7A5C44] mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={areaId}
|
||||
className={cn(
|
||||
'w-full rounded-xl border bg-homenest-cream-light px-3 py-2.5 text-sm text-homenest-bark placeholder:text-[#A87B5D] focus:outline-none focus:ring-2 focus:ring-[#8B5E3C]/30 focus:border-[#3E2C1C] transition resize-y min-h-[80px]',
|
||||
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-[#E9D5B7]'
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextArea;
|
||||
19
frontend-homenest/src/components/ui/Toast.tsx
Normal file
19
frontend-homenest/src/components/ui/Toast.tsx
Normal 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;
|
||||
19
frontend-homenest/src/components/ui/index.ts
Normal file
19
frontend-homenest/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user