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.
This commit is contained in:
312
frontend/src/components/ui/Table.tsx
Normal file
312
frontend/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user