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,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';