249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
"use client"
|
|
|
|
import { type ReactNode, useMemo, useState } from "react"
|
|
import { Search } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from "@/components/ui/pagination"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
|
|
export interface DataTableColumn<T> {
|
|
key: string
|
|
header: string
|
|
cell: (row: T) => ReactNode
|
|
headerClassName?: string
|
|
cellClassName?: string
|
|
}
|
|
|
|
export interface DataTableFilter<T> {
|
|
key: string
|
|
label: string
|
|
options: string[]
|
|
accessor: (row: T) => string
|
|
}
|
|
|
|
export interface DataTableProps<T> {
|
|
data: readonly T[]
|
|
columns: DataTableColumn<T>[]
|
|
getRowId: (row: T) => string | number
|
|
searchAccessor?: (row: T) => string
|
|
searchPlaceholder?: string
|
|
filters?: DataTableFilter<T>[]
|
|
actions?: (row: T) => ReactNode
|
|
pageSize?: number
|
|
emptyMessage?: string
|
|
}
|
|
|
|
const ALL_VALUE = "All"
|
|
|
|
export function DataTable<T>({
|
|
data,
|
|
columns,
|
|
getRowId,
|
|
searchAccessor,
|
|
searchPlaceholder = "Search...",
|
|
filters = [],
|
|
actions,
|
|
pageSize = 5,
|
|
emptyMessage = "No results found.",
|
|
}: DataTableProps<T>) {
|
|
const [query, setQuery] = useState("")
|
|
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({})
|
|
const [page, setPage] = useState(1)
|
|
|
|
const filteredData = useMemo(() => {
|
|
let rows = data
|
|
|
|
if (query.trim() && searchAccessor) {
|
|
const q = query.trim().toLowerCase()
|
|
rows = rows.filter((row) => searchAccessor(row).toLowerCase().includes(q))
|
|
}
|
|
|
|
for (const filter of filters) {
|
|
const active = activeFilters[filter.key]
|
|
if (active && active !== ALL_VALUE) {
|
|
rows = rows.filter((row) => filter.accessor(row) === active)
|
|
}
|
|
}
|
|
|
|
return rows
|
|
}, [data, query, searchAccessor, filters, activeFilters])
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize))
|
|
const currentPage = Math.min(page, totalPages)
|
|
const start = (currentPage - 1) * pageSize
|
|
const visibleRows = filteredData.slice(start, start + pageSize)
|
|
|
|
const goTo = (target: number) => setPage(Math.min(Math.max(target, 1), totalPages))
|
|
|
|
const setFilter = (key: string, value: string) => {
|
|
setActiveFilters((prev) => ({ ...prev, [key]: value }))
|
|
setPage(1)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{(searchAccessor || filters.length > 0) && (
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
{searchAccessor && (
|
|
<div className="relative w-full sm:max-w-xs">
|
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400" />
|
|
<Input
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
placeholder={searchPlaceholder}
|
|
className="h-10 rounded-full bg-slate-50 pl-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{filters.map((filter) => {
|
|
const active = activeFilters[filter.key] ?? ALL_VALUE
|
|
return (
|
|
<div
|
|
key={filter.key}
|
|
className="flex flex-1 flex-wrap items-center gap-2 sm:justify-end"
|
|
>
|
|
{[ALL_VALUE, ...filter.options].map((option) => (
|
|
<Button
|
|
key={option}
|
|
type="button"
|
|
size="sm"
|
|
variant={active === option ? "default" : "outline"}
|
|
className="rounded-full px-4"
|
|
onClick={() => setFilter(filter.key, option)}
|
|
>
|
|
{option}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-indigo-50 hover:bg-indigo-50">
|
|
{columns.map((column, index) => (
|
|
<TableHead
|
|
key={column.key}
|
|
className={cn(
|
|
"text-indigo-700",
|
|
index === 0 && "rounded-l-lg pl-3",
|
|
index === columns.length - 1 && !actions && "rounded-r-lg pr-3",
|
|
column.headerClassName
|
|
)}
|
|
>
|
|
{column.header}
|
|
</TableHead>
|
|
))}
|
|
{actions && (
|
|
<TableHead className="rounded-r-lg pr-3 text-right text-indigo-700">
|
|
Actions
|
|
</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{visibleRows.length === 0 && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={columns.length + (actions ? 1 : 0)}
|
|
className="py-8 text-center text-muted-foreground"
|
|
>
|
|
{emptyMessage}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{visibleRows.map((row) => (
|
|
<TableRow key={getRowId(row)}>
|
|
{columns.map((column, index) => (
|
|
<TableCell
|
|
key={column.key}
|
|
className={cn(index === 0 && "pl-3", column.cellClassName)}
|
|
>
|
|
{column.cell(row)}
|
|
</TableCell>
|
|
))}
|
|
{actions && (
|
|
<TableCell className="pr-3 text-right">{actions(row)}</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{totalPages > 1 && (
|
|
<Pagination className="justify-between">
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
goTo(currentPage - 1)
|
|
}}
|
|
aria-disabled={currentPage === 1}
|
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : undefined}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
|
|
<PaginationContent>
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
|
|
<PaginationItem key={pageNumber}>
|
|
<PaginationLink
|
|
href="#"
|
|
isActive={pageNumber === currentPage}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
goTo(pageNumber)
|
|
}}
|
|
>
|
|
{pageNumber}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
))}
|
|
</PaginationContent>
|
|
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
href="#"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
goTo(currentPage + 1)
|
|
}}
|
|
aria-disabled={currentPage === totalPages}
|
|
className={
|
|
currentPage === totalPages ? "pointer-events-none opacity-50" : undefined
|
|
}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|