213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
|
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
||
|
|
|
||
|
|
interface Column {
|
||
|
|
key: string;
|
||
|
|
label: string;
|
||
|
|
sortable?: boolean;
|
||
|
|
render?: (value: any, row: any) => React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DataTableProps {
|
||
|
|
columns: Column[];
|
||
|
|
data: any[];
|
||
|
|
totalCount: number;
|
||
|
|
page: number;
|
||
|
|
pageSize: number;
|
||
|
|
onPageChange: (page: number) => void;
|
||
|
|
onSort?: (column: string, direction: 'asc' | 'desc') => void;
|
||
|
|
onSearch?: (query: string) => void;
|
||
|
|
onRowClick?: (row: any) => void;
|
||
|
|
isLoading?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function DataTable({
|
||
|
|
columns,
|
||
|
|
data,
|
||
|
|
totalCount,
|
||
|
|
page,
|
||
|
|
pageSize,
|
||
|
|
onPageChange,
|
||
|
|
onSort,
|
||
|
|
onSearch,
|
||
|
|
onRowClick,
|
||
|
|
isLoading = false,
|
||
|
|
}: DataTableProps) {
|
||
|
|
const [searchQuery, setSearchQuery] = useState('');
|
||
|
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||
|
|
|
||
|
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||
|
|
|
||
|
|
const handleSort = (columnKey: string) => {
|
||
|
|
if (!onSort) return;
|
||
|
|
|
||
|
|
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc';
|
||
|
|
setSortColumn(columnKey);
|
||
|
|
setSortDirection(newDirection);
|
||
|
|
onSort(columnKey, newDirection);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSearch = () => {
|
||
|
|
if (onSearch) {
|
||
|
|
onSearch(searchQuery);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Search Bar */}
|
||
|
|
{onSearch && (
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
placeholder="Search..."
|
||
|
|
value={searchQuery}
|
||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
|
|
className="pl-10"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button onClick={handleSearch} disabled={isLoading}>
|
||
|
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||
|
|
<span className="ml-2">Search</span>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Table */}
|
||
|
|
<div className="border rounded-lg overflow-hidden bg-card">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||
|
|
{columns.map((column) => (
|
||
|
|
<TableHead key={column.key} className="font-semibold">
|
||
|
|
{column.sortable ? (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleSort(column.key)}
|
||
|
|
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
|
||
|
|
>
|
||
|
|
{column.label}
|
||
|
|
{sortColumn === column.key ? (
|
||
|
|
sortDirection === 'asc' ? (
|
||
|
|
<ArrowUp className="ml-2 h-4 w-4" />
|
||
|
|
) : (
|
||
|
|
<ArrowDown className="ml-2 h-4 w-4" />
|
||
|
|
)
|
||
|
|
) : (
|
||
|
|
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
column.label
|
||
|
|
)}
|
||
|
|
</TableHead>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{isLoading ? (
|
||
|
|
Array.from({ length: 5 }).map((_, index) => (
|
||
|
|
<TableRow key={index}>
|
||
|
|
{columns.map((column) => (
|
||
|
|
<TableCell key={column.key}>
|
||
|
|
<Skeleton className="h-5 w-full" />
|
||
|
|
</TableCell>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
) : data.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={columns.length} className="text-center py-12">
|
||
|
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||
|
|
<Search className="w-8 h-8 opacity-50" />
|
||
|
|
<p className="text-sm font-medium">No data found</p>
|
||
|
|
<p className="text-xs">Try adjusting your search or filters</p>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
data.map((row, index) => (
|
||
|
|
<TableRow
|
||
|
|
key={row.id || index}
|
||
|
|
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
|
||
|
|
onClick={() => onRowClick?.(row)}
|
||
|
|
>
|
||
|
|
{columns.map((column) => (
|
||
|
|
<TableCell key={column.key}>
|
||
|
|
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||
|
|
</TableCell>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Pagination */}
|
||
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||
|
|
<div className="text-sm text-muted-foreground font-medium">
|
||
|
|
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '}
|
||
|
|
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '}
|
||
|
|
<span className="font-semibold text-foreground">{totalCount}</span> results
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => onPageChange(1)}
|
||
|
|
disabled={page === 1 || isLoading}
|
||
|
|
className="h-8 w-8"
|
||
|
|
>
|
||
|
|
<ChevronsLeft className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => onPageChange(page - 1)}
|
||
|
|
disabled={page === 1 || isLoading}
|
||
|
|
className="h-8 w-8"
|
||
|
|
>
|
||
|
|
<ChevronLeft className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
<div className="flex items-center gap-1 px-3">
|
||
|
|
<span className="text-sm font-medium">
|
||
|
|
Page {page} of {totalPages || 1}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => onPageChange(page + 1)}
|
||
|
|
disabled={page === totalPages || isLoading}
|
||
|
|
className="h-8 w-8"
|
||
|
|
>
|
||
|
|
<ChevronRight className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => onPageChange(totalPages)}
|
||
|
|
disabled={page === totalPages || isLoading}
|
||
|
|
className="h-8 w-8"
|
||
|
|
>
|
||
|
|
<ChevronsRight className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|