// components/ui/Table.tsx 'use client'; import * as React from 'react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; import LoadingSpinner from '@/components/ui/LoadingSpinner'; // 👈 Neu 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; /** Optional: Standard-Sortierspalte */ defaultSortKey?: keyof T; /** Optional: Standard-Sortierrichtung */ defaultSortDirection?: SortDirection; /** Optional: Wenn true, wird statt der Zeilen ein LoadingSpinner angezeigt */ isLoading?: boolean; // 👈 Neu } type SortState = { key: keyof T | null; direction: SortDirection; }; export default function Table(props: TableProps) { const { data, columns, getRowId, selectable = false, onSelectionChange, renderActions, actionsHeader = '', defaultSortKey, defaultSortDirection = 'asc', isLoading = false, // 👈 Neu } = props; const [sort, setSort] = React.useState>({ key: defaultSortKey ?? null, direction: defaultSortDirection, }); 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; if (typeof va === 'number' && typeof vb === 'number') { return sort.direction === 'asc' ? va - vb : vb - va; } if (typeof va === 'string' && typeof vb === 'string') { const na = Number(va); const nb = Number(vb); if (!Number.isNaN(na) && !Number.isNaN(nb)) { return sort.direction === 'asc' ? na - nb : nb - na; } } 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) { return { key, direction: prev.direction === 'asc' ? 'desc' : '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], ); } const colSpan = columns.length + (selectable ? 1 : 0) + (renderActions ? 1 : 0); return (
{selectable && ( )} {columns.map((col) => { const isSorted = sort.key === col.key; const isSortable = col.sortable; const isHideable = col.canHide; return ( ); })} {renderActions && ( )} {isLoading ? ( // 🔹 Loading-State: Spinner-Zeile statt Daten ) : sortedData.length === 0 ? ( // 🔹 Empty-State ) : ( // 🔹 Normale Zeilen sortedData.map((row) => { const id = getRowId(row); const isSelected = selectedIds.includes(id); return ( {selectable && ( )} {columns.map((col) => ( ))} {renderActions && ( )} ); }) )}
{isSortable ? ( ) : ( col.header )} {actionsHeader}
Wird geladen …
Keine Einträge vorhanden.
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)}
); }