2026-06-22 16:29:59 +00:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from "@/components/ui/table";
|
|
|
|
|
import {
|
|
|
|
|
Search,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Loader2,
|
|
|
|
|
Inbox,
|
|
|
|
|
ChevronRight as ChevronRightIcon,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
|
|
|
|
interface Column<T = unknown> {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
render?: (item: T) => React.ReactNode;
|
|
|
|
|
sortable?: boolean;
|
|
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Optional expandable-row config. When set, the table adds a small
|
|
|
|
|
* chevron column at the start of each row; clicking it toggles a
|
|
|
|
|
* spanned row beneath with the rendered content. Clicking the chevron
|
|
|
|
|
* does NOT trigger `onRowClick` — the row stays put so the user can
|
|
|
|
|
* read details without navigating away.
|
|
|
|
|
*/
|
|
|
|
|
interface ExpandableConfig<T> {
|
|
|
|
|
isExpanded: (item: T) => boolean;
|
|
|
|
|
onToggle: (item: T) => void;
|
|
|
|
|
renderExpanded: (item: T) => React.ReactNode;
|
|
|
|
|
/** Optional label for the chevron's `aria-label`. */
|
|
|
|
|
ariaLabel?: (item: T) => string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DataTableProps<T = unknown> {
|
|
|
|
|
columns: Column<T>[];
|
|
|
|
|
data: T[];
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
searchPlaceholder?: string;
|
|
|
|
|
onSearch?: (query: string) => void;
|
|
|
|
|
page?: number;
|
|
|
|
|
totalPages?: number;
|
|
|
|
|
onPageChange?: (page: number) => void;
|
|
|
|
|
onRowClick?: (item: T) => void;
|
|
|
|
|
actions?: React.ReactNode;
|
|
|
|
|
mobileCardRender?: (item: T) => React.ReactNode;
|
|
|
|
|
/**
|
|
|
|
|
* Per-row extra class names for the desktop table row. Use for row-
|
|
|
|
|
* level highlights (e.g. background tint for claimed quotes).
|
|
|
|
|
*/
|
|
|
|
|
rowClassName?: (item: T) => string | undefined;
|
|
|
|
|
/**
|
|
|
|
|
* Per-row inline style for the desktop table row. Use to set custom
|
|
|
|
|
* CSS variables or border colours without forcing every consumer to
|
|
|
|
|
* know the table's internal structure.
|
|
|
|
|
*/
|
|
|
|
|
rowStyle?: (item: T) => React.CSSProperties | undefined;
|
|
|
|
|
/**
|
|
|
|
|
* Per-row extra class names for the mobile card wrapper.
|
|
|
|
|
*/
|
|
|
|
|
mobileCardClassName?: (item: T) => string | undefined;
|
|
|
|
|
/**
|
|
|
|
|
* Per-row inline style for the mobile card wrapper.
|
|
|
|
|
*/
|
|
|
|
|
mobileCardStyle?: (item: T) => React.CSSProperties | undefined;
|
|
|
|
|
/**
|
|
|
|
|
* Expandable-row config. When provided, a chevron column is added on
|
|
|
|
|
* desktop and a "Show items" toggle is added to the mobile card.
|
|
|
|
|
*/
|
|
|
|
|
expandable?: ExpandableConfig<T>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DataTable<T>({
|
|
|
|
|
columns,
|
|
|
|
|
data,
|
|
|
|
|
loading = false,
|
|
|
|
|
error,
|
|
|
|
|
searchPlaceholder = "Search...",
|
|
|
|
|
onSearch,
|
|
|
|
|
page = 1,
|
|
|
|
|
totalPages = 1,
|
|
|
|
|
onPageChange,
|
|
|
|
|
onRowClick,
|
|
|
|
|
actions,
|
|
|
|
|
mobileCardRender,
|
|
|
|
|
rowClassName,
|
|
|
|
|
rowStyle,
|
|
|
|
|
mobileCardClassName,
|
|
|
|
|
mobileCardStyle,
|
|
|
|
|
expandable,
|
|
|
|
|
}: DataTableProps<T>) {
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [searchQuery]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
onSearch?.(debouncedQuery);
|
|
|
|
|
}, [debouncedQuery, onSearch]);
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
|
|
|
<Inbox className="h-12 w-12 mb-4" />
|
|
|
|
|
<p className="text-lg font-medium">Error loading data</p>
|
|
|
|
|
<p className="text-sm">{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const desktopColCount = columns.length + (expandable ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
|
|
|
|
{onSearch && (
|
|
|
|
|
<div className="relative w-full sm:max-w-sm">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={searchPlaceholder}
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
className="pl-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{actions && <div className="flex gap-2">{actions}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
) : data.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
|
|
|
<Inbox className="h-12 w-12 mb-4" />
|
|
|
|
|
<p className="text-lg font-medium">No records found</p>
|
|
|
|
|
<p className="text-sm">Create your first record to get started</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Desktop table */}
|
|
|
|
|
<div className="hidden md:block rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
{expandable && <TableHead className="w-8 p-0" />}
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
<TableHead key={col.key} className={col.className}>
|
|
|
|
|
{col.label}
|
|
|
|
|
</TableHead>
|
|
|
|
|
))}
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{data.map((item, i) => {
|
|
|
|
|
const expanded = expandable?.isExpanded(item) ?? false;
|
|
|
|
|
const rowKey =
|
|
|
|
|
((item as unknown as Record<string, unknown>).id as string) || String(i);
|
|
|
|
|
return (
|
|
|
|
|
<ExpandableRow
|
|
|
|
|
key={rowKey}
|
|
|
|
|
item={item}
|
|
|
|
|
columns={columns}
|
|
|
|
|
onRowClick={onRowClick}
|
|
|
|
|
rowClassName={rowClassName}
|
|
|
|
|
rowStyle={rowStyle}
|
|
|
|
|
expandable={expandable}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
colSpan={desktopColCount}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile cards */}
|
|
|
|
|
{mobileCardRender && (
|
|
|
|
|
<div className="md:hidden space-y-3">
|
|
|
|
|
{data.map((item, i) => {
|
|
|
|
|
const expanded = expandable?.isExpanded(item) ?? false;
|
|
|
|
|
const rowKey =
|
|
|
|
|
((item as unknown as Record<string, unknown>).id as string) || String(i);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={rowKey}
|
|
|
|
|
className={mobileCardClassName?.(item)}
|
|
|
|
|
style={mobileCardStyle?.(item)}
|
|
|
|
|
>
|
|
|
|
|
{mobileCardRender(item)}
|
|
|
|
|
{expandable && (
|
|
|
|
|
<div className="px-4 pb-3 pt-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 px-2 text-xs w-full justify-start"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
expandable.onToggle(item);
|
|
|
|
|
}}
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRightIcon
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-3.5 w-3.5 mr-1 transition-transform",
|
|
|
|
|
expanded && "rotate-90"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{expanded ? "Hide items" : "Show items"}
|
|
|
|
|
</Button>
|
|
|
|
|
{expanded && (
|
|
|
|
|
<div className="mt-2 border-t pt-2">
|
|
|
|
|
{expandable.renderExpanded(item)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Pagination */}
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Page {page} of {totalPages}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onPageChange?.(page - 1)}
|
|
|
|
|
disabled={page <= 1}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onPageChange?.(page + 1)}
|
|
|
|
|
disabled={page >= totalPages}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Internal: a single data row plus its optional expanded panel below.
|
|
|
|
|
* Pulled out of the map so we don't have to write the colSpan/expand
|
|
|
|
|
* logic inline twice.
|
|
|
|
|
*/
|
|
|
|
|
function ExpandableRow<T>({
|
|
|
|
|
item,
|
|
|
|
|
columns,
|
|
|
|
|
onRowClick,
|
|
|
|
|
rowClassName,
|
|
|
|
|
rowStyle,
|
|
|
|
|
expandable,
|
|
|
|
|
expanded,
|
|
|
|
|
colSpan,
|
|
|
|
|
}: {
|
|
|
|
|
item: T;
|
|
|
|
|
columns: Column<T>[];
|
|
|
|
|
onRowClick?: (item: T) => void;
|
|
|
|
|
rowClassName?: (item: T) => string | undefined;
|
|
|
|
|
rowStyle?: (item: T) => React.CSSProperties | undefined;
|
|
|
|
|
expandable?: ExpandableConfig<T>;
|
|
|
|
|
expanded: boolean;
|
|
|
|
|
colSpan: number;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<TableRow
|
|
|
|
|
className={cn(
|
|
|
|
|
onRowClick && "cursor-pointer",
|
|
|
|
|
rowClassName?.(item)
|
|
|
|
|
)}
|
|
|
|
|
style={rowStyle?.(item)}
|
|
|
|
|
onClick={() => onRowClick?.(item)}
|
|
|
|
|
data-expanded={expanded || undefined}
|
|
|
|
|
>
|
|
|
|
|
{expandable && (
|
|
|
|
|
<TableCell className="w-8 p-0 text-center">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
expandable.onToggle(item);
|
|
|
|
|
}}
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
aria-label={
|
|
|
|
|
expandable.ariaLabel
|
|
|
|
|
? expandable.ariaLabel(item)
|
|
|
|
|
: expanded
|
|
|
|
|
? "Hide details"
|
|
|
|
|
: "Show details"
|
|
|
|
|
}
|
|
|
|
|
className="inline-flex items-center justify-center h-8 w-8 rounded hover:bg-accent/60 text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRightIcon
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-4 w-4 transition-transform duration-150",
|
|
|
|
|
expanded && "rotate-90"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</TableCell>
|
|
|
|
|
)}
|
|
|
|
|
{columns.map((col) => (
|
|
|
|
|
<TableCell key={col.key} className={col.className}>
|
|
|
|
|
{col.render
|
|
|
|
|
? col.render(item)
|
|
|
|
|
: String(((item as unknown as Record<string, unknown>)[col.key]) ?? "")}
|
|
|
|
|
</TableCell>
|
|
|
|
|
))}
|
|
|
|
|
</TableRow>
|
|
|
|
|
{expandable && expanded && (
|
|
|
|
|
<TableRow
|
|
|
|
|
// The expanded panel is not clickable — we stop propagation so
|
|
|
|
|
// clicking inside the panel (e.g. on a link or button) doesn't
|
|
|
|
|
// bubble up and trigger the row's onClick handler.
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="bg-muted/30 hover:bg-muted/30"
|
|
|
|
|
>
|
|
|
|
|
<TableCell colSpan={colSpan} className="p-0">
|
|
|
|
|
{expandable.renderExpanded(item)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|