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

32
packages/ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@horux/ui",
"version": "0.1.0",
"private": true,
"description": "UI components for Horux Strategy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"react": "^18.2.0",
"recharts": "^2.12.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"lucide-react": "^0.312.0",
"date-fns": "^3.3.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"eslint": "^8.56.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"react": "^18.2.0"
}
}

View File

@@ -0,0 +1,280 @@
import React from 'react';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Info,
XCircle,
type LucideIcon,
} from 'lucide-react';
import { cn } from '../utils/cn';
// ============================================================================
// Types
// ============================================================================
export type AlertSeverity = 'info' | 'success' | 'warning' | 'critical' | 'error';
export interface AlertBadgeProps {
/** The severity level of the alert */
severity: AlertSeverity;
/** Optional label text */
label?: string;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show icon */
showIcon?: boolean;
/** Make the badge pulsate for critical alerts */
pulse?: boolean;
/** Additional CSS classes */
className?: string;
/** Click handler */
onClick?: () => void;
}
// ============================================================================
// Severity Configuration
// ============================================================================
interface SeverityConfig {
icon: LucideIcon;
bgColor: string;
textColor: string;
borderColor: string;
pulseColor: string;
label: string;
}
const severityConfigs: Record<AlertSeverity, SeverityConfig> = {
info: {
icon: Info,
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
textColor: 'text-blue-700 dark:text-blue-300',
borderColor: 'border-blue-200 dark:border-blue-800',
pulseColor: 'bg-blue-400',
label: 'Info',
},
success: {
icon: CheckCircle,
bgColor: 'bg-green-50 dark:bg-green-900/20',
textColor: 'text-green-700 dark:text-green-300',
borderColor: 'border-green-200 dark:border-green-800',
pulseColor: 'bg-green-400',
label: 'Bueno',
},
warning: {
icon: AlertTriangle,
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
textColor: 'text-yellow-700 dark:text-yellow-300',
borderColor: 'border-yellow-200 dark:border-yellow-800',
pulseColor: 'bg-yellow-400',
label: 'Alerta',
},
critical: {
icon: XCircle,
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-300',
borderColor: 'border-red-200 dark:border-red-800',
pulseColor: 'bg-red-400',
label: 'Critico',
},
error: {
icon: AlertCircle,
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-300',
borderColor: 'border-red-200 dark:border-red-800',
pulseColor: 'bg-red-400',
label: 'Error',
},
};
// ============================================================================
// Size Configuration
// ============================================================================
const sizeConfigs = {
sm: {
padding: 'px-2 py-0.5',
text: 'text-xs',
iconSize: 12,
gap: 'gap-1',
},
md: {
padding: 'px-2.5 py-1',
text: 'text-sm',
iconSize: 14,
gap: 'gap-1.5',
},
lg: {
padding: 'px-3 py-1.5',
text: 'text-base',
iconSize: 16,
gap: 'gap-2',
},
};
// ============================================================================
// Component
// ============================================================================
export function AlertBadge({
severity,
label,
size = 'md',
showIcon = true,
pulse = false,
className,
onClick,
}: AlertBadgeProps): React.ReactElement {
const config = severityConfigs[severity];
const sizeConfig = sizeConfigs[size];
const Icon = config.icon;
const displayLabel = label ?? config.label;
const isClickable = Boolean(onClick);
return (
<span
className={cn(
'inline-flex items-center rounded-full border font-medium',
config.bgColor,
config.textColor,
config.borderColor,
sizeConfig.padding,
sizeConfig.text,
sizeConfig.gap,
isClickable && 'cursor-pointer hover:opacity-80 transition-opacity',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{pulse && (severity === 'critical' || severity === 'error') && (
<span className="relative flex h-2 w-2">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
config.pulseColor
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
config.pulseColor
)}
/>
</span>
)}
{showIcon && !pulse && (
<Icon size={sizeConfig.iconSize} className="flex-shrink-0" />
)}
{displayLabel && <span>{displayLabel}</span>}
</span>
);
}
// ============================================================================
// Status Badge Variant (simpler dot + text)
// ============================================================================
export interface StatusBadgeProps {
status: 'active' | 'inactive' | 'pending' | 'error';
label?: string;
size?: 'sm' | 'md';
className?: string;
}
const statusConfigs = {
active: {
dotColor: 'bg-green-500',
textColor: 'text-green-700 dark:text-green-400',
label: 'Activo',
},
inactive: {
dotColor: 'bg-gray-400',
textColor: 'text-gray-600 dark:text-gray-400',
label: 'Inactivo',
},
pending: {
dotColor: 'bg-yellow-500',
textColor: 'text-yellow-700 dark:text-yellow-400',
label: 'Pendiente',
},
error: {
dotColor: 'bg-red-500',
textColor: 'text-red-700 dark:text-red-400',
label: 'Error',
},
};
export function StatusBadge({
status,
label,
size = 'md',
className,
}: StatusBadgeProps): React.ReactElement {
const config = statusConfigs[status];
const displayLabel = label ?? config.label;
return (
<span
className={cn(
'inline-flex items-center gap-2',
config.textColor,
size === 'sm' ? 'text-xs' : 'text-sm',
className
)}
>
<span
className={cn(
'rounded-full',
config.dotColor,
size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2'
)}
/>
<span className="font-medium">{displayLabel}</span>
</span>
);
}
// ============================================================================
// Notification Badge (for counts)
// ============================================================================
export interface NotificationBadgeProps {
count: number;
maxCount?: number;
severity?: 'default' | 'warning' | 'critical';
className?: string;
}
export function NotificationBadge({
count,
maxCount = 99,
severity = 'default',
className,
}: NotificationBadgeProps): React.ReactElement | null {
if (count <= 0) return null;
const displayCount = count > maxCount ? `${maxCount}+` : count.toString();
const severityStyles = {
default: 'bg-blue-500 text-white',
warning: 'bg-yellow-500 text-white',
critical: 'bg-red-500 text-white',
};
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full text-xs font-bold',
'min-w-[20px] h-5 px-1.5',
severityStyles[severity],
className
)}
>
{displayCount}
</span>
);
}

View File

@@ -0,0 +1,699 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
X,
Filter,
} from 'lucide-react';
import { cn } from '../utils/cn';
import { SkeletonTable } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type SortDirection = 'asc' | 'desc' | null;
export type ColumnAlign = 'left' | 'center' | 'right';
export interface ColumnDef<T> {
/** Unique column identifier */
id: string;
/** Column header label */
header: string;
/** Data accessor key or function */
accessorKey?: keyof T;
accessorFn?: (row: T) => unknown;
/** Custom cell renderer */
cell?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
/** Column alignment */
align?: ColumnAlign;
/** Whether column is sortable */
sortable?: boolean;
/** Whether column is filterable */
filterable?: boolean;
/** Column width */
width?: string | number;
/** Minimum column width */
minWidth?: string | number;
/** Whether to hide on mobile */
hideOnMobile?: boolean;
/** Custom sort function */
sortFn?: (a: T, b: T, direction: SortDirection) => number;
/** Custom filter function */
filterFn?: (row: T, filterValue: string) => boolean;
}
export interface PaginationConfig {
/** Current page (1-indexed) */
page: number;
/** Items per page */
pageSize: number;
/** Total number of items (for server-side pagination) */
totalItems?: number;
/** Available page sizes */
pageSizeOptions?: number[];
/** Callback when page changes */
onPageChange?: (page: number) => void;
/** Callback when page size changes */
onPageSizeChange?: (pageSize: number) => void;
}
export interface DataTableProps<T extends Record<string, unknown>> {
/** Column definitions */
columns: ColumnDef<T>[];
/** Table data */
data: T[];
/** Row key extractor */
getRowId?: (row: T, index: number) => string;
/** Pagination configuration */
pagination?: PaginationConfig;
/** Enable global search */
enableSearch?: boolean;
/** Search placeholder */
searchPlaceholder?: string;
/** Enable column filters */
enableFilters?: boolean;
/** Default sort column */
defaultSortColumn?: string;
/** Default sort direction */
defaultSortDirection?: SortDirection;
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Table title */
title?: string;
/** Table subtitle */
subtitle?: string;
/** Row click handler */
onRowClick?: (row: T, index: number) => void;
/** Selected rows (controlled) */
selectedRows?: Set<string>;
/** Row selection handler */
onRowSelect?: (rowId: string, selected: boolean) => void;
/** Enable row selection */
enableRowSelection?: boolean;
/** Striped rows */
striped?: boolean;
/** Hover effect on rows */
hoverable?: boolean;
/** Compact mode */
compact?: boolean;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Utility Functions
// ============================================================================
function getCellValue<T>(row: T, column: ColumnDef<T>): unknown {
if (column.accessorFn) {
return column.accessorFn(row);
}
if (column.accessorKey) {
return row[column.accessorKey];
}
return null;
}
function defaultSort<T>(
a: T,
b: T,
column: ColumnDef<T>,
direction: SortDirection
): number {
if (!direction) return 0;
const aVal = getCellValue(a, column);
const bVal = getCellValue(b, column);
let comparison = 0;
if (aVal === null || aVal === undefined) comparison = 1;
else if (bVal === null || bVal === undefined) comparison = -1;
else if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal;
} else if (typeof aVal === 'string' && typeof bVal === 'string') {
comparison = aVal.localeCompare(bVal, 'es-MX');
} else if (aVal instanceof Date && bVal instanceof Date) {
comparison = aVal.getTime() - bVal.getTime();
} else {
comparison = String(aVal).localeCompare(String(bVal), 'es-MX');
}
return direction === 'asc' ? comparison : -comparison;
}
function defaultFilter<T>(row: T, column: ColumnDef<T>, filterValue: string): boolean {
const value = getCellValue(row, column);
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(filterValue.toLowerCase());
}
// ============================================================================
// Sub-Components
// ============================================================================
interface SortIconProps {
direction: SortDirection;
}
function SortIcon({ direction }: SortIconProps): React.ReactElement {
if (direction === 'asc') {
return <ChevronUp size={14} className="text-blue-500" />;
}
if (direction === 'desc') {
return <ChevronDown size={14} className="text-blue-500" />;
}
return <ChevronsUpDown size={14} className="text-gray-400" />;
}
interface PaginationProps {
currentPage: number;
pageSize: number;
totalItems: number;
pageSizeOptions: number[];
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
function Pagination({
currentPage,
pageSize,
totalItems,
pageSizeOptions,
onPageChange,
onPageSizeChange,
}: PaginationProps): React.ReactElement {
const totalPages = Math.ceil(totalItems / pageSize);
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const canGoPrev = currentPage > 1;
const canGoNext = currentPage < totalPages;
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{/* Page size selector */}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>Mostrar</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>por pagina</span>
</div>
{/* Info and controls */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
{startItem}-{endItem} de {totalItems}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(1)}
disabled={!canGoPrev}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoPrev && 'opacity-50 cursor-not-allowed'
)}
aria-label="Primera pagina"
>
<ChevronsLeft size={18} />
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrev}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoPrev && 'opacity-50 cursor-not-allowed'
)}
aria-label="Pagina anterior"
>
<ChevronLeft size={18} />
</button>
<span className="px-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoNext && 'opacity-50 cursor-not-allowed'
)}
aria-label="Pagina siguiente"
>
<ChevronRight size={18} />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={!canGoNext}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoNext && 'opacity-50 cursor-not-allowed'
)}
aria-label="Ultima pagina"
>
<ChevronsRight size={18} />
</button>
</div>
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function DataTable<T extends Record<string, unknown>>({
columns,
data,
getRowId,
pagination,
enableSearch = false,
searchPlaceholder = 'Buscar...',
enableFilters = false,
defaultSortColumn,
defaultSortDirection = null,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
title,
subtitle,
onRowClick,
selectedRows,
onRowSelect,
enableRowSelection = false,
striped = false,
hoverable = true,
compact = false,
className,
}: DataTableProps<T>): React.ReactElement {
// State
const [searchQuery, setSearchQuery] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(defaultSortColumn ?? null);
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSortDirection);
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
const [showFilters, setShowFilters] = useState(false);
// Internal pagination state (for client-side pagination)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1);
const [internalPageSize, setInternalPageSize] = useState(pagination?.pageSize ?? 10);
// Effective pagination values
const currentPage = pagination?.page ?? internalPage;
const pageSize = pagination?.pageSize ?? internalPageSize;
const pageSizeOptions = pagination?.pageSizeOptions ?? [10, 25, 50, 100];
// Row ID helper
const getRowIdFn = useCallback(
(row: T, index: number): string => {
if (getRowId) return getRowId(row, index);
if ('id' in row) return String(row.id);
return String(index);
},
[getRowId]
);
// Filter and sort data
const processedData = useMemo(() => {
let result = [...data];
// Apply global search
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((row) =>
columns.some((col) => {
const value = getCellValue(row, col);
return value !== null && String(value).toLowerCase().includes(query);
})
);
}
// Apply column filters
if (Object.keys(columnFilters).length > 0) {
result = result.filter((row) =>
Object.entries(columnFilters).every(([colId, filterValue]) => {
if (!filterValue) return true;
const column = columns.find((c) => c.id === colId);
if (!column) return true;
if (column.filterFn) return column.filterFn(row, filterValue);
return defaultFilter(row, column, filterValue);
})
);
}
// Apply sorting
if (sortColumn && sortDirection) {
const column = columns.find((c) => c.id === sortColumn);
if (column) {
result.sort((a, b) => {
if (column.sortFn) return column.sortFn(a, b, sortDirection);
return defaultSort(a, b, column, sortDirection);
});
}
}
return result;
}, [data, columns, searchQuery, columnFilters, sortColumn, sortDirection]);
// Calculate total items
const totalItems = pagination?.totalItems ?? processedData.length;
// Apply pagination (client-side only if not server-side)
const paginatedData = useMemo(() => {
if (pagination?.totalItems !== undefined) {
// Server-side pagination - data is already paginated
return processedData;
}
// Client-side pagination
const start = (currentPage - 1) * pageSize;
return processedData.slice(start, start + pageSize);
}, [processedData, pagination?.totalItems, currentPage, pageSize]);
// Handlers
const handleSort = useCallback((columnId: string) => {
setSortColumn((prev) => {
if (prev !== columnId) {
setSortDirection('asc');
return columnId;
}
setSortDirection((dir) => {
if (dir === 'asc') return 'desc';
if (dir === 'desc') return null;
return 'asc';
});
return columnId;
});
}, []);
const handlePageChange = useCallback(
(page: number) => {
if (pagination?.onPageChange) {
pagination.onPageChange(page);
} else {
setInternalPage(page);
}
},
[pagination]
);
const handlePageSizeChange = useCallback(
(size: number) => {
if (pagination?.onPageSizeChange) {
pagination.onPageSizeChange(size);
} else {
setInternalPageSize(size);
setInternalPage(1);
}
},
[pagination]
);
const handleColumnFilterChange = useCallback((columnId: string, value: string) => {
setColumnFilters((prev) => ({
...prev,
[columnId]: value,
}));
setInternalPage(1);
}, []);
const clearFilters = useCallback(() => {
setColumnFilters({});
setSearchQuery('');
setInternalPage(1);
}, []);
const hasActiveFilters = searchQuery || Object.values(columnFilters).some(Boolean);
if (isLoading) {
return (
<SkeletonTable
rows={pageSize}
columns={columns.length}
className={className}
/>
);
}
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle || enableSearch || enableFilters) && (
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* Title */}
{(title || subtitle) && (
<div>
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Search and filters */}
<div className="flex items-center gap-2">
{enableSearch && (
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setInternalPage(1);
}}
placeholder={searchPlaceholder}
className="w-full sm:w-64 rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
/>
</div>
)}
{enableFilters && (
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors',
showFilters || hasActiveFilters
? 'border-blue-500 bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
: 'border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<Filter size={16} />
<span>Filtros</span>
</button>
)}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<X size={14} />
<span>Limpiar</span>
</button>
)}
</div>
</div>
{/* Column filters */}
{showFilters && enableFilters && (
<div className="mt-3 flex flex-wrap gap-2">
{columns
.filter((col) => col.filterable !== false)
.map((column) => (
<div key={column.id} className="flex-shrink-0">
<input
type="text"
value={columnFilters[column.id] || ''}
onChange={(e) =>
handleColumnFilterChange(column.id, e.target.value)
}
placeholder={column.header}
className="w-32 rounded border border-gray-300 bg-white px-2 py-1 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
))}
</div>
)}
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
{enableRowSelection && (
<th className="w-10 px-4 py-3">
<input
type="checkbox"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
onChange={(e) => {
paginatedData.forEach((row, index) => {
const rowId = getRowIdFn(row, index);
onRowSelect?.(rowId, e.target.checked);
});
}}
/>
</th>
)}
{columns.map((column) => (
<th
key={column.id}
className={cn(
'px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400',
compact ? 'py-2' : 'py-3',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.hideOnMobile && 'hidden md:table-cell',
column.sortable !== false && 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-300'
)}
style={{
width: column.width,
minWidth: column.minWidth,
}}
onClick={() => column.sortable !== false && handleSort(column.id)}
>
<div
className={cn(
'flex items-center gap-1',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end'
)}
>
<span>{column.header}</span>
{column.sortable !== false && (
<SortIcon
direction={sortColumn === column.id ? sortDirection : null}
/>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{paginatedData.length === 0 ? (
<tr>
<td
colSpan={columns.length + (enableRowSelection ? 1 : 0)}
className="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
>
{emptyMessage}
</td>
</tr>
) : (
paginatedData.map((row, rowIndex) => {
const rowId = getRowIdFn(row, rowIndex);
const isSelected = selectedRows?.has(rowId);
return (
<tr
key={rowId}
className={cn(
'transition-colors',
striped && rowIndex % 2 === 1 && 'bg-gray-50 dark:bg-gray-800/30',
hoverable && 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
onRowClick && 'cursor-pointer',
isSelected && 'bg-blue-50 dark:bg-blue-900/20'
)}
onClick={() => onRowClick?.(row, rowIndex)}
>
{enableRowSelection && (
<td className="w-10 px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onRowSelect?.(rowId, e.target.checked);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
</td>
)}
{columns.map((column) => {
const value = getCellValue(row, column);
const displayValue = column.cell
? column.cell(value, row, rowIndex)
: value !== null && value !== undefined
? String(value)
: '-';
return (
<td
key={column.id}
className={cn(
'px-4 text-sm text-gray-900 dark:text-gray-100',
compact ? 'py-2' : 'py-3',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.hideOnMobile && 'hidden md:table-cell'
)}
style={{
width: column.width,
minWidth: column.minWidth,
}}
>
{displayValue}
</td>
);
})}
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination !== undefined && totalItems > 0 && (
<Pagination
currentPage={currentPage}
pageSize={pageSize}
totalItems={totalItems}
pageSizeOptions={pageSizeOptions}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,400 @@
import React, { useMemo } from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { cn } from '../utils/cn';
import { SkeletonKPICard } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type ValueFormat = 'currency' | 'percent' | 'number' | 'compact';
export interface SparklineDataPoint {
value: number;
}
export interface KPICardProps {
/** Title of the KPI */
title: string;
/** Current value */
value: number;
/** Previous period value for comparison */
previousValue?: number;
/** Format to display the value */
format?: ValueFormat;
/** Currency code for currency format (default: MXN) */
currency?: string;
/** Number of decimal places */
decimals?: number;
/** Optional prefix (e.g., "$") */
prefix?: string;
/** Optional suffix (e.g., "%", "users") */
suffix?: string;
/** Sparkline data points */
sparklineData?: SparklineDataPoint[];
/** Whether the card is loading */
isLoading?: boolean;
/** Invert the color logic (lower is better) */
invertColors?: boolean;
/** Optional icon component */
icon?: React.ReactNode;
/** Additional CSS classes */
className?: string;
/** Period label (e.g., "vs mes anterior") */
periodLabel?: string;
/** Click handler */
onClick?: () => void;
}
// ============================================================================
// Formatting Utilities
// ============================================================================
function formatValue(
value: number,
format: ValueFormat,
options: {
currency?: string;
decimals?: number;
prefix?: string;
suffix?: string;
} = {}
): string {
const { currency = 'MXN', decimals, prefix = '', suffix = '' } = options;
let formatted: string;
switch (format) {
case 'currency':
formatted = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 0,
}).format(value);
break;
case 'percent':
formatted = new Intl.NumberFormat('es-MX', {
style: 'percent',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value / 100);
break;
case 'compact':
formatted = new Intl.NumberFormat('es-MX', {
notation: 'compact',
compactDisplay: 'short',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value);
break;
case 'number':
default:
formatted = new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 2,
}).format(value);
break;
}
return `${prefix}${formatted}${suffix}`;
}
function calculateVariation(
current: number,
previous: number
): { percentage: number; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) {
return { percentage: 0, direction: 'neutral' };
}
const percentage = ((current - previous) / Math.abs(previous)) * 100;
if (Math.abs(percentage) < 0.1) {
return { percentage: 0, direction: 'neutral' };
}
return {
percentage,
direction: percentage > 0 ? 'up' : 'down',
};
}
// ============================================================================
// Mini Sparkline Component
// ============================================================================
interface SparklineProps {
data: SparklineDataPoint[];
width?: number;
height?: number;
strokeColor?: string;
strokeWidth?: number;
className?: string;
}
function Sparkline({
data,
width = 80,
height = 32,
strokeColor,
strokeWidth = 2,
className,
}: SparklineProps): React.ReactElement | null {
const pathD = useMemo(() => {
if (data.length < 2) return null;
const values = data.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const padding = 2;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const points = values.map((value, index) => {
const x = padding + (index / (values.length - 1)) * chartWidth;
const y = padding + chartHeight - ((value - min) / range) * chartHeight;
return { x, y };
});
return points
.map((point, i) => `${i === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
}, [data, width, height]);
if (!pathD) return null;
// Determine color based on trend
const trend = data[data.length - 1].value >= data[0].value;
const color = strokeColor ?? (trend ? '#10B981' : '#EF4444');
return (
<svg
width={width}
height={height}
className={cn('overflow-visible', className)}
viewBox={`0 0 ${width} ${height}`}
>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// ============================================================================
// Variation Badge Component
// ============================================================================
interface VariationBadgeProps {
percentage: number;
direction: 'up' | 'down' | 'neutral';
invertColors?: boolean;
periodLabel?: string;
}
function VariationBadge({
percentage,
direction,
invertColors = false,
periodLabel,
}: VariationBadgeProps): React.ReactElement {
const isPositive = direction === 'up';
const isNeutral = direction === 'neutral';
// Determine if this change is "good" or "bad"
const isGood = invertColors ? !isPositive : isPositive;
const colorClasses = isNeutral
? 'text-gray-500 bg-gray-100 dark:bg-gray-700 dark:text-gray-400'
: isGood
? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400'
: 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400';
const Icon = isNeutral ? Minus : isPositive ? TrendingUp : TrendingDown;
return (
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-sm font-medium',
colorClasses
)}
>
<Icon size={14} className="flex-shrink-0" />
<span>{Math.abs(percentage).toFixed(1)}%</span>
</span>
{periodLabel && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{periodLabel}
</span>
)}
</div>
);
}
// ============================================================================
// Main KPICard Component
// ============================================================================
export function KPICard({
title,
value,
previousValue,
format = 'number',
currency = 'MXN',
decimals,
prefix,
suffix,
sparklineData,
isLoading = false,
invertColors = false,
icon,
className,
periodLabel = 'vs periodo anterior',
onClick,
}: KPICardProps): React.ReactElement {
// Calculate variation if previous value is provided
const variation = useMemo(() => {
if (previousValue === undefined) return null;
return calculateVariation(value, previousValue);
}, [value, previousValue]);
// Format the display value
const formattedValue = useMemo(() => {
return formatValue(value, format, { currency, decimals, prefix, suffix });
}, [value, format, currency, decimals, prefix, suffix]);
if (isLoading) {
return <SkeletonKPICard showSparkline={Boolean(sparklineData)} className={className} />;
}
const isClickable = Boolean(onClick);
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all',
'dark:border-gray-700 dark:bg-gray-800',
isClickable && 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Title with optional icon */}
<div className="flex items-center gap-2 mb-1">
{icon && (
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">
{icon}
</span>
)}
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
{title}
</h3>
</div>
{/* Main Value */}
<p className="text-2xl font-bold text-gray-900 dark:text-white mb-2 truncate">
{formattedValue}
</p>
{/* Variation Badge */}
{variation && (
<VariationBadge
percentage={variation.percentage}
direction={variation.direction}
invertColors={invertColors}
periodLabel={periodLabel}
/>
)}
</div>
{/* Sparkline */}
{sparklineData && sparklineData.length > 1 && (
<div className="ml-4 flex-shrink-0">
<Sparkline data={sparklineData} />
</div>
)}
</div>
</div>
);
}
// ============================================================================
// Compact KPI Card Variant
// ============================================================================
export interface CompactKPICardProps {
title: string;
value: number;
format?: ValueFormat;
icon?: React.ReactNode;
trend?: 'up' | 'down' | 'neutral';
className?: string;
}
export function CompactKPICard({
title,
value,
format = 'number',
icon,
trend,
className,
}: CompactKPICardProps): React.ReactElement {
const formattedValue = formatValue(value, format, {});
const trendColors = {
up: 'text-green-500',
down: 'text-red-500',
neutral: 'text-gray-400',
};
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50',
className
)}
>
{icon && (
<div
className={cn(
'flex-shrink-0',
trend ? trendColors[trend] : 'text-gray-400'
)}
>
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{title}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{formattedValue}
</p>
</div>
{trend && (
<div className={cn('flex-shrink-0', trendColors[trend])}>
{trend === 'up' && <TrendingUp size={16} />}
{trend === 'down' && <TrendingDown size={16} />}
{trend === 'neutral' && <Minus size={16} />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
Minus,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { cn } from '../utils/cn';
import { AlertBadge, type AlertSeverity } from './AlertBadge';
import { SkeletonCard } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type MetricStatus = 'good' | 'warning' | 'critical' | 'neutral';
export type MetricTrend = 'up' | 'down' | 'stable';
export type MetricFormat = 'currency' | 'percent' | 'number' | 'compact' | 'days';
export interface MetricValue {
current: number;
previous?: number;
target?: number;
}
export interface MetricPeriod {
label: string;
startDate?: Date;
endDate?: Date;
}
export interface MetricComparison {
type: 'previous_period' | 'previous_year' | 'target' | 'budget';
value: number;
label: string;
}
export interface MetricCardProps {
/** Metric name/title */
title: string;
/** Description or subtitle */
description?: string;
/** Metric values */
metric: MetricValue;
/** Current period */
period?: MetricPeriod;
/** Comparison data */
comparison?: MetricComparison;
/** Value format */
format?: MetricFormat;
/** Currency code */
currency?: string;
/** Number of decimal places */
decimals?: number;
/** Status thresholds - automatically determines status */
thresholds?: {
good: number;
warning: number;
};
/** Override automatic status */
status?: MetricStatus;
/** Invert threshold logic (lower is better) */
invertThresholds?: boolean;
/** Icon to display */
icon?: LucideIcon;
/** Loading state */
isLoading?: boolean;
/** Click handler */
onClick?: () => void;
/** Link to detailed view */
detailsLink?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Formatting Utilities
// ============================================================================
function formatMetricValue(
value: number,
format: MetricFormat,
options: { currency?: string; decimals?: number } = {}
): string {
const { currency = 'MXN', decimals } = options;
switch (format) {
case 'currency':
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 0,
}).format(value);
case 'percent':
return new Intl.NumberFormat('es-MX', {
style: 'percent',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value / 100);
case 'compact':
return new Intl.NumberFormat('es-MX', {
notation: 'compact',
compactDisplay: 'short',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value);
case 'days':
return `${value.toFixed(decimals ?? 0)} dias`;
case 'number':
default:
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 2,
}).format(value);
}
}
function calculateTrend(current: number, previous?: number): MetricTrend {
if (previous === undefined) return 'stable';
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
if (Math.abs(change) < 1) return 'stable';
return change > 0 ? 'up' : 'down';
}
function calculateVariationPercent(current: number, previous: number): number {
if (previous === 0) return 0;
return ((current - previous) / Math.abs(previous)) * 100;
}
function determineStatus(
value: number,
thresholds?: { good: number; warning: number },
invert: boolean = false
): MetricStatus {
if (!thresholds) return 'neutral';
if (invert) {
// Lower is better (e.g., DSO, costs)
if (value <= thresholds.good) return 'good';
if (value <= thresholds.warning) return 'warning';
return 'critical';
} else {
// Higher is better (e.g., revenue, margins)
if (value >= thresholds.good) return 'good';
if (value >= thresholds.warning) return 'warning';
return 'critical';
}
}
function statusToSeverity(status: MetricStatus): AlertSeverity {
switch (status) {
case 'good':
return 'success';
case 'warning':
return 'warning';
case 'critical':
return 'critical';
default:
return 'info';
}
}
// ============================================================================
// Sub-components
// ============================================================================
interface TrendIndicatorProps {
trend: MetricTrend;
percentage: number;
invertColors?: boolean;
}
function TrendIndicator({
trend,
percentage,
invertColors = false,
}: TrendIndicatorProps): React.ReactElement {
const isPositive = trend === 'up';
const isNeutral = trend === 'stable';
const isGood = invertColors ? !isPositive : isPositive;
const colorClass = isNeutral
? 'text-gray-500'
: isGood
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
const Icon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
return (
<div className={cn('flex items-center gap-1 text-sm font-medium', colorClass)}>
<Icon size={16} />
<span>
{trend !== 'stable' && (trend === 'up' ? '+' : '')}
{percentage.toFixed(1)}%
</span>
</div>
);
}
interface TargetProgressProps {
current: number;
target: number;
format: MetricFormat;
currency?: string;
}
function TargetProgress({
current,
target,
format,
currency,
}: TargetProgressProps): React.ReactElement {
const progress = Math.min((current / target) * 100, 100);
const isOnTrack = progress >= 80;
const isAhead = current >= target;
return (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Objetivo: {formatMetricValue(target, format, { currency })}</span>
<span>{progress.toFixed(0)}%</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
isAhead
? 'bg-green-500'
: isOnTrack
? 'bg-blue-500'
: 'bg-yellow-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
// ============================================================================
// Main MetricCard Component
// ============================================================================
export function MetricCard({
title,
description,
metric,
period,
comparison,
format = 'number',
currency = 'MXN',
decimals,
thresholds,
status: statusOverride,
invertThresholds = false,
icon: Icon,
isLoading = false,
onClick,
detailsLink,
className,
}: MetricCardProps): React.ReactElement {
// Calculate derived values
const trend = useMemo(
() => calculateTrend(metric.current, metric.previous),
[metric.current, metric.previous]
);
const variationPercent = useMemo(() => {
if (metric.previous === undefined) return 0;
return calculateVariationPercent(metric.current, metric.previous);
}, [metric.current, metric.previous]);
const status = useMemo(() => {
if (statusOverride) return statusOverride;
return determineStatus(metric.current, thresholds, invertThresholds);
}, [metric.current, thresholds, invertThresholds, statusOverride]);
const formattedValue = useMemo(
() => formatMetricValue(metric.current, format, { currency, decimals }),
[metric.current, format, currency, decimals]
);
if (isLoading) {
return <SkeletonCard className={className} showHeader lines={4} />;
}
const isClickable = Boolean(onClick || detailsLink);
return (
<div
className={cn(
'rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-all',
'dark:border-gray-700 dark:bg-gray-800',
isClickable &&
'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{Icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<Icon size={20} className="text-gray-600 dark:text-gray-300" />
</div>
)}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{title}
</h3>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{description}
</p>
)}
</div>
</div>
{status !== 'neutral' && (
<AlertBadge
severity={statusToSeverity(status)}
size="sm"
label={status === 'good' ? 'Bueno' : status === 'warning' ? 'Alerta' : 'Critico'}
/>
)}
</div>
{/* Main Value */}
<div className="mb-3">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{formattedValue}
</p>
{period && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{period.label}
</p>
)}
</div>
{/* Trend & Comparison */}
<div className="flex items-center justify-between">
{metric.previous !== undefined && (
<TrendIndicator
trend={trend}
percentage={variationPercent}
invertColors={invertThresholds}
/>
)}
{comparison && (
<div className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium">
{formatMetricValue(comparison.value, format, { currency })}
</span>
<span className="ml-1">{comparison.label}</span>
</div>
)}
</div>
{/* Target Progress */}
{metric.target !== undefined && (
<TargetProgress
current={metric.current}
target={metric.target}
format={format}
currency={currency}
/>
)}
{/* Details Link */}
{detailsLink && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<a
href={detailsLink}
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
onClick={(e) => e.stopPropagation()}
>
Ver detalles
<ArrowRight size={14} />
</a>
</div>
)}
</div>
);
}
// ============================================================================
// Metric Card Grid Component
// ============================================================================
export interface MetricCardGridProps {
children: React.ReactNode;
columns?: 2 | 3 | 4;
className?: string;
}
export function MetricCardGrid({
children,
columns = 3,
className,
}: MetricCardGridProps): React.ReactElement {
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
return (
<div className={cn('grid gap-4', gridCols[columns], className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,673 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
Check,
} from 'lucide-react';
import { cn } from '../utils/cn';
// ============================================================================
// Types
// ============================================================================
export type PeriodType = 'month' | 'quarter' | 'year' | 'custom';
export type ComparisonType =
| 'previous_period'
| 'previous_year'
| 'previous_quarter'
| 'budget'
| 'none';
export interface DateRange {
startDate: Date;
endDate: Date;
}
export interface PeriodValue {
type: PeriodType;
year: number;
month?: number; // 1-12
quarter?: number; // 1-4
customRange?: DateRange;
}
export interface PeriodSelectorProps {
/** Current selected period */
value: PeriodValue;
/** Period change handler */
onChange: (value: PeriodValue) => void;
/** Comparison type */
comparisonType?: ComparisonType;
/** Comparison type change handler */
onComparisonChange?: (type: ComparisonType) => void;
/** Available period types */
availablePeriodTypes?: PeriodType[];
/** Show comparison selector */
showComparison?: boolean;
/** Minimum selectable date */
minDate?: Date;
/** Maximum selectable date */
maxDate?: Date;
/** Locale for formatting */
locale?: string;
/** Additional CSS classes */
className?: string;
/** Compact mode */
compact?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const MONTHS_ES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
const QUARTERS_ES = ['Q1 (Ene-Mar)', 'Q2 (Abr-Jun)', 'Q3 (Jul-Sep)', 'Q4 (Oct-Dic)'];
const PERIOD_TYPE_LABELS: Record<PeriodType, string> = {
month: 'Mes',
quarter: 'Trimestre',
year: 'Anio',
custom: 'Personalizado',
};
const COMPARISON_LABELS: Record<ComparisonType, string> = {
previous_period: 'Periodo anterior',
previous_year: 'Mismo periodo anio anterior',
previous_quarter: 'Trimestre anterior',
budget: 'Presupuesto',
none: 'Sin comparacion',
};
// ============================================================================
// Helper Functions
// ============================================================================
function formatPeriodLabel(value: PeriodValue): string {
switch (value.type) {
case 'month':
return `${MONTHS_ES[(value.month ?? 1) - 1]} ${value.year}`;
case 'quarter':
return `Q${value.quarter} ${value.year}`;
case 'year':
return `${value.year}`;
case 'custom':
if (value.customRange) {
const start = value.customRange.startDate.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
});
const end = value.customRange.endDate.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${start} - ${end}`;
}
return 'Seleccionar fechas';
default:
return '';
}
}
function getQuarterMonths(quarter: number): number[] {
const startMonth = (quarter - 1) * 3 + 1;
return [startMonth, startMonth + 1, startMonth + 2];
}
// ============================================================================
// Sub-Components
// ============================================================================
interface DropdownProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function Dropdown({ isOpen, onClose, children, className }: DropdownProps): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={ref}
className={cn(
'absolute top-full left-0 z-50 mt-1 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{children}
</div>
);
}
interface MonthPickerProps {
year: number;
selectedMonth: number;
onSelect: (month: number, year: number) => void;
onYearChange: (year: number) => void;
minDate?: Date;
maxDate?: Date;
}
function MonthPicker({
year,
selectedMonth,
onSelect,
onYearChange,
minDate,
maxDate,
}: MonthPickerProps): React.ReactElement {
const isMonthDisabled = (month: number): boolean => {
const date = new Date(year, month - 1, 1);
if (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) {
return true;
}
if (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) {
return true;
}
return false;
};
return (
<div className="p-3">
{/* Year navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => onYearChange(year - 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-gray-900 dark:text-white">{year}</span>
<button
onClick={() => onYearChange(year + 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronRight size={18} />
</button>
</div>
{/* Month grid */}
<div className="grid grid-cols-3 gap-1">
{MONTHS_ES.map((month, index) => {
const monthNum = index + 1;
const isSelected = monthNum === selectedMonth;
const isDisabled = isMonthDisabled(monthNum);
return (
<button
key={month}
onClick={() => !isDisabled && onSelect(monthNum, year)}
disabled={isDisabled}
className={cn(
'px-2 py-2 text-sm rounded transition-colors',
isSelected
? 'bg-blue-500 text-white'
: isDisabled
? 'text-gray-300 cursor-not-allowed dark:text-gray-600'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{month.slice(0, 3)}
</button>
);
})}
</div>
</div>
);
}
interface QuarterPickerProps {
year: number;
selectedQuarter: number;
onSelect: (quarter: number, year: number) => void;
onYearChange: (year: number) => void;
}
function QuarterPicker({
year,
selectedQuarter,
onSelect,
onYearChange,
}: QuarterPickerProps): React.ReactElement {
return (
<div className="p-3">
{/* Year navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => onYearChange(year - 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-gray-900 dark:text-white">{year}</span>
<button
onClick={() => onYearChange(year + 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronRight size={18} />
</button>
</div>
{/* Quarter buttons */}
<div className="space-y-1">
{QUARTERS_ES.map((quarter, index) => {
const quarterNum = index + 1;
const isSelected = quarterNum === selectedQuarter;
return (
<button
key={quarter}
onClick={() => onSelect(quarterNum, year)}
className={cn(
'w-full px-3 py-2 text-sm rounded text-left transition-colors',
isSelected
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{quarter}
</button>
);
})}
</div>
</div>
);
}
interface YearPickerProps {
selectedYear: number;
onSelect: (year: number) => void;
minYear?: number;
maxYear?: number;
}
function YearPicker({
selectedYear,
onSelect,
minYear = 2020,
maxYear,
}: YearPickerProps): React.ReactElement {
const currentYear = new Date().getFullYear();
const max = maxYear ?? currentYear + 1;
const years = Array.from({ length: max - minYear + 1 }, (_, i) => max - i);
return (
<div className="p-3 max-h-64 overflow-y-auto">
<div className="space-y-1">
{years.map((year) => {
const isSelected = year === selectedYear;
return (
<button
key={year}
onClick={() => onSelect(year)}
className={cn(
'w-full px-3 py-2 text-sm rounded text-left transition-colors flex items-center justify-between',
isSelected
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<span>{year}</span>
{isSelected && <Check size={16} />}
</button>
);
})}
</div>
</div>
);
}
interface DateRangePickerProps {
value?: DateRange;
onChange: (range: DateRange) => void;
}
function DateRangePicker({ value, onChange }: DateRangePickerProps): React.ReactElement {
const [startDate, setStartDate] = useState(
value?.startDate?.toISOString().split('T')[0] ?? ''
);
const [endDate, setEndDate] = useState(
value?.endDate?.toISOString().split('T')[0] ?? ''
);
const handleApply = () => {
if (startDate && endDate) {
onChange({
startDate: new Date(startDate),
endDate: new Date(endDate),
});
}
};
return (
<div className="p-3 space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Fecha inicio
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Fecha fin
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<button
onClick={handleApply}
disabled={!startDate || !endDate}
className={cn(
'w-full rounded bg-blue-500 px-3 py-2 text-sm font-medium text-white transition-colors',
!startDate || !endDate
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-blue-600'
)}
>
Aplicar
</button>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function PeriodSelector({
value,
onChange,
comparisonType = 'none',
onComparisonChange,
availablePeriodTypes = ['month', 'quarter', 'year'],
showComparison = true,
minDate,
maxDate,
className,
compact = false,
}: PeriodSelectorProps): React.ReactElement {
const [isPeriodOpen, setIsPeriodOpen] = useState(false);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [tempYear, setTempYear] = useState(value.year);
const handlePeriodTypeChange = useCallback(
(type: PeriodType) => {
const newValue: PeriodValue = { ...value, type };
if (type === 'month' && !value.month) {
newValue.month = new Date().getMonth() + 1;
}
if (type === 'quarter' && !value.quarter) {
newValue.quarter = Math.ceil((new Date().getMonth() + 1) / 3);
}
onChange(newValue);
},
[value, onChange]
);
const handleMonthSelect = useCallback(
(month: number, year: number) => {
onChange({ ...value, type: 'month', month, year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleQuarterSelect = useCallback(
(quarter: number, year: number) => {
onChange({ ...value, type: 'quarter', quarter, year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleYearSelect = useCallback(
(year: number) => {
onChange({ ...value, type: 'year', year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleCustomRangeSelect = useCallback(
(range: DateRange) => {
onChange({ ...value, type: 'custom', customRange: range });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleComparisonSelect = useCallback(
(type: ComparisonType) => {
onComparisonChange?.(type);
setIsComparisonOpen(false);
},
[onComparisonChange]
);
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{/* Period Type Tabs */}
{availablePeriodTypes.length > 1 && (
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{availablePeriodTypes.map((type) => (
<button
key={type}
onClick={() => handlePeriodTypeChange(type)}
className={cn(
'px-3 py-1.5 text-sm font-medium transition-colors',
value.type === type
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{PERIOD_TYPE_LABELS[type]}
</button>
))}
</div>
)}
{/* Period Selector */}
<div className="relative">
<button
onClick={() => setIsPeriodOpen(!isPeriodOpen)}
className={cn(
'flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
compact && 'px-2 py-1.5'
)}
>
<Calendar size={16} className="text-gray-400" />
<span>{formatPeriodLabel(value)}</span>
<ChevronDown size={14} className="text-gray-400" />
</button>
<Dropdown
isOpen={isPeriodOpen}
onClose={() => setIsPeriodOpen(false)}
className="min-w-[240px]"
>
{value.type === 'month' && (
<MonthPicker
year={tempYear}
selectedMonth={value.month ?? 1}
onSelect={handleMonthSelect}
onYearChange={setTempYear}
minDate={minDate}
maxDate={maxDate}
/>
)}
{value.type === 'quarter' && (
<QuarterPicker
year={tempYear}
selectedQuarter={value.quarter ?? 1}
onSelect={handleQuarterSelect}
onYearChange={setTempYear}
/>
)}
{value.type === 'year' && (
<YearPicker
selectedYear={value.year}
onSelect={handleYearSelect}
minYear={minDate?.getFullYear()}
maxYear={maxDate?.getFullYear()}
/>
)}
{value.type === 'custom' && (
<DateRangePicker
value={value.customRange}
onChange={handleCustomRangeSelect}
/>
)}
</Dropdown>
</div>
{/* Comparison Selector */}
{showComparison && onComparisonChange && (
<div className="relative">
<button
onClick={() => setIsComparisonOpen(!isComparisonOpen)}
className={cn(
'flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
compact && 'px-2 py-1.5',
comparisonType !== 'none' && 'border-blue-300 bg-blue-50 text-blue-600 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
)}
>
<span>vs {COMPARISON_LABELS[comparisonType]}</span>
<ChevronDown size={14} />
</button>
<Dropdown
isOpen={isComparisonOpen}
onClose={() => setIsComparisonOpen(false)}
className="min-w-[200px]"
>
<div className="p-2 space-y-1">
{(Object.entries(COMPARISON_LABELS) as [ComparisonType, string][]).map(
([type, label]) => (
<button
key={type}
onClick={() => handleComparisonSelect(type)}
className={cn(
'w-full flex items-center justify-between px-3 py-2 text-sm rounded transition-colors',
comparisonType === type
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<span>{label}</span>
{comparisonType === type && <Check size={14} />}
</button>
)
)}
</div>
</Dropdown>
</div>
)}
</div>
);
}
// ============================================================================
// Quick Period Selector (Preset buttons)
// ============================================================================
export interface QuickPeriodSelectorProps {
onSelect: (value: PeriodValue) => void;
className?: string;
}
export function QuickPeriodSelector({
onSelect,
className,
}: QuickPeriodSelectorProps): React.ReactElement {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
const currentQuarter = Math.ceil(currentMonth / 3);
const presets = [
{
label: 'Este mes',
value: { type: 'month' as const, year: currentYear, month: currentMonth },
},
{
label: 'Mes anterior',
value: {
type: 'month' as const,
year: currentMonth === 1 ? currentYear - 1 : currentYear,
month: currentMonth === 1 ? 12 : currentMonth - 1,
},
},
{
label: 'Este trimestre',
value: { type: 'quarter' as const, year: currentYear, quarter: currentQuarter },
},
{
label: 'Este anio',
value: { type: 'year' as const, year: currentYear },
},
{
label: 'Anio anterior',
value: { type: 'year' as const, year: currentYear - 1 },
},
];
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{presets.map((preset) => (
<button
key={preset.label}
onClick={() => onSelect(preset.value)}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{preset.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,350 @@
import React from 'react';
import { cn } from '../utils/cn';
// ============================================================================
// Base Skeleton Component
// ============================================================================
interface SkeletonProps {
className?: string;
variant?: 'rectangular' | 'circular' | 'text';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'shimmer' | 'none';
}
export function Skeleton({
className,
variant = 'rectangular',
width,
height,
animation = 'pulse',
}: SkeletonProps): React.ReactElement {
const baseStyles = 'bg-gray-200 dark:bg-gray-700';
const variantStyles = {
rectangular: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4 w-full',
};
const animationStyles = {
pulse: 'animate-pulse',
shimmer:
'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent',
none: '',
};
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={cn(
baseStyles,
variantStyles[variant],
animationStyles[animation],
className
)}
style={style}
aria-hidden="true"
/>
);
}
// ============================================================================
// Skeleton Card
// ============================================================================
interface SkeletonCardProps {
className?: string;
showHeader?: boolean;
showFooter?: boolean;
lines?: number;
}
export function SkeletonCard({
className,
showHeader = true,
showFooter = false,
lines = 3,
}: SkeletonCardProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{showHeader && (
<div className="mb-4 flex items-center gap-3">
<Skeleton variant="circular" width={40} height={40} />
<div className="flex-1 space-y-2">
<Skeleton height={16} width="60%" />
<Skeleton height={12} width="40%" />
</div>
</div>
)}
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
height={14}
width={i === lines - 1 ? '70%' : '100%'}
/>
))}
</div>
{showFooter && (
<div className="mt-4 flex gap-2">
<Skeleton height={32} width={80} />
<Skeleton height={32} width={80} />
</div>
)}
</div>
);
}
// ============================================================================
// Skeleton KPI Card
// ============================================================================
interface SkeletonKPICardProps {
className?: string;
showSparkline?: boolean;
}
export function SkeletonKPICard({
className,
showSparkline = false,
}: SkeletonKPICardProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<Skeleton height={14} width={100} className="mb-2" />
<Skeleton height={32} width={120} className="mb-3" />
<div className="flex items-center gap-2">
<Skeleton height={20} width={60} />
<Skeleton height={14} width={80} />
</div>
</div>
{showSparkline && (
<Skeleton height={40} width={80} className="ml-4" />
)}
</div>
</div>
);
}
// ============================================================================
// Skeleton Table
// ============================================================================
interface SkeletonTableProps {
className?: string;
rows?: number;
columns?: number;
}
export function SkeletonTable({
className,
rows = 5,
columns = 4,
}: SkeletonTableProps): React.ReactElement {
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700',
className
)}
>
{/* Header */}
<div className="flex gap-4 border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} height={14} className="flex-1" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div
key={rowIndex}
className="flex gap-4 border-b border-gray-100 p-4 last:border-0 dark:border-gray-700"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={colIndex}
height={14}
className="flex-1"
width={colIndex === 0 ? '80%' : undefined}
/>
))}
</div>
))}
</div>
);
}
// ============================================================================
// Skeleton Chart
// ============================================================================
interface SkeletonChartProps {
className?: string;
type?: 'line' | 'bar' | 'pie' | 'area';
height?: number;
}
export function SkeletonChart({
className,
type = 'line',
height = 300,
}: SkeletonChartProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="mb-4 flex items-center justify-between">
<Skeleton height={20} width={150} />
<Skeleton height={32} width={120} />
</div>
<div
className="flex items-end justify-around gap-2"
style={{ height }}
>
{type === 'pie' ? (
<div className="flex items-center justify-center flex-1">
<Skeleton variant="circular" width={200} height={200} />
</div>
) : type === 'bar' ? (
Array.from({ length: 8 }).map((_, i) => (
<Skeleton
key={i}
className="flex-1"
height={`${Math.random() * 60 + 40}%`}
/>
))
) : (
<div className="relative w-full h-full">
<Skeleton height="100%" className="opacity-30" />
<div className="absolute inset-0 flex items-center justify-center">
<Skeleton height={2} width="90%" />
</div>
</div>
)}
</div>
{/* Legend */}
<div className="mt-4 flex justify-center gap-6">
<div className="flex items-center gap-2">
<Skeleton variant="circular" width={12} height={12} />
<Skeleton height={12} width={60} />
</div>
<div className="flex items-center gap-2">
<Skeleton variant="circular" width={12} height={12} />
<Skeleton height={12} width={60} />
</div>
</div>
</div>
);
}
// ============================================================================
// Skeleton Text Block
// ============================================================================
interface SkeletonTextProps {
className?: string;
lines?: number;
lastLineWidth?: string;
}
export function SkeletonText({
className,
lines = 3,
lastLineWidth = '60%',
}: SkeletonTextProps): React.ReactElement {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === lines - 1 ? lastLineWidth : '100%'}
/>
))}
</div>
);
}
// ============================================================================
// Skeleton Avatar
// ============================================================================
interface SkeletonAvatarProps {
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function SkeletonAvatar({
className,
size = 'md',
}: SkeletonAvatarProps): React.ReactElement {
const sizes = {
sm: 32,
md: 40,
lg: 48,
xl: 64,
};
return (
<Skeleton
variant="circular"
width={sizes[size]}
height={sizes[size]}
className={className}
/>
);
}
// ============================================================================
// Skeleton Button
// ============================================================================
interface SkeletonButtonProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export function SkeletonButton({
className,
size = 'md',
fullWidth = false,
}: SkeletonButtonProps): React.ReactElement {
const sizes = {
sm: { height: 32, width: 80 },
md: { height: 40, width: 100 },
lg: { height: 48, width: 120 },
};
return (
<Skeleton
height={sizes[size].height}
width={fullWidth ? '100%' : sizes[size].width}
className={cn('rounded-md', className)}
/>
);
}

View File

@@ -0,0 +1,508 @@
import React, { useMemo } from 'react';
import {
AreaChart as RechartsAreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface AreaConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Area color */
color: string;
/** Gradient ID (auto-generated if not provided) */
gradientId?: string;
/** Fill opacity */
fillOpacity?: number;
/** Stroke width */
strokeWidth?: number;
/** Stack ID for stacked areas */
stackId?: string;
/** Area type */
type?: 'monotone' | 'linear' | 'step' | 'stepBefore' | 'stepAfter';
/** Whether area is hidden initially */
hidden?: boolean;
}
export interface AreaChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Area configurations */
areas: AreaConfig[];
/** Key for X-axis values */
xAxisKey: string;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** X-axis label */
xAxisLabel?: string;
/** Y-axis label */
yAxisLabel?: string;
/** Format function for X-axis ticks */
xAxisFormatter?: (value: string | number) => string;
/** Format function for Y-axis ticks */
yAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Y-axis domain */
yAxisDomain?: [number | 'auto' | 'dataMin' | 'dataMax', number | 'auto' | 'dataMin' | 'dataMax'];
/** Reference line at Y value */
referenceLineY?: number;
/** Reference line label */
referenceLineLabel?: string;
/** Gradient style */
gradientStyle?: 'solid' | 'fade' | 'none';
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultAreaColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function AreaChart<T extends Record<string, unknown>>({
data,
areas,
xAxisKey,
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
xAxisLabel,
yAxisLabel,
xAxisFormatter,
yAxisFormatter,
tooltipFormatter,
yAxisDomain,
referenceLineY,
referenceLineLabel,
gradientStyle = 'fade',
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
}: AreaChartProps<T>): React.ReactElement {
// Assign colors and gradient IDs to areas
const areasWithConfig = useMemo(() => {
return areas.map((area, index) => ({
...area,
color: area.color || defaultAreaColors[index % defaultAreaColors.length],
gradientId: area.gradientId || `gradient-${area.dataKey}-${index}`,
fillOpacity: area.fillOpacity ?? 0.3,
strokeWidth: area.strokeWidth ?? 2,
type: area.type || 'monotone',
}));
}, [areas]);
if (isLoading) {
return <SkeletonChart type="area" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
<RechartsAreaChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
{/* Gradient Definitions */}
<defs>
{areasWithConfig.map((area) => (
<linearGradient
key={area.gradientId}
id={area.gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
{gradientStyle === 'fade' ? (
<>
<stop
offset="5%"
stopColor={area.color}
stopOpacity={area.fillOpacity}
/>
<stop
offset="95%"
stopColor={area.color}
stopOpacity={0.05}
/>
</>
) : gradientStyle === 'solid' ? (
<stop
offset="0%"
stopColor={area.color}
stopOpacity={area.fillOpacity}
/>
) : (
<stop offset="0%" stopColor={area.color} stopOpacity={0} />
)}
</linearGradient>
))}
</defs>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
/>
)}
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={xAxisFormatter}
label={
xAxisLabel
? {
value: xAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={yAxisFormatter}
domain={yAxisDomain}
label={
yAxisLabel
? {
value: yAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ stroke: '#9CA3AF', strokeDasharray: '5 5' }}
/>
{showLegend && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{referenceLineY !== undefined && (
<ReferenceLine
y={referenceLineY}
stroke="#9CA3AF"
strokeDasharray="5 5"
label={
referenceLineLabel
? {
value: referenceLineLabel,
fill: '#6B7280',
fontSize: 12,
position: 'right',
}
: undefined
}
/>
)}
{areasWithConfig
.filter((area) => !area.hidden)
.map((area) => (
<Area
key={area.dataKey}
type={area.type}
dataKey={area.dataKey}
name={area.name}
stroke={area.color}
strokeWidth={area.strokeWidth}
fill={`url(#${area.gradientId})`}
stackId={area.stackId}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
))}
</RechartsAreaChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Stacked Area Chart Variant
// ============================================================================
export interface StackedAreaChartProps<T extends Record<string, unknown>>
extends Omit<AreaChartProps<T>, 'areas'> {
areas: Omit<AreaConfig, 'stackId'>[];
}
export function StackedAreaChart<T extends Record<string, unknown>>({
areas,
...props
}: StackedAreaChartProps<T>): React.ReactElement {
const stackedAreas = useMemo(() => {
return areas.map((area) => ({
...area,
stackId: 'stack',
}));
}, [areas]);
return <AreaChart {...props} areas={stackedAreas} />;
}
// ============================================================================
// Cash Flow Chart (specialized for cumulative cash flow)
// ============================================================================
export interface CashFlowChartProps<T extends Record<string, unknown>> {
data: T[];
xAxisKey: string;
cashFlowKey: string;
cumulativeKey?: string;
title?: string;
subtitle?: string;
height?: number;
showCumulative?: boolean;
zeroLineLabel?: string;
currency?: string;
isLoading?: boolean;
className?: string;
}
export function CashFlowChart<T extends Record<string, unknown>>({
data,
xAxisKey,
cashFlowKey,
cumulativeKey = 'cumulative',
title = 'Flujo de Caja',
subtitle,
height = 300,
showCumulative = true,
zeroLineLabel = 'Punto de equilibrio',
currency = 'MXN',
isLoading = false,
className,
}: CashFlowChartProps<T>): React.ReactElement {
const areas: AreaConfig[] = useMemo(() => {
const config: AreaConfig[] = [
{
dataKey: cashFlowKey,
name: 'Flujo Mensual',
color: '#3B82F6',
type: 'monotone',
},
];
if (showCumulative && cumulativeKey) {
config.push({
dataKey: cumulativeKey,
name: 'Acumulado',
color: '#10B981',
type: 'monotone',
fillOpacity: 0.1,
});
}
return config;
}, [cashFlowKey, cumulativeKey, showCumulative]);
const currencyFormatter = (value: number): string => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
return (
<AreaChart
data={data}
areas={areas}
xAxisKey={xAxisKey}
title={title}
subtitle={subtitle}
height={height}
yAxisFormatter={(value) => {
if (Math.abs(value) >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1000) {
return `$${(value / 1000).toFixed(0)}K`;
}
return `$${value}`;
}}
tooltipFormatter={currencyFormatter}
referenceLineY={0}
referenceLineLabel={zeroLineLabel}
gradientStyle="fade"
isLoading={isLoading}
className={className}
/>
);
}

View File

@@ -0,0 +1,526 @@
import React, { useMemo } from 'react';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface BarConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Bar color */
color: string;
/** Stacked group ID (for stacked bars) */
stackId?: string;
/** Bar radius */
radius?: number | [number, number, number, number];
}
export type BarLayout = 'horizontal' | 'vertical';
export interface BarChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Bar configurations */
bars: BarConfig[];
/** Key for category axis (X for vertical, Y for horizontal) */
categoryKey: string;
/** Chart layout */
layout?: BarLayout;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** Category axis label */
categoryAxisLabel?: string;
/** Value axis label */
valueAxisLabel?: string;
/** Format function for category axis ticks */
categoryAxisFormatter?: (value: string) => string;
/** Format function for value axis ticks */
valueAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Bar size (width for vertical, height for horizontal) */
barSize?: number;
/** Gap between bar groups */
barGap?: number;
/** Gap between bars in a group */
barCategoryGap?: string | number;
/** Enable bar labels */
showBarLabels?: boolean;
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
/** Color each bar differently based on value */
colorByValue?: (value: number) => string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultBarColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Custom Bar Label
// ============================================================================
interface CustomLabelProps {
x?: number;
y?: number;
width?: number;
height?: number;
value?: number;
formatter?: (value: number) => string;
}
function CustomBarLabel({
x = 0,
y = 0,
width = 0,
height = 0,
value,
formatter,
}: CustomLabelProps): React.ReactElement | null {
if (value === undefined) return null;
const formattedValue = formatter ? formatter(value) : value.toLocaleString('es-MX');
return (
<text
x={x + width / 2}
y={y + height / 2}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
className="text-xs font-medium"
>
{formattedValue}
</text>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function BarChart<T extends Record<string, unknown>>({
data,
bars,
categoryKey,
layout = 'vertical',
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
categoryAxisLabel,
valueAxisLabel,
categoryAxisFormatter,
valueAxisFormatter,
tooltipFormatter,
barSize,
barGap = 4,
barCategoryGap = '20%',
showBarLabels = false,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
colorByValue,
}: BarChartProps<T>): React.ReactElement {
// Assign colors to bars if not provided
const barsWithColors = useMemo(() => {
return bars.map((bar, index) => ({
...bar,
color: bar.color || defaultBarColors[index % defaultBarColors.length],
radius: bar.radius ?? 4,
}));
}, [bars]);
if (isLoading) {
return <SkeletonChart type="bar" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
const isHorizontal = layout === 'horizontal';
// For horizontal layout, we need more height per item
const adjustedHeight = isHorizontal ? Math.max(height, data.length * 40) : height;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={adjustedHeight}>
<RechartsBarChart
data={data}
layout={isHorizontal ? 'vertical' : 'horizontal'}
margin={{ top: 10, right: 10, left: isHorizontal ? 80 : 0, bottom: 0 }}
barGap={barGap}
barCategoryGap={barCategoryGap}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
horizontal={!isHorizontal}
vertical={isHorizontal}
/>
)}
{isHorizontal ? (
<>
<XAxis
type="number"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={valueAxisFormatter}
label={
valueAxisLabel
? {
value: valueAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
type="category"
dataKey={categoryKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={categoryAxisFormatter}
width={80}
label={
categoryAxisLabel
? {
value: categoryAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
</>
) : (
<>
<XAxis
dataKey={categoryKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={categoryAxisFormatter}
label={
categoryAxisLabel
? {
value: categoryAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={valueAxisFormatter}
label={
valueAxisLabel
? {
value: valueAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
</>
)}
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ fill: '#F3F4F6', opacity: 0.5 }}
/>
{showLegend && bars.length > 1 && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{barsWithColors.map((bar) => (
<Bar
key={bar.dataKey}
dataKey={bar.dataKey}
name={bar.name}
fill={bar.color}
stackId={bar.stackId}
barSize={barSize}
radius={bar.radius}
label={
showBarLabels
? (props: CustomLabelProps) => (
<CustomBarLabel {...props} formatter={valueAxisFormatter} />
)
: undefined
}
>
{colorByValue &&
data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colorByValue(entry[bar.dataKey] as number)}
/>
))}
</Bar>
))}
</RechartsBarChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Stacked Bar Chart Variant
// ============================================================================
export interface StackedBarChartProps<T extends Record<string, unknown>>
extends Omit<BarChartProps<T>, 'bars'> {
/** Bar configurations with automatic stackId */
bars: Omit<BarConfig, 'stackId'>[];
}
export function StackedBarChart<T extends Record<string, unknown>>({
bars,
...props
}: StackedBarChartProps<T>): React.ReactElement {
const stackedBars = useMemo(() => {
return bars.map((bar) => ({
...bar,
stackId: 'stack',
}));
}, [bars]);
return <BarChart {...props} bars={stackedBars} />;
}
// ============================================================================
// Comparison Bar Chart (side by side bars for comparison)
// ============================================================================
export interface ComparisonBarChartProps<T extends Record<string, unknown>> {
data: T[];
categoryKey: string;
currentKey: string;
previousKey: string;
currentLabel?: string;
previousLabel?: string;
currentColor?: string;
previousColor?: string;
title?: string;
subtitle?: string;
height?: number;
layout?: BarLayout;
valueAxisFormatter?: (value: number) => string;
tooltipFormatter?: (value: number, name: string) => string;
isLoading?: boolean;
className?: string;
}
export function ComparisonBarChart<T extends Record<string, unknown>>({
data,
categoryKey,
currentKey,
previousKey,
currentLabel = 'Actual',
previousLabel = 'Anterior',
currentColor = '#3B82F6',
previousColor = '#9CA3AF',
title,
subtitle,
height = 300,
layout = 'vertical',
valueAxisFormatter,
tooltipFormatter,
isLoading = false,
className,
}: ComparisonBarChartProps<T>): React.ReactElement {
const bars: BarConfig[] = [
{ dataKey: previousKey, name: previousLabel, color: previousColor },
{ dataKey: currentKey, name: currentLabel, color: currentColor },
];
return (
<BarChart
data={data}
bars={bars}
categoryKey={categoryKey}
layout={layout}
title={title}
subtitle={subtitle}
height={height}
valueAxisFormatter={valueAxisFormatter}
tooltipFormatter={tooltipFormatter}
isLoading={isLoading}
className={className}
/>
);
}

View File

@@ -0,0 +1,478 @@
import React, { useMemo } from 'react';
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface LineConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Line color */
color: string;
/** Line type */
type?: 'monotone' | 'linear' | 'step' | 'stepBefore' | 'stepAfter';
/** Whether to show dots on data points */
showDots?: boolean;
/** Stroke width */
strokeWidth?: number;
/** Dash pattern for line */
strokeDasharray?: string;
/** Whether line is hidden initially */
hidden?: boolean;
}
export interface LineChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Line configurations */
lines: LineConfig[];
/** Key for X-axis values */
xAxisKey: string;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** X-axis label */
xAxisLabel?: string;
/** Y-axis label */
yAxisLabel?: string;
/** Format function for X-axis ticks */
xAxisFormatter?: (value: string | number) => string;
/** Format function for Y-axis ticks */
yAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Y-axis domain (auto by default) */
yAxisDomain?: [number | 'auto' | 'dataMin' | 'dataMax', number | 'auto' | 'dataMin' | 'dataMax'];
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultLineColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function LineChart<T extends Record<string, unknown>>({
data,
lines,
xAxisKey,
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
xAxisLabel,
yAxisLabel,
xAxisFormatter,
yAxisFormatter,
tooltipFormatter,
yAxisDomain,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
}: LineChartProps<T>): React.ReactElement {
// Assign colors to lines if not provided
const linesWithColors = useMemo(() => {
return lines.map((line, index) => ({
...line,
color: line.color || defaultLineColors[index % defaultLineColors.length],
}));
}, [lines]);
if (isLoading) {
return <SkeletonChart type="line" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
/>
)}
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={xAxisFormatter}
label={
xAxisLabel
? {
value: xAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={yAxisFormatter}
domain={yAxisDomain}
label={
yAxisLabel
? {
value: yAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ stroke: '#9CA3AF', strokeDasharray: '5 5' }}
/>
{showLegend && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{linesWithColors
.filter((line) => !line.hidden)
.map((line) => (
<Line
key={line.dataKey}
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
strokeDasharray={line.strokeDasharray}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Multi-Axis Line Chart
// ============================================================================
export interface DualAxisLineChartProps<T extends Record<string, unknown>> {
data: T[];
leftLines: LineConfig[];
rightLines: LineConfig[];
xAxisKey: string;
title?: string;
subtitle?: string;
height?: number;
leftAxisLabel?: string;
rightAxisLabel?: string;
leftAxisFormatter?: (value: number) => string;
rightAxisFormatter?: (value: number) => string;
tooltipFormatter?: (value: number, name: string) => string;
isLoading?: boolean;
className?: string;
}
export function DualAxisLineChart<T extends Record<string, unknown>>({
data,
leftLines,
rightLines,
xAxisKey,
title,
subtitle,
height = 300,
leftAxisLabel,
rightAxisLabel,
leftAxisFormatter,
rightAxisFormatter,
tooltipFormatter,
isLoading = false,
className,
}: DualAxisLineChartProps<T>): React.ReactElement {
if (isLoading) {
return <SkeletonChart type="line" height={height} className={className} />;
}
const allLines = [...leftLines, ...rightLines];
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
/>
<YAxis
yAxisId="left"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={leftAxisFormatter}
label={
leftAxisLabel
? {
value: leftAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={rightAxisFormatter}
label={
rightAxisLabel
? {
value: rightAxisLabel,
angle: 90,
position: 'insideRight',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip content={<CustomTooltip formatter={tooltipFormatter} />} />
<Legend content={<CustomLegend />} />
{leftLines.map((line) => (
<Line
key={line.dataKey}
yAxisId="left"
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
/>
))}
{rightLines.map((line) => (
<Line
key={line.dataKey}
yAxisId="right"
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
strokeDasharray={line.strokeDasharray || '5 5'}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,582 @@
import React, { useMemo, useState, useCallback } from 'react';
import {
PieChart as RechartsPieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
Sector,
type TooltipProps,
type PieSectorDataItem,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface PieDataItem {
/** Category name */
name: string;
/** Numeric value */
value: number;
/** Optional custom color */
color?: string;
}
export interface PieChartProps {
/** Chart data */
data: PieDataItem[];
/** Chart title */
title?: string;
/** Chart subtitle */
subtitle?: string;
/** Chart height */
height?: number;
/** Inner radius for donut chart (0 for pie, >0 for donut) */
innerRadius?: number | string;
/** Outer radius */
outerRadius?: number | string;
/** Padding angle between slices */
paddingAngle?: number;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'right' | 'bottom';
/** Show labels on slices */
showLabels?: boolean;
/** Label type */
labelType?: 'name' | 'value' | 'percent' | 'name-percent';
/** Format function for values */
valueFormatter?: (value: number) => string;
/** Active slice on hover effect */
activeOnHover?: boolean;
/** Center label (for donut charts) */
centerLabel?: {
title: string;
value: string;
};
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
/** Click handler for slices */
onSliceClick?: (data: PieDataItem, index: number) => void;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultPieColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
'#F97316', // orange-500
'#6366F1', // indigo-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
valueFormatter?: (value: number) => string;
total: number;
}
function CustomTooltip({
active,
payload,
valueFormatter,
total,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
const data = payload[0];
const value = data.value as number;
const percentage = ((value / total) * 100).toFixed(1);
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-2 mb-1">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: data.payload.color || data.payload.fill }}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{data.name}
</span>
</div>
<div className="space-y-0.5 text-sm">
<p className="text-gray-600 dark:text-gray-300">
Valor: <span className="font-medium">{formattedValue}</span>
</p>
<p className="text-gray-600 dark:text-gray-300">
Porcentaje: <span className="font-medium">{percentage}%</span>
</p>
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{
value: string;
color: string;
payload: { value: number };
}>;
total: number;
valueFormatter?: (value: number) => string;
layout: 'horizontal' | 'vertical';
}
function CustomLegend({
payload,
total,
valueFormatter,
layout,
}: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
const isVertical = layout === 'vertical';
return (
<div
className={cn(
'flex gap-3',
isVertical ? 'flex-col' : 'flex-wrap justify-center'
)}
>
{payload.map((entry, index) => {
const value = entry.payload.value;
const percentage = ((value / total) * 100).toFixed(1);
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<div
key={index}
className={cn(
'flex items-center gap-2',
isVertical && 'justify-between min-w-[180px]'
)}
>
<div className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300 truncate max-w-[120px]">
{entry.value}
</span>
</div>
{isVertical && (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue} ({percentage}%)
</span>
)}
</div>
);
})}
</div>
);
}
// ============================================================================
// Active Shape (for hover effect)
// ============================================================================
interface ActiveShapeProps extends PieSectorDataItem {
cx: number;
cy: number;
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
fill: string;
payload: { name: string };
percent: number;
value: number;
valueFormatter?: (value: number) => string;
}
function renderActiveShape(props: ActiveShapeProps): React.ReactElement {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
valueFormatter,
} = props;
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<g>
{/* Expanded active sector */}
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius + 8}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
{/* Inner sector for donut */}
{innerRadius > 0 && (
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius - 4}
outerRadius={innerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
opacity={0.3}
/>
)}
{/* Center text for donut */}
{innerRadius > 0 && (
<>
<text
x={cx}
y={cy - 10}
textAnchor="middle"
fill="#374151"
className="text-sm font-medium"
>
{payload.name}
</text>
<text
x={cx}
y={cy + 10}
textAnchor="middle"
fill="#6B7280"
className="text-xs"
>
{formattedValue}
</text>
<text
x={cx}
y={cy + 28}
textAnchor="middle"
fill="#9CA3AF"
className="text-xs"
>
{(percent * 100).toFixed(1)}%
</text>
</>
)}
</g>
);
}
// ============================================================================
// Custom Label
// ============================================================================
interface CustomLabelProps {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
percent: number;
name: string;
value: number;
labelType: 'name' | 'value' | 'percent' | 'name-percent';
valueFormatter?: (value: number) => string;
}
function renderCustomLabel({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
name,
value,
labelType,
valueFormatter,
}: CustomLabelProps): React.ReactElement | null {
if (percent < 0.05) return null; // Don't show label for small slices
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
let labelText: string;
switch (labelType) {
case 'name':
labelText = name;
break;
case 'value':
labelText = valueFormatter ? valueFormatter(value) : value.toLocaleString('es-MX');
break;
case 'percent':
labelText = `${(percent * 100).toFixed(0)}%`;
break;
case 'name-percent':
labelText = `${name} (${(percent * 100).toFixed(0)}%)`;
break;
default:
labelText = `${(percent * 100).toFixed(0)}%`;
}
return (
<text
x={x}
y={y}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
className="text-xs font-medium"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}
>
{labelText}
</text>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function PieChart({
data,
title,
subtitle,
height = 300,
innerRadius = 0,
outerRadius = '80%',
paddingAngle = 2,
showLegend = true,
legendPosition = 'bottom',
showLabels = false,
labelType = 'percent',
valueFormatter,
activeOnHover = true,
centerLabel,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
onSliceClick,
}: PieChartProps): React.ReactElement {
const [activeIndex, setActiveIndex] = useState<number | undefined>(undefined);
// Calculate total
const total = useMemo(() => data.reduce((sum, item) => sum + item.value, 0), [data]);
// Assign colors to data
const dataWithColors = useMemo(() => {
return data.map((item, index) => ({
...item,
color: item.color || defaultPieColors[index % defaultPieColors.length],
}));
}, [data]);
const onPieEnter = useCallback((_: unknown, index: number) => {
if (activeOnHover) {
setActiveIndex(index);
}
}, [activeOnHover]);
const onPieLeave = useCallback(() => {
if (activeOnHover) {
setActiveIndex(undefined);
}
}, [activeOnHover]);
const handleClick = useCallback(
(data: PieDataItem, index: number) => {
if (onSliceClick) {
onSliceClick(data, index);
}
},
[onSliceClick]
);
if (isLoading) {
return <SkeletonChart type="pie" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0 || total === 0;
const isHorizontalLegend = legendPosition === 'bottom';
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<div
className={cn(
'flex',
isHorizontalLegend ? 'flex-col' : 'flex-row items-center gap-4'
)}
>
<div className={cn('relative', !isHorizontalLegend && 'flex-1')}>
<ResponsiveContainer width="100%" height={height}>
<RechartsPieChart>
<Pie
data={dataWithColors}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={paddingAngle}
dataKey="value"
nameKey="name"
activeIndex={activeIndex}
activeShape={
activeOnHover
? (props: ActiveShapeProps) =>
renderActiveShape({ ...props, valueFormatter })
: undefined
}
onMouseEnter={onPieEnter}
onMouseLeave={onPieLeave}
onClick={(data, index) => handleClick(data as PieDataItem, index)}
label={
showLabels && !activeOnHover
? (props: CustomLabelProps) =>
renderCustomLabel({ ...props, labelType, valueFormatter })
: undefined
}
labelLine={false}
style={{ cursor: onSliceClick ? 'pointer' : 'default' }}
>
{dataWithColors.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
content={
<CustomTooltip total={total} valueFormatter={valueFormatter} />
}
/>
{showLegend && isHorizontalLegend && (
<Legend
content={
<CustomLegend
total={total}
valueFormatter={valueFormatter}
layout="horizontal"
/>
}
verticalAlign="bottom"
/>
)}
</RechartsPieChart>
</ResponsiveContainer>
{/* Center Label for Donut */}
{centerLabel && innerRadius && !activeOnHover && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
{centerLabel.title}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{centerLabel.value}
</p>
</div>
</div>
)}
</div>
{/* Vertical Legend */}
{showLegend && !isHorizontalLegend && (
<div className="flex-shrink-0">
<CustomLegend
payload={dataWithColors.map((d) => ({
value: d.name,
color: d.color!,
payload: { value: d.value },
}))}
total={total}
valueFormatter={valueFormatter}
layout="vertical"
/>
</div>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Donut Chart Variant (convenience wrapper)
// ============================================================================
export interface DonutChartProps extends Omit<PieChartProps, 'innerRadius'> {
/** Inner radius percentage (default: 60%) */
innerRadiusPercent?: number;
}
export function DonutChart({
innerRadiusPercent = 60,
...props
}: DonutChartProps): React.ReactElement {
return <PieChart {...props} innerRadius={`${innerRadiusPercent}%`} />;
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "../dist",
"rootDir": ".",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["./**/*.ts", "./**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,36 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Utility function to merge Tailwind CSS classes with clsx
* Handles conditional classes and merges conflicting Tailwind classes correctly
*
* @example
* cn('px-2 py-1', 'px-4') // => 'py-1 px-4'
* cn('text-red-500', isActive && 'text-blue-500') // => 'text-blue-500' when isActive is true
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Utility to conditionally apply classes based on a boolean condition
*/
export function conditionalClass(
condition: boolean,
trueClass: string,
falseClass: string = ''
): string {
return condition ? trueClass : falseClass;
}
/**
* Utility to create variant-based class mappings
*/
export function variantClasses<T extends string>(
variant: T,
variants: Record<T, string>,
defaultClass: string = ''
): string {
return variants[variant] ?? defaultClass;
}