## 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>
700 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|