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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user