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:
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