'use client' import * as React from 'react' import { ChevronDownIcon } from '@heroicons/react/20/solid' type Align = 'left' | 'center' | 'right' export type SortDirection = 'asc' | 'desc' export type SortState = { key: string; direction: SortDirection } | null export type Column = { key: string header: React.ReactNode widthClassName?: string align?: Align className?: string headerClassName?: string /** Wenn gesetzt: so wird die Zelle gerendert */ cell?: (row: T, rowIndex: number) => React.ReactNode /** Standard: row[col.key] */ accessor?: (row: T) => React.ReactNode /** Optional: sr-only Header (z.B. für Action-Spalte) */ srOnlyHeader?: boolean /** Sortieren aktivieren (Header wird klickbar) */ sortable?: boolean /** * Wert, nach dem sortiert werden soll (empfohlen!), * z.B. number/string/Date/boolean. * Fallback ist row[col.key]. */ sortValue?: (row: T) => string | number | boolean | Date | null | undefined /** Optional: komplett eigene Vergleichsfunktion */ sortFn?: (a: T, b: T) => number } export type TableProps = { columns: Array> rows: T[] getRowKey?: (row: T, index: number) => string /** Optionaler Titelbereich über der Tabelle */ title?: React.ReactNode description?: React.ReactNode actions?: React.ReactNode /** Styling / Layout */ fullWidth?: boolean card?: boolean striped?: boolean stickyHeader?: boolean compact?: boolean /** States */ isLoading?: boolean emptyLabel?: React.ReactNode className?: string rowClassName?: (row: T, rowIndex: number) => string | undefined onRowClick?: (row: T) => void onRowContextMenu?: (row: T, e: React.MouseEvent) => void /** * Controlled Sorting: * - sort: aktueller Sort-Status * - onSortChange: wird bei Klick auf Header aufgerufen */ sort?: SortState onSortChange?: (next: SortState) => void /** * Uncontrolled Sorting: * Initiale Sortierung (wenn sort nicht gesetzt ist) */ defaultSort?: SortState } function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } function alignTd(a?: Align) { if (a === 'center') return 'text-center' if (a === 'right') return 'text-right' return 'text-left' } function justifyForAlign(a?: Align) { if (a === 'center') return 'justify-center' if (a === 'right') return 'justify-end' return 'justify-start' } function xPadForColumn(colIndex: number, totalCols: number) { const isFirst = colIndex === 0 const isLast = colIndex === totalCols - 1 // außen weniger Padding, innen normal if (isFirst) return 'pl-2 pr-2' if (isLast) return 'pl-2 pr-2' return 'px-2' } function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } { if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' } if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() } if (typeof v === 'number') return { isNull: false, kind: 'number', value: v } if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 } if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) } // strings & sonstiges return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() } } export default function Table({ columns, rows, getRowKey, title, description, actions, fullWidth = false, card = true, striped = false, stickyHeader = false, compact = false, isLoading = false, emptyLabel = 'Keine Daten vorhanden.', className, rowClassName, onRowClick, onRowContextMenu, sort, onSortChange, defaultSort = null, }: TableProps) { const cellY = compact ? 'py-2' : 'py-4' const headY = compact ? 'py-3' : 'py-3.5' const isControlled = sort !== undefined const [internalSort, setInternalSort] = React.useState(defaultSort) const sortState = isControlled ? sort! : internalSort const setSortState = React.useCallback( (next: SortState) => { if (!isControlled) setInternalSort(next) onSortChange?.(next) }, [isControlled, onSortChange] ) const sortedRows = React.useMemo(() => { if (!sortState) return rows const col = columns.find((c) => c.key === sortState.key) if (!col) return rows const dirMul = sortState.direction === 'asc' ? 1 : -1 const decorated = rows.map((r, i) => ({ r, i })) decorated.sort((x, y) => { let res = 0 if (col.sortFn) { res = col.sortFn(x.r, y.r) } else { const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key] const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key] const na = normalizeSortValue(ax) const nb = normalizeSortValue(by) // nulls immer nach hinten if (na.isNull && !nb.isNull) res = 1 else if (!na.isNull && nb.isNull) res = -1 else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0 else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true }) } if (res === 0) return x.i - y.i // stable return res * dirMul }) return decorated.map((d) => d.r) }, [rows, columns, sortState]) return (
{(title || description || actions) && (
{title && (

{title}

)} {description && (

{description}

)}
{actions && (
{actions}
)}
)}
{columns.map((col, colIndex) => { const xPad = xPadForColumn(colIndex, columns.length) const isSorted = !!sortState && sortState.key === col.key const dir = isSorted ? sortState!.direction : undefined const ariaSort = col.sortable && !col.srOnlyHeader ? isSorted ? dir === 'asc' ? 'ascending' : 'descending' : 'none' : undefined const nextSort = () => { if (!col.sortable || col.srOnlyHeader) return if (!isSorted) return setSortState({ key: col.key, direction: 'asc' }) if (dir === 'asc') return setSortState({ key: col.key, direction: 'desc' }) return setSortState(null) } return ( ) })} {isLoading ? ( ) : sortedRows.length === 0 ? ( ) : ( sortedRows.map((row, rowIndex) => { const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex) return ( onRowClick?.(row)} onContextMenu={ onRowContextMenu ? (e) => { e.preventDefault() onRowContextMenu(row, e) } : undefined } > {columns.map((col, colIndex) => { const xPad = xPadForColumn(colIndex, columns.length) const content = col.cell?.(row, rowIndex) ?? col.accessor?.(row) ?? (row as any)?.[col.key] return ( ) })} ) }) )}
{col.srOnlyHeader ? ( {col.header} ) : col.sortable ? ( ) : ( col.header )}
Lädt…
{emptyLabel}
{content}
) }