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:
345
apps/web/src/components/layout/Header.tsx
Normal file
345
apps/web/src/components/layout/Header.tsx
Normal 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;
|
||||
306
apps/web/src/components/layout/Sidebar.tsx
Normal file
306
apps/web/src/components/layout/Sidebar.tsx
Normal 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;
|
||||
9
apps/web/src/components/layout/index.ts
Normal file
9
apps/web/src/components/layout/index.ts
Normal 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';
|
||||
215
apps/web/src/components/ui/Button.tsx
Normal file
215
apps/web/src/components/ui/Button.tsx
Normal 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;
|
||||
256
apps/web/src/components/ui/Card.tsx
Normal file
256
apps/web/src/components/ui/Card.tsx
Normal 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;
|
||||
266
apps/web/src/components/ui/Input.tsx
Normal file
266
apps/web/src/components/ui/Input.tsx
Normal 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;
|
||||
15
apps/web/src/components/ui/index.ts
Normal file
15
apps/web/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user