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>
156 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|