phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { useState, useMemo, type ReactNode } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyField: keyof T & string;
|
||||
pageSize?: number;
|
||||
onRowClick?: (row: T) => void;
|
||||
filterFn?: (row: T, query: string) => boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
keyField,
|
||||
pageSize = 25,
|
||||
onRowClick,
|
||||
filterFn,
|
||||
emptyMessage = 'No data',
|
||||
}: Props<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter || !filterFn) return data;
|
||||
return data.filter((row) => filterFn(row, filter));
|
||||
}, [data, filter, filterFn]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey];
|
||||
const bv = (b as Record<string, unknown>)[sortKey];
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [filtered, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filterFn && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter…"
|
||||
value={filter}
|
||||
onChange={(e) => { setFilter(e.target.value); setPage(0); }}
|
||||
className="mb-3 w-full max-w-xs rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="Filter table"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-surface-700">
|
||||
<table className="w-full text-sm" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 bg-surface-900">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 text-left font-medium text-gray-400 ${col.className ?? ''} ${col.sortable !== false ? 'cursor-pointer select-none hover:text-gray-200' : ''}`}
|
||||
onClick={() => col.sortable !== false && toggleSort(col.key)}
|
||||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable !== false && (
|
||||
sortKey === col.key
|
||||
? (sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)
|
||||
: <ChevronsUpDown size={14} className="opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr
|
||||
key={String((row as Record<string, unknown>)[keyField])}
|
||||
className={`border-b border-surface-700/50 ${onRowClick ? 'cursor-pointer hover:bg-surface-800/50' : ''}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-3 py-2 text-gray-300 ${col.className ?? ''}`}>
|
||||
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{sorted.length} rows</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Previous page">
|
||||
Prev
|
||||
</button>
|
||||
<span className="py-1">{page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Next page">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user