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:
32
packages/ui/package.json
Normal file
32
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
280
packages/ui/src/components/AlertBadge.tsx
Normal file
280
packages/ui/src/components/AlertBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
699
packages/ui/src/components/DataTable.tsx
Normal file
699
packages/ui/src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
400
packages/ui/src/components/KPICard.tsx
Normal file
400
packages/ui/src/components/KPICard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
packages/ui/src/components/MetricCard.tsx
Normal file
425
packages/ui/src/components/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
673
packages/ui/src/components/PeriodSelector.tsx
Normal file
673
packages/ui/src/components/PeriodSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
350
packages/ui/src/components/Skeleton.tsx
Normal file
350
packages/ui/src/components/Skeleton.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
508
packages/ui/src/components/charts/AreaChart.tsx
Normal file
508
packages/ui/src/components/charts/AreaChart.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
526
packages/ui/src/components/charts/BarChart.tsx
Normal file
526
packages/ui/src/components/charts/BarChart.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
478
packages/ui/src/components/charts/LineChart.tsx
Normal file
478
packages/ui/src/components/charts/LineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
582
packages/ui/src/components/charts/PieChart.tsx
Normal file
582
packages/ui/src/components/charts/PieChart.tsx
Normal 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}%`} />;
|
||||
}
|
||||
32
packages/ui/src/tsconfig.json
Normal file
32
packages/ui/src/tsconfig.json
Normal 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"]
|
||||
}
|
||||
36
packages/ui/src/utils/cn.ts
Normal file
36
packages/ui/src/utils/cn.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user