Files
2026-07-08 11:08:24 +05:30

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>
)
}