226 lines
7.1 KiB
TypeScript
226 lines
7.1 KiB
TypeScript
|
|
import { useState } from 'react';
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from '@/components/ui/table';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import { Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
|
||
|
|
export interface Column<T> {
|
||
|
|
key: string;
|
||
|
|
header: string;
|
||
|
|
cell: (item: T) => React.ReactNode;
|
||
|
|
sortable?: boolean;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DataTableProps<T> {
|
||
|
|
data: T[];
|
||
|
|
columns: Column<T>[];
|
||
|
|
searchPlaceholder?: string;
|
||
|
|
searchKey?: keyof T;
|
||
|
|
onRowClick?: (item: T) => void;
|
||
|
|
isLoading?: boolean;
|
||
|
|
emptyMessage?: string;
|
||
|
|
pageSize?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DataTable<T extends { id: string }>({
|
||
|
|
data,
|
||
|
|
columns,
|
||
|
|
searchPlaceholder = 'Pesquisar...',
|
||
|
|
searchKey,
|
||
|
|
onRowClick,
|
||
|
|
isLoading = false,
|
||
|
|
emptyMessage = 'Nenhum registro encontrado',
|
||
|
|
pageSize: initialPageSize = 10,
|
||
|
|
}: DataTableProps<T>) {
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
const [pageSize, setPageSize] = useState(initialPageSize);
|
||
|
|
|
||
|
|
const filteredData = searchKey
|
||
|
|
? data.filter((item) =>
|
||
|
|
String(item[searchKey])
|
||
|
|
.toLowerCase()
|
||
|
|
.includes(search.toLowerCase())
|
||
|
|
)
|
||
|
|
: data;
|
||
|
|
|
||
|
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||
|
|
const startIndex = (currentPage - 1) * pageSize;
|
||
|
|
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||
|
|
|
||
|
|
const handlePageChange = (page: number) => {
|
||
|
|
setCurrentPage(Math.min(Math.max(1, page), totalPages));
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className="gov-card">
|
||
|
|
<div className="p-8 text-center">
|
||
|
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto" />
|
||
|
|
<p className="mt-4 text-muted-foreground">Carregando dados...</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="gov-card animate-fade-in">
|
||
|
|
{/* Search and filters */}
|
||
|
|
<div className="p-4 border-b border-border">
|
||
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div className="relative w-full sm:w-80">
|
||
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
placeholder={searchPlaceholder}
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => {
|
||
|
|
setSearch(e.target.value);
|
||
|
|
setCurrentPage(1);
|
||
|
|
}}
|
||
|
|
className="pl-9"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<span>{filteredData.length} registros</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
<div className="overflow-x-auto">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow className="hover:bg-transparent">
|
||
|
|
{columns.map((column) => (
|
||
|
|
<TableHead
|
||
|
|
key={column.key}
|
||
|
|
className={cn('font-semibold text-foreground', column.className)}
|
||
|
|
>
|
||
|
|
{column.header}
|
||
|
|
</TableHead>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{paginatedData.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell
|
||
|
|
colSpan={columns.length}
|
||
|
|
className="h-32 text-center text-muted-foreground"
|
||
|
|
>
|
||
|
|
{emptyMessage}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
paginatedData.map((item) => (
|
||
|
|
<TableRow
|
||
|
|
key={item.id}
|
||
|
|
className={cn(
|
||
|
|
'table-row-hover',
|
||
|
|
onRowClick && 'cursor-pointer'
|
||
|
|
)}
|
||
|
|
onClick={() => onRowClick?.(item)}
|
||
|
|
>
|
||
|
|
{columns.map((column) => (
|
||
|
|
<TableCell key={column.key} className={column.className}>
|
||
|
|
{column.cell(item)}
|
||
|
|
</TableCell>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Pagination */}
|
||
|
|
{filteredData.length > 0 && (
|
||
|
|
<div className="p-4 border-t border-border">
|
||
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm text-muted-foreground">Linhas por página:</span>
|
||
|
|
<Select
|
||
|
|
value={String(pageSize)}
|
||
|
|
onValueChange={(value) => {
|
||
|
|
setPageSize(Number(value));
|
||
|
|
setCurrentPage(1);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-16 h-8">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="5">5</SelectItem>
|
||
|
|
<SelectItem value="10">10</SelectItem>
|
||
|
|
<SelectItem value="20">20</SelectItem>
|
||
|
|
<SelectItem value="50">50</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-sm text-muted-foreground">
|
||
|
|
Página {currentPage} de {totalPages}
|
||
|
|
</span>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => handlePageChange(1)}
|
||
|
|
disabled={currentPage === 1}
|
||
|
|
>
|
||
|
|
<ChevronsLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
||
|
|
disabled={currentPage === 1}
|
||
|
|
>
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
||
|
|
disabled={currentPage === totalPages}
|
||
|
|
>
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => handlePageChange(totalPages)}
|
||
|
|
disabled={currentPage === totalPages}
|
||
|
|
>
|
||
|
|
<ChevronsRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|