UMBRA/frontend/src/components/shared/EntityTable.tsx
Kyle Pope 1b78dadf75 Fix bugs and action remaining QA suggestions
Bugs fixed:
- formatUpdatedAt treats naive UTC timestamps as UTC (append Z before parsing)
- PersonForm/LocationForm X button now inline with star toggle, matching panel style
- LocationForm contact placeholder changed from +44 to +61

QA suggestions actioned:
- CategoryAutocomplete: replace blur setTimeout with onPointerDown preventDefault
- CategoryFilterBar: replace hardcoded 600px maxWidth with 100vw
- Location "other" category shows dash instead of styled badge
- Delete dead legacy constants files (people/constants.ts, locations/constants.ts)
- EntityTable rows: add tabIndex, Enter/Space keyboard navigation, focus ring
- Replace Record<string, unknown> casts with typed keyof accessors
- Add email validation (field_validator) to Person and Location schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:46:38 +08:00

156 lines
4.6 KiB
TypeScript

import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { VisibilityMode } from '@/hooks/useTableVisibility';
export interface ColumnDef<T> {
key: string;
label: string;
render: (item: T) => React.ReactNode;
sortable?: boolean;
visibilityLevel: VisibilityMode;
}
interface EntityTableProps<T extends { id: number }> {
columns: ColumnDef<T>[];
rows: T[];
pinnedRows: T[];
pinnedLabel: string;
showPinned: boolean;
selectedId: number | null;
onRowClick: (id: number) => void;
sortKey: string;
sortDir: 'asc' | 'desc';
onSort: (key: string) => void;
visibilityMode: VisibilityMode;
loading?: boolean;
}
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
function isVisible(colLevel: VisibilityMode, mode: VisibilityMode): boolean {
return LEVEL_ORDER.indexOf(colLevel) <= LEVEL_ORDER.indexOf(mode);
}
function SkeletonRow({ colCount }: { colCount: number }) {
return (
<tr className="border-b border-border/50">
{Array.from({ length: colCount }).map((_, i) => (
<td key={i} className="px-3 py-2.5">
<div className="animate-pulse rounded-md bg-muted h-4 w-full" />
</td>
))}
</tr>
);
}
export function EntityTable<T extends { id: number }>({
columns,
rows,
pinnedRows,
pinnedLabel,
showPinned,
selectedId,
onRowClick,
sortKey,
sortDir,
onSort,
visibilityMode,
loading = false,
}: EntityTableProps<T>) {
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
const colCount = visibleColumns.length;
const showPinnedSection = showPinned && pinnedRows.length > 0;
const SortIcon = ({ colKey }: { colKey: string }) => {
if (sortKey !== colKey) return <ArrowUpDown className="h-3.5 w-3.5 ml-1 opacity-40" />;
return sortDir === 'asc' ? (
<ArrowUp className="h-3.5 w-3.5 ml-1" />
) : (
<ArrowDown className="h-3.5 w-3.5 ml-1" />
);
};
const DataRow = ({ item }: { item: T }) => (
<tr
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring ${
selectedId === item.id ? 'bg-accent/10' : ''
}`}
onClick={() => onRowClick(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(item.id);
}
}}
tabIndex={0}
role="row"
aria-selected={selectedId === item.id}
>
{visibleColumns.map((col) => (
<td key={col.key} className="px-3 py-2.5 text-sm">
{col.render(item)}
</td>
))}
</tr>
);
return (
<div className="w-full">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{visibleColumns.map((col) => (
<th
key={col.key}
className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
>
{col.sortable ? (
<button
type="button"
onClick={() => onSort(col.key)}
aria-label={`Sort by ${col.label}`}
className="flex items-center hover:text-foreground transition-colors duration-150"
>
{col.label}
<SortIcon colKey={col.key} />
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
) : (
<>
{showPinnedSection && (
<>
<tr>
<td
colSpan={colCount}
className="px-3 py-1.5 text-[11px] uppercase tracking-wider text-muted-foreground"
>
{pinnedLabel}
</td>
</tr>
{pinnedRows.map((item) => (
<DataRow key={item.id} item={item} />
))}
<tr>
<td colSpan={colCount} className="border-b border-border/50" />
</tr>
</>
)}
{rows.map((item) => (
<DataRow key={item.id} item={item} />
))}
</>
)}
</tbody>
</table>
</div>
);
}