interface Column { key: string; label: string; render: (item: T) => React.ReactNode; className?: string; } // F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only // component pre-F-1 — every consumer page handed it the first 50 // rows from a paginated endpoint and there was no way for the // operator to advance. The backend has always returned `{data, // total, page, per_page}` but the frontend never surfaced page // 2+. The pagination prop below opt-ins reusable controls in the // table footer; CertificatesPage is the first consumer (and the // audit's flagged page), but TargetsPage / IssuersPage / others // can adopt by passing the same prop. interface PaginationProps { page: number; perPage: number; total: number; onPageChange: (page: number) => void; onPerPageChange?: (perPage: number) => void; perPageOptions?: number[]; } interface DataTableProps { columns: Column[]; data: T[]; onRowClick?: (item: T) => void; emptyMessage?: string; isLoading?: boolean; keyField?: string; selectable?: boolean; selectedKeys?: Set; onSelectionChange?: (keys: Set) => void; pagination?: PaginationProps; } export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { if (isLoading) { return (
Loading...
); } if (!data.length) { return (
{emptyMessage || 'No data found'}
); } const allKeys = data.map((item) => (item as Record)[keyField] as string); const allSelected = selectable && selectedKeys && allKeys.length > 0 && allKeys.every(k => selectedKeys.has(k)); const toggleAll = () => { if (!onSelectionChange) return; if (allSelected) { onSelectionChange(new Set()); } else { onSelectionChange(new Set(allKeys)); } }; const toggleOne = (key: string) => { if (!onSelectionChange || !selectedKeys) return; const next = new Set(selectedKeys); if (next.has(key)) next.delete(key); else next.add(key); onSelectionChange(next); }; return (
{selectable && ( )} {columns.map(col => ( ))} {data.map((item, i) => { const rowKey = (item as Record)[keyField] as string ?? `row-${i}`; const isSelected = selectable && selectedKeys?.has(rowKey); return ( onRowClick?.(item)} className={`border-b border-surface-border/50 transition-colors hover:bg-surface-muted ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-brand-50' : ''}`} > {selectable && ( )} {columns.map(col => ( ))} ); })}
{col.label}
{ e.stopPropagation(); toggleOne(rowKey); }} onClick={(e) => e.stopPropagation()} className="rounded border-surface-border bg-white text-brand-500 focus:ring-brand-500 focus:ring-offset-0 cursor-pointer" /> {col.render(item)}
{pagination && pagination.total > 0 && ( )}
); } // F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable // consumers that want prev/next + page counter + per-page selector // against a paginated backend response. Disabling logic guards the // boundaries (prev disabled on page 1; next disabled when page * // per_page >= total). function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) { const start = total === 0 ? 0 : (page - 1) * perPage + 1; const end = Math.min(page * perPage, total); const lastPage = Math.max(1, Math.ceil(total / perPage)); const isFirst = page <= 1; const isLast = page >= lastPage; const options = perPageOptions ?? [25, 50, 100, 200]; return (
Showing {start}{end} of {total.toLocaleString()}
{onPerPageChange && ( )} Page {page} of {lastPage}
); } export type { Column, PaginationProps };