215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
// ---------------------------------------------------------------------------
|
||
// DataTable — pre-built sortable, paginated table wired to AnyDB
|
||
// ---------------------------------------------------------------------------
|
||
// PRE-INSTALLED. CodeWriterAgent just uses:
|
||
// import { DataTable } from "@/components/DataTable"
|
||
// <DataTable
|
||
// collection="orders"
|
||
// columns={[
|
||
// { key: 'customer', label: 'Customer' },
|
||
// { key: 'total_cents', label: 'Amount', render: (v) => `$${(v/100).toFixed(2)}` },
|
||
// { key: 'status', label: 'Status', render: (v) => <Badge>{v}</Badge> },
|
||
// { key: 'created_at', label: 'Date', render: (v) => new Date(v).toLocaleDateString() },
|
||
// ]}
|
||
// filter={{ status: 'active' }}
|
||
// pageSize={10}
|
||
// searchField="customer"
|
||
// />
|
||
// ---------------------------------------------------------------------------
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { useCollection } from "@/hooks/use-anydb";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { ChevronUp, ChevronDown, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||
|
||
interface Column<T = any> {
|
||
key: string;
|
||
label: string;
|
||
sortable?: boolean;
|
||
render?: (value: any, row: T) => React.ReactNode;
|
||
className?: string;
|
||
}
|
||
|
||
interface DataTableProps<T = any> {
|
||
collection: string;
|
||
columns: Column<T>[];
|
||
filter?: Record<string, any>;
|
||
pageSize?: number;
|
||
searchField?: string;
|
||
searchPlaceholder?: string;
|
||
onRowClick?: (row: T) => void;
|
||
emptyMessage?: string;
|
||
className?: string;
|
||
}
|
||
|
||
export function DataTable<T extends Record<string, any> = any>({
|
||
collection,
|
||
columns,
|
||
filter = {},
|
||
pageSize = 10,
|
||
searchField,
|
||
searchPlaceholder = "Search...",
|
||
onRowClick,
|
||
emptyMessage = "No data found",
|
||
className = "",
|
||
}: DataTableProps<T>) {
|
||
const [page, setPage] = useState(0);
|
||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||
const [sortDir, setSortDir] = useState<1 | -1>(-1);
|
||
const [search, setSearch] = useState("");
|
||
|
||
const queryFilter = useMemo(() => {
|
||
const f = { ...filter };
|
||
if (searchField && search.trim()) {
|
||
f[searchField] = { $regex: search.trim(), $options: "i" };
|
||
}
|
||
return f;
|
||
}, [filter, searchField, search]);
|
||
|
||
const sort = sortKey ? { [sortKey]: sortDir } : { created_at: -1 as const };
|
||
|
||
const { data, total, loading, error } = useCollection<T>(collection, {
|
||
filter: queryFilter,
|
||
sort,
|
||
limit: pageSize,
|
||
skip: page * pageSize,
|
||
});
|
||
|
||
const totalPages = Math.ceil(total / pageSize);
|
||
|
||
const handleSort = (key: string) => {
|
||
if (sortKey === key) {
|
||
setSortDir(d => (d === 1 ? -1 : 1));
|
||
} else {
|
||
setSortKey(key);
|
||
setSortDir(-1);
|
||
}
|
||
setPage(0);
|
||
};
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-8 text-destructive">
|
||
<p>Failed to load data: {error}</p>
|
||
<Button variant="outline" size="sm" className="mt-2" onClick={() => window.location.reload()}>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`rounded-lg border bg-card ${className}`}>
|
||
{/* Search bar */}
|
||
{searchField && (
|
||
<div className="p-4 border-b">
|
||
<div className="relative 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={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||
className="pl-9 h-10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Table */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b bg-muted/50">
|
||
{columns.map((col) => (
|
||
<th
|
||
key={col.key}
|
||
className={`px-4 py-3 text-left font-medium text-muted-foreground ${
|
||
col.sortable !== false ? "cursor-pointer select-none hover:text-foreground transition-colors" : ""
|
||
} ${col.className || ""}`}
|
||
onClick={() => col.sortable !== false && handleSort(col.key)}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
{col.label}
|
||
{col.sortable !== false && sortKey === col.key && (
|
||
sortDir === 1 ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
||
)}
|
||
</div>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
Array.from({ length: pageSize }).map((_, i) => (
|
||
<tr key={i} className="border-b">
|
||
{columns.map((col) => (
|
||
<td key={col.key} className="px-4 py-3">
|
||
<Skeleton className="h-5 w-full" />
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))
|
||
) : data.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||
{emptyMessage}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
data.map((row, i) => (
|
||
<tr
|
||
key={row._id || i}
|
||
className={`border-b transition-colors hover:bg-muted/30 ${
|
||
onRowClick ? "cursor-pointer" : ""
|
||
}`}
|
||
onClick={() => onRowClick?.(row)}
|
||
>
|
||
{columns.map((col) => (
|
||
<td key={col.key} className={`px-4 py-3 ${col.className || ""}`}>
|
||
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? "")}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||
<p className="text-sm text-muted-foreground">
|
||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page === 0}
|
||
onClick={() => setPage(p => p - 1)}
|
||
className="h-8"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-sm tabular-nums">
|
||
{page + 1} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page >= totalPages - 1}
|
||
onClick={() => setPage(p => p + 1)}
|
||
className="h-8"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|