first commit
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user