feat: Implement Phase 1 & 2 - Full monorepo architecture

## Backend API (apps/api)
- Express.js server with TypeScript
- JWT authentication with access/refresh tokens
- Multi-tenant middleware (schema per tenant)
- Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts
- SAT integration: CFDI 4.0 XML parser, FIEL authentication
- Metrics engine: 50+ financial metrics (Core, Startup, Enterprise)
- Rate limiting, CORS, Helmet security

## Frontend Web (apps/web)
- Next.js 14 with App Router
- Authentication pages: login, register, forgot-password
- Dashboard layout with Sidebar and Header
- Dashboard pages: overview, cash-flow, revenue, expenses, metrics
- Zustand stores for auth and UI state
- Theme support with flash prevention

## Database Package (packages/database)
- PostgreSQL migrations with multi-tenant architecture
- Public schema: plans, tenants, users, sessions, subscriptions
- Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts
- Tenant management functions
- Seed data for plans and super admin

## Shared Package (packages/shared)
- TypeScript types: auth, tenant, financial, metrics, reports
- Zod validation schemas for all entities
- Utility functions for formatting

## UI Package (packages/ui)
- Chart components: LineChart, BarChart, AreaChart, PieChart
- Data components: DataTable, MetricCard, KPICard, AlertBadge
- PeriodSelector and Skeleton components

## Infrastructure
- Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog
- Makefile with 25+ development commands
- Development scripts: dev-setup.sh, dev-down.sh
- Complete .env.example template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

View File

@@ -0,0 +1,345 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { cn, getInitials } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth.store';
import { useUIStore, useTheme } from '@/stores/ui.store';
import {
Menu,
Bell,
Sun,
Moon,
Search,
User,
Settings,
LogOut,
ChevronDown,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
/**
* Notificacion mock
*/
interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
timestamp: Date;
read: boolean;
}
/**
* Notificaciones de ejemplo
*/
const mockNotifications: Notification[] = [
{
id: '1',
title: 'Trade ejecutado',
message: 'BTC/USDT compra a $43,250',
type: 'success',
timestamp: new Date(Date.now() - 1000 * 60 * 5),
read: false,
},
{
id: '2',
title: 'Stop Loss activado',
message: 'ETH/USDT posicion cerrada',
type: 'warning',
timestamp: new Date(Date.now() - 1000 * 60 * 30),
read: false,
},
{
id: '3',
title: 'Nueva estrategia disponible',
message: 'Grid Trading actualizado',
type: 'info',
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
read: true,
},
];
/**
* Componente Header
*
* Header principal con búsqueda, notificaciones, tema y menú de usuario.
*/
export const Header: React.FC = () => {
const router = useRouter();
const { user, logout } = useAuthStore();
const { sidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
const { isDark, toggleTheme } = useTheme();
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [notifications] = useState<Notification[]>(mockNotifications);
const notificationRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
// Cerrar menus al hacer click fuera
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
setShowNotifications(false);
}
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Handle logout
const handleLogout = async () => {
await logout();
router.push('/login');
};
// Unread count
const unreadCount = notifications.filter((n) => !n.read).length;
return (
<header
className={cn(
'fixed top-0 right-0 z-40',
'h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md',
'border-b border-slate-200 dark:border-slate-700',
'transition-all duration-300',
// Ajustar ancho segun sidebar
isMobile ? 'left-0' : (sidebarCollapsed ? 'left-20' : 'left-64')
)}
>
<div className="flex items-center justify-between h-full px-4 lg:px-6">
{/* Left Section */}
<div className="flex items-center gap-4">
{/* Mobile menu button */}
{isMobile && (
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="Abrir menu"
>
<Menu className="h-5 w-5" />
</button>
)}
{/* Search */}
<div className="relative">
{showSearch ? (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Buscar..."
className="w-64 px-4 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={() => setShowSearch(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setShowSearch(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Buscar</span>
<kbd className="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-slate-100 dark:bg-slate-700 rounded">
<span className="text-xs">Ctrl</span>K
</kbd>
</button>
)}
</div>
</div>
{/* Right Section */}
<div className="flex items-center gap-2">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{/* Notifications */}
<div ref={notificationRef} className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
aria-label="Notificaciones"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-4 h-4 flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full">
{unreadCount}
</span>
)}
</button>
{/* Notifications Dropdown */}
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Notificaciones
</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
'px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0',
'hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors',
!notification.read && 'bg-primary-50/50 dark:bg-primary-900/20'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'w-2 h-2 mt-2 rounded-full flex-shrink-0',
notification.type === 'success' && 'bg-success-500',
notification.type === 'error' && 'bg-error-500',
notification.type === 'warning' && 'bg-warning-500',
notification.type === 'info' && 'bg-primary-500'
)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{notification.title}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
{notification.message}
</p>
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
{formatTimeAgo(notification.timestamp)}
</p>
</div>
</div>
</div>
))
) : (
<div className="p-8 text-center text-slate-500 dark:text-slate-400">
No hay notificaciones
</div>
)}
</div>
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
<Link
href="/notifications"
className="block text-center text-sm text-primary-600 dark:text-primary-400 hover:underline"
onClick={() => setShowNotifications(false)}
>
Ver todas
</Link>
</div>
</div>
)}
</div>
{/* User Menu */}
<div ref={userMenuRef} className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
{/* Avatar */}
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center text-primary-700 dark:text-primary-300 font-semibold text-sm">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
getInitials(user?.name || 'Usuario')
)}
</div>
<div className="hidden md:block text-left">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{user?.name || 'Usuario'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{user?.role || 'trader'}
</p>
</div>
<ChevronDown className="hidden md:block h-4 w-4 text-slate-400" />
</button>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{user?.name || 'Usuario'}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
{user?.email || 'usuario@ejemplo.com'}
</p>
</div>
<div className="py-2">
<Link
href="/profile"
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
onClick={() => setShowUserMenu(false)}
>
<User className="h-4 w-4" />
Mi Perfil
</Link>
<Link
href="/settings"
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
onClick={() => setShowUserMenu(false)}
>
<Settings className="h-4 w-4" />
Configuracion
</Link>
</div>
<div className="py-2 border-t border-slate-200 dark:border-slate-700">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-error-600 dark:text-error-400 hover:bg-error-50 dark:hover:bg-error-900/20"
>
<LogOut className="h-4 w-4" />
Cerrar sesion
</button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
};
/**
* Formatea tiempo relativo
*/
function formatTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'Ahora';
if (minutes < 60) return `Hace ${minutes} min`;
if (hours < 24) return `Hace ${hours}h`;
return `Hace ${days}d`;
}
export default Header;

View File

@@ -0,0 +1,306 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui.store';
import {
LayoutDashboard,
LineChart,
Wallet,
History,
Settings,
HelpCircle,
ChevronLeft,
ChevronRight,
TrendingUp,
Bot,
Shield,
Bell,
} from 'lucide-react';
/**
* Item de navegación
*/
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
badge?: string | number;
children?: NavItem[];
}
/**
* Grupos de navegación
*/
interface NavGroup {
label?: string;
items: NavItem[];
}
/**
* Navegación principal
*/
const navigation: NavGroup[] = [
{
items: [
{
label: 'Dashboard',
href: '/dashboard',
icon: <LayoutDashboard className="h-5 w-5" />,
},
],
},
{
label: 'Trading',
items: [
{
label: 'Estrategias',
href: '/strategies',
icon: <Bot className="h-5 w-5" />,
badge: 3,
},
{
label: 'Portfolio',
href: '/portfolio',
icon: <Wallet className="h-5 w-5" />,
},
{
label: 'Mercados',
href: '/markets',
icon: <TrendingUp className="h-5 w-5" />,
},
{
label: 'Historial',
href: '/history',
icon: <History className="h-5 w-5" />,
},
],
},
{
label: 'Analisis',
items: [
{
label: 'Performance',
href: '/analytics',
icon: <LineChart className="h-5 w-5" />,
},
{
label: 'Riesgo',
href: '/risk',
icon: <Shield className="h-5 w-5" />,
},
],
},
{
label: 'Sistema',
items: [
{
label: 'Notificaciones',
href: '/notifications',
icon: <Bell className="h-5 w-5" />,
badge: 5,
},
{
label: 'Configuracion',
href: '/settings',
icon: <Settings className="h-5 w-5" />,
},
{
label: 'Ayuda',
href: '/help',
icon: <HelpCircle className="h-5 w-5" />,
},
],
},
];
/**
* Componente NavLink
*/
interface NavLinkProps {
item: NavItem;
collapsed: boolean;
}
const NavLink: React.FC<NavLinkProps> = ({ item, collapsed }) => {
const pathname = usePathname();
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg',
'text-sm font-medium transition-all duration-200',
'group relative',
isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white',
collapsed && 'justify-center px-2'
)}
>
{/* Active Indicator */}
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary-600 dark:bg-primary-400 rounded-r-full" />
)}
{/* Icon */}
<span
className={cn(
'flex-shrink-0 transition-colors',
isActive
? 'text-primary-600 dark:text-primary-400'
: 'text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300'
)}
>
{item.icon}
</span>
{/* Label */}
{!collapsed && (
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Badge */}
{item.badge && !collapsed && (
<span className="flex-shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300">
{item.badge}
</span>
)}
{/* Tooltip when collapsed */}
{collapsed && (
<span className="absolute left-full ml-2 px-2 py-1 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
{item.label}
{item.badge && (
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-primary-500 text-white">
{item.badge}
</span>
)}
</span>
)}
</Link>
);
};
/**
* Componente Sidebar
*
* Barra lateral de navegación con soporte para colapsado y grupos de navegación.
*/
export const Sidebar: React.FC = () => {
const { sidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
// En mobile, cerrar al hacer click en overlay
const handleOverlayClick = () => {
if (isMobile) {
setSidebarOpen(false);
}
};
return (
<>
{/* Overlay mobile */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={handleOverlayClick}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full',
'bg-white dark:bg-slate-900',
'border-r border-slate-200 dark:border-slate-700',
'flex flex-col',
'transition-all duration-300 ease-in-out',
// Width
sidebarCollapsed ? 'w-20' : 'w-64',
// Mobile
isMobile && !sidebarOpen && '-translate-x-full',
isMobile && sidebarOpen && 'translate-x-0',
// Desktop
!isMobile && 'translate-x-0'
)}
>
{/* Header */}
<div className={cn(
'flex items-center h-16 px-4',
'border-b border-slate-200 dark:border-slate-700',
sidebarCollapsed && 'justify-center'
)}>
{/* Logo */}
<Link href="/dashboard" className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
<span className="text-white font-bold text-xl">H</span>
</div>
{!sidebarCollapsed && (
<span className="text-xl font-bold text-slate-900 dark:text-white">
Horux
</span>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
{navigation.map((group, idx) => (
<div key={idx}>
{/* Group Label */}
{group.label && !sidebarCollapsed && (
<h3 className="px-3 mb-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
{group.label}
</h3>
)}
{/* Separator when collapsed */}
{group.label && sidebarCollapsed && (
<div className="my-2 border-t border-slate-200 dark:border-slate-700" />
)}
{/* Items */}
<div className="space-y-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} collapsed={sidebarCollapsed} />
))}
</div>
</div>
))}
</nav>
{/* Footer - Collapse Button */}
{!isMobile && (
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
<button
onClick={toggleSidebarCollapsed}
className={cn(
'flex items-center gap-3 w-full px-3 py-2.5 rounded-lg',
'text-sm font-medium text-slate-600 dark:text-slate-400',
'hover:bg-slate-100 dark:hover:bg-slate-700/50',
'transition-all duration-200',
sidebarCollapsed && 'justify-center'
)}
aria-label={sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
>
{sidebarCollapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<>
<ChevronLeft className="h-5 w-5" />
<span>Colapsar</span>
</>
)}
</button>
</div>
)}
</aside>
</>
);
};
export default Sidebar;

View File

@@ -0,0 +1,9 @@
/**
* Layout Components Barrel Export
*
* Re-exporta todos los componentes de layout.
* Ejemplo: import { Sidebar, Header } from '@/components/layout';
*/
export { Sidebar } from './Sidebar';
export { Header } from './Header';

View File

@@ -0,0 +1,215 @@
'use client';
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Variantes del botón
*/
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
/**
* Tamaños del botón
*/
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* Props del componente Button
*/
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
/**
* Estilos base del botón
*/
const baseStyles = `
inline-flex items-center justify-center
font-medium rounded-lg
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
active:scale-[0.98]
`;
/**
* Estilos por variante
*/
const variantStyles: Record<ButtonVariant, string> = {
primary: `
bg-primary-600 text-white
hover:bg-primary-700
focus:ring-primary-500
dark:bg-primary-500 dark:hover:bg-primary-600
`,
secondary: `
bg-slate-100 text-slate-900
hover:bg-slate-200
focus:ring-slate-500
dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600
`,
outline: `
border-2 border-primary-600 text-primary-600
hover:bg-primary-50
focus:ring-primary-500
dark:border-primary-400 dark:text-primary-400 dark:hover:bg-primary-950
`,
ghost: `
text-slate-700
hover:bg-slate-100
focus:ring-slate-500
dark:text-slate-300 dark:hover:bg-slate-800
`,
danger: `
bg-error-600 text-white
hover:bg-error-700
focus:ring-error-500
dark:bg-error-500 dark:hover:bg-error-600
`,
success: `
bg-success-600 text-white
hover:bg-success-700
focus:ring-success-500
dark:bg-success-500 dark:hover:bg-success-600
`,
};
/**
* Estilos por tamaño
*/
const sizeStyles: Record<ButtonSize, string> = {
xs: 'text-xs px-2.5 py-1.5 gap-1',
sm: 'text-sm px-3 py-2 gap-1.5',
md: 'text-sm px-4 py-2.5 gap-2',
lg: 'text-base px-5 py-3 gap-2',
xl: 'text-lg px-6 py-3.5 gap-2.5',
};
/**
* Componente Spinner para loading
*/
const Spinner: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={cn('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
/**
* Componente Button
*
* Botón reutilizable con múltiples variantes y tamaños.
* Soporta estados de loading, iconos y ancho completo.
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
...props
},
ref
) => {
// Determinar tamaño del spinner
const spinnerSize = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-5 w-5',
}[size];
return (
<button
ref={ref}
className={cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
fullWidth && 'w-full',
className
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<Spinner className={spinnerSize} />
<span>Cargando...</span>
</>
) : (
<>
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
{children}
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
/**
* Botón de icono (solo icono, sin texto)
*/
interface IconButtonProps extends Omit<ButtonProps, 'leftIcon' | 'rightIcon' | 'children'> {
icon: React.ReactNode;
'aria-label': string;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, size = 'md', icon, ...props }, ref) => {
const iconSizeStyles: Record<ButtonSize, string> = {
xs: 'p-1.5',
sm: 'p-2',
md: 'p-2.5',
lg: 'p-3',
xl: 'p-3.5',
};
return (
<Button
ref={ref}
className={cn(iconSizeStyles[size], className)}
size={size}
{...props}
>
{icon}
</Button>
);
}
);
IconButton.displayName = 'IconButton';
export default Button;

View File

@@ -0,0 +1,256 @@
'use client';
import React, { forwardRef, HTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* Variantes del Card
*/
type CardVariant = 'default' | 'bordered' | 'elevated' | 'gradient';
/**
* Props del componente Card
*/
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: CardVariant;
padding?: 'none' | 'sm' | 'md' | 'lg';
hoverable?: boolean;
clickable?: boolean;
}
/**
* Estilos base del card
*/
const baseStyles = `
rounded-xl bg-white
dark:bg-slate-800
transition-all duration-200
`;
/**
* Estilos por variante
*/
const variantStyles: Record<CardVariant, string> = {
default: `
border border-slate-200
dark:border-slate-700
`,
bordered: `
border-2 border-slate-300
dark:border-slate-600
`,
elevated: `
shadow-lg shadow-slate-200/50
dark:shadow-slate-900/50
border border-slate-100
dark:border-slate-700
`,
gradient: `
border border-transparent
bg-gradient-to-br from-white to-slate-50
dark:from-slate-800 dark:to-slate-900
shadow-lg shadow-slate-200/50
dark:shadow-slate-900/50
`,
};
/**
* Estilos de padding
*/
const paddingStyles: Record<string, string> = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
/**
* Componente Card
*
* Contenedor reutilizable con múltiples variantes y estados.
*/
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
className,
variant = 'default',
padding = 'md',
hoverable = false,
clickable = false,
children,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
baseStyles,
variantStyles[variant],
paddingStyles[padding],
hoverable && 'hover:border-primary-300 hover:shadow-md dark:hover:border-primary-600',
clickable && 'cursor-pointer active:scale-[0.99]',
className
)}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
/**
* Card Header
*/
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
subtitle?: string;
action?: React.ReactNode;
}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, title, subtitle, action, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-start justify-between gap-4 mb-4', className)}
{...props}
>
{(title || subtitle) ? (
<div className="flex-1 min-w-0">
{title && (
<h3 className="text-lg font-semibold text-slate-900 dark:text-white truncate">
{title}
</h3>
)}
{subtitle && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{subtitle}
</p>
)}
</div>
) : (
children
)}
{action && <div className="flex-shrink-0">{action}</div>}
</div>
);
}
);
CardHeader.displayName = 'CardHeader';
/**
* Card Content
*/
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
return (
<div ref={ref} className={cn('', className)} {...props}>
{children}
</div>
);
}
);
CardContent.displayName = 'CardContent';
/**
* Card Footer
*/
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'mt-4 pt-4 border-t border-slate-200 dark:border-slate-700',
className
)}
{...props}
>
{children}
</div>
);
}
);
CardFooter.displayName = 'CardFooter';
/**
* Stats Card - Card especializado para mostrar estadísticas
*/
interface StatsCardProps extends HTMLAttributes<HTMLDivElement> {
title: string;
value: string | number;
change?: {
value: number;
label?: string;
};
icon?: React.ReactNode;
trend?: 'up' | 'down' | 'neutral';
}
export const StatsCard = forwardRef<HTMLDivElement, StatsCardProps>(
({ className, title, value, change, icon, trend, ...props }, ref) => {
const trendColors = {
up: 'text-success-600 dark:text-success-400',
down: 'text-error-600 dark:text-error-400',
neutral: 'text-slate-500 dark:text-slate-400',
};
const trendBgColors = {
up: 'bg-success-50 dark:bg-success-900/30',
down: 'bg-error-50 dark:bg-error-900/30',
neutral: 'bg-slate-100 dark:bg-slate-700',
};
return (
<Card ref={ref} className={cn('', className)} {...props}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
{title}
</p>
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
{value}
</p>
{change && (
<div className="mt-2 flex items-center gap-2">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
trendBgColors[trend || 'neutral'],
trendColors[trend || 'neutral']
)}
>
{change.value >= 0 ? '+' : ''}{change.value}%
</span>
{change.label && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{change.label}
</span>
)}
</div>
)}
</div>
{icon && (
<div className="flex-shrink-0 p-3 rounded-lg bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
{icon}
</div>
)}
</div>
</Card>
);
}
);
StatsCard.displayName = 'StatsCard';
export default Card;

View File

@@ -0,0 +1,266 @@
'use client';
import React, { forwardRef, InputHTMLAttributes, useState } from 'react';
import { cn } from '@/lib/utils';
import { Eye, EyeOff, AlertCircle, CheckCircle } from 'lucide-react';
/**
* Tamaños del input
*/
type InputSize = 'sm' | 'md' | 'lg';
/**
* Props del componente Input
*/
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
success?: boolean;
size?: InputSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
/**
* Estilos base del input
*/
const baseStyles = `
w-full rounded-lg border
transition-all duration-200 ease-in-out
focus:outline-none focus:ring-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50
dark:disabled:bg-slate-900
placeholder:text-slate-400 dark:placeholder:text-slate-500
`;
/**
* Estilos por estado
*/
const stateStyles = {
default: `
border-slate-300 bg-white text-slate-900
hover:border-slate-400
focus:border-primary-500 focus:ring-primary-500/20
dark:border-slate-600 dark:bg-slate-800 dark:text-white
dark:hover:border-slate-500
dark:focus:border-primary-400 dark:focus:ring-primary-400/20
`,
error: `
border-error-500 bg-white text-slate-900
hover:border-error-600
focus:border-error-500 focus:ring-error-500/20
dark:border-error-400 dark:bg-slate-800 dark:text-white
dark:hover:border-error-300
dark:focus:border-error-400 dark:focus:ring-error-400/20
`,
success: `
border-success-500 bg-white text-slate-900
hover:border-success-600
focus:border-success-500 focus:ring-success-500/20
dark:border-success-400 dark:bg-slate-800 dark:text-white
dark:hover:border-success-300
dark:focus:border-success-400 dark:focus:ring-success-400/20
`,
};
/**
* Estilos por tamaño
*/
const sizeStyles: Record<InputSize, string> = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2.5 text-sm',
lg: 'px-4 py-3 text-base',
};
/**
* Estilos del label
*/
const labelStyles = `
block text-sm font-medium text-slate-700
dark:text-slate-200 mb-1.5
`;
/**
* Componente Input
*
* Input reutilizable con soporte para label, error, hint,
* iconos y diferentes tamaños.
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
className,
label,
error,
hint,
success,
size = 'md',
leftIcon,
rightIcon,
fullWidth = true,
type = 'text',
id,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
// Determinar el estado actual
const state = error ? 'error' : success ? 'success' : 'default';
// Determinar si es password y toggle visibility
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
// Calcular padding extra para iconos
const paddingLeft = leftIcon ? 'pl-10' : '';
const paddingRight = rightIcon || isPassword ? 'pr-10' : '';
return (
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
{/* Label */}
{label && (
<label htmlFor={inputId} className={labelStyles}>
{label}
</label>
)}
{/* Input Container */}
<div className="relative">
{/* Left Icon */}
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
{leftIcon}
</span>
)}
{/* Input */}
<input
ref={ref}
id={inputId}
type={inputType}
className={cn(
baseStyles,
stateStyles[state],
sizeStyles[size],
paddingLeft,
paddingRight,
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{/* Right Icon or Password Toggle or State Icon */}
<span className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isPassword ? (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
) : error ? (
<AlertCircle className="h-4 w-4 text-error-500" />
) : success ? (
<CheckCircle className="h-4 w-4 text-success-500" />
) : rightIcon ? (
<span className="text-slate-400 dark:text-slate-500">{rightIcon}</span>
) : null}
</span>
</div>
{/* Error Message */}
{error && (
<p
id={`${inputId}-error`}
className="mt-1.5 text-sm text-error-600 dark:text-error-400"
>
{error}
</p>
)}
{/* Hint */}
{hint && !error && (
<p
id={`${inputId}-hint`}
className="mt-1.5 text-sm text-slate-500 dark:text-slate-400"
>
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
/**
* Componente TextArea
*/
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
fullWidth?: boolean;
}
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, label, error, hint, fullWidth = true, id, ...props }, ref) => {
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const state = error ? 'error' : 'default';
return (
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
{label && (
<label htmlFor={inputId} className={labelStyles}>
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={cn(
baseStyles,
stateStyles[state],
'px-4 py-2.5 text-sm min-h-[100px] resize-y',
className
)}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-error-600 dark:text-error-400">
{error}
</p>
)}
{hint && !error && (
<p id={`${inputId}-hint`} className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
{hint}
</p>
)}
</div>
);
}
);
TextArea.displayName = 'TextArea';
export default Input;

View File

@@ -0,0 +1,15 @@
/**
* UI Components Barrel Export
*
* Re-exporta todos los componentes UI para facilitar imports.
* Ejemplo: import { Button, Input, Card } from '@/components/ui';
*/
export { Button, IconButton } from './Button';
export type { } from './Button';
export { Input, TextArea } from './Input';
export type { } from './Input';
export { Card, CardHeader, CardContent, CardFooter, StatsCard } from './Card';
export type { } from './Card';