Files
ATLAS/frontend/src/components/ui/Table.tsx
FlotillasGPS Developer 51d78bacf4 FlotillasGPS - Sistema completo de monitoreo de flotillas GPS
Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
2026-01-21 08:18:00 +00:00

313 lines
10 KiB
TypeScript

import { ReactNode, useState, useMemo } from 'react'
import {
ChevronUpIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/20/solid'
import clsx from 'clsx'
// Types
export interface Column<T> {
key: string
header: string
width?: string
sortable?: boolean
render?: (item: T, index: number) => ReactNode
align?: 'left' | 'center' | 'right'
}
export interface TableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (item: T) => string
onRowClick?: (item: T) => void
selectedKey?: string
emptyMessage?: string
isLoading?: boolean
loadingRows?: number
// Sorting
sortable?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
onSort?: (key: string, order: 'asc' | 'desc') => void
// Pagination
pagination?: boolean
pageSize?: number
currentPage?: number
totalItems?: number
onPageChange?: (page: number) => void
// Styling
compact?: boolean
striped?: boolean
hoverable?: boolean
stickyHeader?: boolean
}
export default function Table<T>({
data,
columns,
keyExtractor,
onRowClick,
selectedKey,
emptyMessage = 'No hay datos disponibles',
isLoading = false,
loadingRows = 5,
sortable = true,
defaultSortKey,
defaultSortOrder = 'asc',
onSort,
pagination = false,
pageSize = 10,
currentPage: controlledPage,
totalItems,
onPageChange,
compact = false,
striped = false,
hoverable = true,
stickyHeader = false,
}: TableProps<T>) {
const [sortKey, setSortKey] = useState(defaultSortKey)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(defaultSortOrder)
const [internalPage, setInternalPage] = useState(1)
const currentPage = controlledPage ?? internalPage
const setCurrentPage = onPageChange ?? setInternalPage
// Handle sorting
const handleSort = (key: string) => {
const newOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc'
setSortKey(key)
setSortOrder(newOrder)
onSort?.(key, newOrder)
}
// Sort data locally if no external handler
const sortedData = useMemo(() => {
if (!sortKey || onSort) return data
return [...data].sort((a, b) => {
const aVal = (a as Record<string, unknown>)[sortKey]
const bVal = (b as Record<string, unknown>)[sortKey]
if (aVal === bVal) return 0
if (aVal === null || aVal === undefined) return 1
if (bVal === null || bVal === undefined) return -1
const comparison = aVal < bVal ? -1 : 1
return sortOrder === 'asc' ? comparison : -comparison
})
}, [data, sortKey, sortOrder, onSort])
// Pagination
const total = totalItems ?? sortedData.length
const totalPages = Math.ceil(total / pageSize)
const paginatedData = useMemo(() => {
if (!pagination || onPageChange) return sortedData
const start = (currentPage - 1) * pageSize
return sortedData.slice(start, start + pageSize)
}, [sortedData, pagination, currentPage, pageSize, onPageChange])
// Cell padding based on compact mode
const cellPadding = compact ? 'px-3 py-2' : 'px-4 py-3'
return (
<div className="w-full">
<div className="overflow-x-auto rounded-lg border border-slate-700/50">
<table className="min-w-full divide-y divide-slate-700/50">
{/* Header */}
<thead
className={clsx(
'bg-slate-800/50',
stickyHeader && 'sticky top-0 z-10'
)}
>
<tr>
{columns.map((column) => (
<th
key={column.key}
className={clsx(
cellPadding,
'text-xs font-semibold text-slate-400 uppercase tracking-wider',
'text-left',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.sortable !== false && sortable && 'cursor-pointer hover:text-white',
column.width
)}
style={{ width: column.width }}
onClick={() =>
column.sortable !== false &&
sortable &&
handleSort(column.key)
}
>
<div className="flex items-center gap-1">
<span>{column.header}</span>
{column.sortable !== false && sortable && (
<span className="flex flex-col">
<ChevronUpIcon
className={clsx(
'w-3 h-3 -mb-1',
sortKey === column.key && sortOrder === 'asc'
? 'text-accent-500'
: 'text-slate-600'
)}
/>
<ChevronDownIcon
className={clsx(
'w-3 h-3 -mt-1',
sortKey === column.key && sortOrder === 'desc'
? 'text-accent-500'
: 'text-slate-600'
)}
/>
</span>
)}
</div>
</th>
))}
</tr>
</thead>
{/* Body */}
<tbody className="divide-y divide-slate-700/30 bg-card">
{isLoading ? (
// Loading skeleton
Array.from({ length: loadingRows }).map((_, i) => (
<tr key={i}>
{columns.map((column) => (
<td key={column.key} className={cellPadding}>
<div className="h-4 bg-slate-700 rounded animate-shimmer" />
</td>
))}
</tr>
))
) : paginatedData.length === 0 ? (
// Empty state
<tr>
<td
colSpan={columns.length}
className="px-4 py-12 text-center text-slate-500"
>
{emptyMessage}
</td>
</tr>
) : (
// Data rows
paginatedData.map((item, index) => {
const key = keyExtractor(item)
const isSelected = selectedKey === key
return (
<tr
key={key}
onClick={() => onRowClick?.(item)}
className={clsx(
'transition-colors duration-100',
striped && index % 2 === 1 && 'bg-slate-800/30',
hoverable && 'hover:bg-slate-700/30',
onRowClick && 'cursor-pointer',
isSelected && 'bg-accent-500/10 hover:bg-accent-500/20'
)}
>
{columns.map((column) => (
<td
key={column.key}
className={clsx(
cellPadding,
'text-sm text-slate-300',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right'
)}
>
{column.render
? column.render(item, index)
: String((item as Record<string, unknown>)[column.key] ?? '-')}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && totalPages > 1 && (
<div className="flex items-center justify-between mt-4 px-2">
<p className="text-sm text-slate-500">
Mostrando {(currentPage - 1) * pageSize + 1} -{' '}
{Math.min(currentPage * pageSize, total)} de {total}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className={clsx(
'p-2 rounded-lg transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
'hover:bg-slate-700 text-slate-400 hover:text-white'
)}
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
{/* Page numbers */}
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<button
key={pageNum}
type="button"
onClick={() => setCurrentPage(pageNum)}
className={clsx(
'w-8 h-8 rounded-lg text-sm font-medium transition-colors',
currentPage === pageNum
? 'bg-accent-500 text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-white'
)}
>
{pageNum}
</button>
)
})}
</div>
<button
type="button"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className={clsx(
'p-2 rounded-lg transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
'hover:bg-slate-700 text-slate-400 hover:text-white'
)}
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
)
}