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.
313 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|