Files
HoruxStrategyKimi/packages/ui/src/components/DataTable.tsx
HORUX360 a9b1994c48 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>
2026-01-31 11:05:24 +00:00

700 lines
23 KiB
TypeScript

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>
);
}