Files
olive-grove/src/components/DataTable.tsx
T

215 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------------------------------------------------------------------------
// 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>
);
}