// frontend/src/components/Tables.tsx import { Fragment, type ReactNode, useLayoutEffect, useRef, useState, } from 'react' import { ChevronDownIcon } from '@heroicons/react/20/solid' import Button from './Button' export type TableVariant = | 'simple' | 'card' | 'full-width' | 'constrained' | 'striped' | 'uppercase' | 'stacked-mobile' | 'hidden-mobile' | 'avatars' | 'sticky-header' | 'vertical-lines' | 'condensed' | 'sortable' | 'grouped' | 'summary' | 'border' export type TableColumn = { key: string header: string render: (row: T) => ReactNode mobileRender?: (row: T) => ReactNode isPrimary?: boolean sortable?: boolean hideOnMobile?: 'sm' | 'md' | 'lg' align?: 'left' | 'center' | 'right' className?: string headerClassName?: string } export type TableGroup = { id: string | number name: string rows: T[] } export type TableSummaryRow = { label: ReactNode value: ReactNode emphasized?: boolean colSpan?: number } type SortDirection = 'asc' | 'desc' type TablesProps = { title?: string description?: string rows?: T[] groups?: TableGroup[] columns: TableColumn[] getRowId: (row: T) => string | number variant?: TableVariant emptyText?: string actionLabel?: string onAction?: () => void headerAction?: ReactNode rowActions?: (row: T) => ReactNode selectable?: boolean selectedRowIds?: Array onSelectedRowIdsChange?: (ids: Array) => void sortKey?: string sortDirection?: SortDirection onSort?: (column: TableColumn) => void summaryRows?: TableSummaryRow[] hideHeadings?: boolean stickyTopClassName?: string className?: string } function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } function getAlignmentClass(align?: TableColumn['align']) { if (align === 'right') { return 'text-right' } if (align === 'center') { return 'text-center' } return 'text-left' } function getHiddenClass(hideOnMobile?: TableColumn['hideOnMobile']) { if (hideOnMobile === 'sm') { return 'hidden sm:table-cell' } if (hideOnMobile === 'md') { return 'hidden md:table-cell' } if (hideOnMobile === 'lg') { return 'hidden lg:table-cell' } return '' } function Checkbox({ checked, onChange, inputRef, value, }: { checked: boolean onChange: (checked: boolean) => void inputRef?: React.Ref value?: string }) { return (
onChange(event.target.checked)} 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 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 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 dark:disabled:border-white/10 dark:disabled:bg-gray-800 dark:disabled:checked:bg-gray-800 forced-colors:appearance-auto" />
) } export default function Tables({ title, description, rows = [], groups, columns, getRowId, variant = 'simple', emptyText = 'Keine Einträge vorhanden.', actionLabel, onAction, headerAction, rowActions, selectable = false, selectedRowIds, onSelectedRowIdsChange, sortKey, sortDirection = 'asc', onSort, summaryRows, hideHeadings = false, stickyTopClassName = 'top-0', className, }: TablesProps) { const headerCheckboxRef = useRef(null) const [internalSelectedRowIds, setInternalSelectedRowIds] = useState>([]) const rowGroups = groups && groups.length > 0 ? groups : [ { id: 'default', name: '', rows, }, ] const allRows = rowGroups.flatMap((group) => group.rows) const allRowIds = allRows.map(getRowId) const selectedIds = selectedRowIds ?? internalSelectedRowIds const selectedSet = new Set(selectedIds) const checked = allRowIds.length > 0 && selectedIds.length === allRowIds.length const indeterminate = selectedIds.length > 0 && selectedIds.length < allRowIds.length const isCard = variant === 'card' const isBorder = variant === 'border' const isFullWidth = variant === 'full-width' const isConstrained = variant === 'constrained' const isStriped = variant === 'striped' const isUppercase = variant === 'uppercase' const isStackedMobile = variant === 'stacked-mobile' const isStickyHeader = variant === 'sticky-header' const isVerticalLines = variant === 'vertical-lines' const isCondensed = variant === 'condensed' const isSortable = variant === 'sortable' const isGrouped = variant === 'grouped' || Boolean(groups?.length) const hasSummary = variant === 'summary' || Boolean(summaryRows?.length) const totalColumns = columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0) useLayoutEffect(() => { if (!headerCheckboxRef.current) { return } headerCheckboxRef.current.indeterminate = indeterminate }, [indeterminate]) function updateSelected(nextSelectedIds: Array) { if (!selectedRowIds) { setInternalSelectedRowIds(nextSelectedIds) } onSelectedRowIdsChange?.(nextSelectedIds) } function toggleAll(nextChecked: boolean) { updateSelected(nextChecked ? allRowIds : []) } function toggleRow(rowId: string | number, nextChecked: boolean) { if (nextChecked) { updateSelected(Array.from(new Set([...selectedIds, rowId]))) return } updateSelected(selectedIds.filter((id) => id !== rowId)) } function renderHeaderAction() { if (headerAction) { return headerAction } if (!actionLabel) { return null } return ( ) } function renderHeader() { if (!title && !description && !actionLabel && !headerAction) { return null } return (
{title && (

{title}

)} {description && (

{description}

)}
{(actionLabel || headerAction) && (
{renderHeaderAction()}
)}
) } function renderColumnHeader(column: TableColumn) { const activeSortColumn = sortKey === column.key const showSortIcon = isSortable || column.sortable if (!showSortIcon) { return column.header } return ( ) } function renderTableHead() { return ( {selectable && ( )} {columns.map((column, columnIndex) => { const isFirst = columnIndex === 0 const isLast = columnIndex === columns.length - 1 && !rowActions const hiddenClass = getHiddenClass(column.hideOnMobile) return ( {renderColumnHeader(column)} ) })} {rowActions && ( Aktionen )} ) } function renderRow(row: T, rowIndex: number) { const rowId = getRowId(row) const selected = selectedSet.has(rowId) return ( {selectable && ( {selected && (
)} toggleRow(rowId, nextChecked)} /> )} {columns.map((column, columnIndex) => { const isFirst = columnIndex === 0 const isLast = columnIndex === columns.length - 1 && !rowActions const hiddenClass = getHiddenClass(column.hideOnMobile) return ( {column.render(row)} {isStackedMobile && column.isPrimary && column.mobileRender && (
{column.mobileRender(row)}
)} ) })} {rowActions && ( {rowActions(row)} )} ) } function renderGroupedRows() { return rowGroups.map((group) => ( {isGrouped && group.name && ( {group.name} )} {group.rows.map(renderRow)} )) } function renderEmptyRow() { if (allRows.length > 0) { return null } return ( {emptyText} ) } function renderSummaryRows() { if (!hasSummary || !summaryRows?.length) { return null } return ( {summaryRows.map((row, index) => { const colSpan = row.colSpan ?? Math.max(totalColumns - 1, 1) return ( {row.label} {row.value} ) })} ) } function renderTable() { return ( {renderTableHead()} {renderEmptyRow()} {renderGroupedRows()} {renderSummaryRows()}
) } return (
{renderHeader()}
{isCard || isBorder ? (
{renderTable()}
) : ( renderTable() )}
) } function isAvatarVariant(variant: TableVariant) { return variant === 'avatars' }