WFDASH-WEBAPP/src/components/data-table.tsx

465 lines
14 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect, useRef } 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("");
const onSearchRef = useRef(onSearch);
useEffect(() => {
onSearchRef.current = onSearch;
}, [onSearch]);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => {
onSearchRef.current?.(debouncedQuery);
}, [debouncedQuery]);
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 — <md (768px). Desktop table switches to
`hidden md:block`, mobile cards take over. Always rendered
so every list page is mobile-usable even without a custom
`mobileCardRender` — the default below reads the first
two columns as a title + subtitle. */}
<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 ? (
mobileCardRender(item)
) : (
<DefaultMobileCard
item={item}
columns={columns}
onRowClick={onRowClick}
/>
)}
{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>
)}
</>
);
}
/**
* Default fallback mobile card — used when a list page didn't pass a
* custom `mobileCardRender`. Renders the first column as the title
* (the row's "name"-equivalent) and the second column as a single
* supporting line; further columns are stacked underneath in muted
* text. The whole card is tappable when `onRowClick` was provided.
*
* Touch targets: 56px+ on the title row when tappable, so phones get
* a comfortable thumb-zone hit area. Description wraps naturally
* (no fixed-width truncation).
*
* Pages with bespoke mobile layouts (e.g. `/sprint`) still pass
* `mobileCardRender` and skip this; this card exists so the other 9+
* list pages are usable on mobile with zero per-page wiring.
*/
function DefaultMobileCard<T>({
item,
columns,
onRowClick,
}: {
item: T;
columns: Column<T>[];
onRowClick?: (item: T) => void;
}) {
const titleCol = columns[0];
const subtitleCol = columns[1];
const tail = columns.slice(2);
const titleNode = titleCol
? titleCol.render
? titleCol.render(item)
: String(
(item as unknown as Record<string, unknown>)[titleCol.key] ?? "",
)
: null;
const subtitleNode = subtitleCol
? subtitleCol.render
? subtitleCol.render(item)
: String(
(item as unknown as Record<string, unknown>)[subtitleCol.key] ?? "",
)
: null;
const CardInner = (
<div
className={cn(
"rounded-md border bg-card text-card-foreground p-3 space-y-1.5",
onRowClick && "cursor-pointer transition-colors hover:bg-muted/30",
)}
>
<div className="font-medium text-sm leading-tight truncate">
{titleNode}
</div>
{subtitleNode && (
<div className="text-xs text-muted-foreground leading-snug line-clamp-2">
{subtitleNode}
</div>
)}
{tail.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 pt-1 border-t border-border/50 text-[11px] text-muted-foreground">
{tail.map((c) => {
const v = c.render
? c.render(item)
: String(
(item as unknown as Record<string, unknown>)[c.key] ?? "",
);
return (
<span key={c.key}>
<span className="text-foreground/60">{c.label}:</span>{" "}
<span className="text-foreground/90">{v}</span>
</span>
);
})}
</div>
)}
</div>
);
if (onRowClick) {
return (
<button
type="button"
onClick={() => onRowClick(item)}
className="block w-full text-left min-h-[56px]"
>
{CardInner}
</button>
);
}
return CardInner;
}