// components/ui/Table.tsx 'use client'; import * as React from 'react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; function classNames(...classes: Array) { return classes.filter(Boolean).join(' '); } export type SortDirection = 'asc' | 'desc'; export type TableColumn = { /** Schlüssel im Datensatz, z.B. 'inventoryNumber' */ key: keyof T; /** Spaltenüberschrift */ header: string; /** Kann die Spalte sortiert werden? */ sortable?: boolean; /** Kann die Spalte ausgeblendet werden? */ canHide?: boolean, /** Optional eigene Klassen für die TH-Zelle */ headerClassName?: string; /** Optional eigene Klassen für die TD-Zelle */ cellClassName?: string; /** Custom Renderer für Zellen (z.B. formatDate) */ render?: (row: T) => React.ReactNode; }; export interface TableProps { data: T[]; columns: TableColumn[]; /** Eindeutige ID pro Zeile */ getRowId: (row: T) => string; /** Checkboxen + Auswahl aktivieren */ selectable?: boolean; /** Callback bei Änderung der Auswahl */ onSelectionChange?: (selected: T[]) => void; /** Optional: Actions in der letzten Spalte rendern */ renderActions?: (row: T) => React.ReactNode; /** Optional: Header-Text für die Actions-Spalte */ actionsHeader?: string; } type SortState = { key: keyof T | null; direction: SortDirection; }; export default function Table(props: TableProps) { const { data, columns, getRowId, selectable = false, onSelectionChange, renderActions, actionsHeader = '', } = props; const [sort, setSort] = React.useState>({ key: null, direction: 'asc', }); const [selectedIds, setSelectedIds] = React.useState([]); const headerCheckboxRef = React.useRef(null); // Sortierte Daten const sortedData = React.useMemo(() => { if (!sort.key) return data; const col = columns.find((c) => c.key === sort.key); if (!col) return data; const sorted = [...data].sort((a, b) => { const va = (a as any)[sort.key!]; const vb = (b as any)[sort.key!]; if (va == null && vb == null) return 0; if (va == null) return sort.direction === 'asc' ? -1 : 1; if (vb == null) return sort.direction === 'asc' ? 1 : -1; // Numbers if (typeof va === 'number' && typeof vb === 'number') { return sort.direction === 'asc' ? va - vb : vb - va; } // Date / ISO-String const sa = va instanceof Date ? va.getTime() : String(va); const sb = vb instanceof Date ? vb.getTime() : String(vb); if (sa < sb) return sort.direction === 'asc' ? -1 : 1; if (sa > sb) return sort.direction === 'asc' ? 1 : -1; return 0; }); return sorted; }, [data, columns, sort]); // Selection / Tri-State Header Checkbox React.useLayoutEffect(() => { if (!selectable || !headerCheckboxRef.current) return; const allIds = sortedData.map((row) => getRowId(row)); const checkedCount = allIds.filter((id) => selectedIds.includes(id)).length; const isAll = checkedCount === allIds.length && allIds.length > 0; const isNone = checkedCount === 0; const isIndeterminate = !isAll && !isNone; headerCheckboxRef.current.checked = isAll; headerCheckboxRef.current.indeterminate = isIndeterminate; }, [sortedData, selectedIds, selectable, getRowId]); React.useEffect(() => { if (!onSelectionChange) return; const selectedRows = sortedData.filter((row) => selectedIds.includes(getRowId(row))); onSelectionChange(selectedRows); }, [selectedIds, sortedData, getRowId, onSelectionChange]); function toggleSort(key: keyof T) { setSort((prev) => { if (prev.key === key) { // gleiche Spalte -> Richtung flippen return { key, direction: prev.direction === 'asc' ? 'desc' : 'asc', }; } // neue Spalte -> asc return { key, direction: 'asc' }; }); } function toggleAll() { if (!selectable) return; const allIds = sortedData.map((row) => getRowId(row)); const allSelected = allIds.length > 0 && allIds.every((id) => selectedIds.includes(id)); setSelectedIds(allSelected ? [] : allIds); } function toggleRow(id: string) { if (!selectable) return; setSelectedIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], ); } return (
{/* Wichtig: auf kleinen Screens overflow-x-visible, erst ab lg overflow-x-auto */}
{selectable && ( )} {columns.map((col) => { const isSorted = sort.key === col.key; const isSortable = col.sortable; const isHideable = col.canHide; return ( ); })} {renderActions && ( )} {sortedData.map((row) => { const id = getRowId(row); const isSelected = selectedIds.includes(id); return ( {selectable && ( )} {columns.map((col) => ( ))} {renderActions && ( )} ); })} {sortedData.length === 0 && ( )}
{isSortable ? ( ) : ( col.header )} {actionsHeader}
toggleRow(id)} className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500" />
{col.render ? col.render(row) : String((row as any)[col.key] ?? '—')} {renderActions(row)}
Keine Daten vorhanden.
); }