geraete/components/ui/Table.tsx
2025-12-05 13:53:29 +01:00

369 lines
14 KiB
TypeScript

// 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<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
export type SortDirection = 'asc' | 'desc';
export type TableColumn<T> = {
/** 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<T> {
data: T[];
columns: TableColumn<T>[];
/** 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<T> = {
key: keyof T | null;
direction: SortDirection;
};
export default function Table<T>(props: TableProps<T>) {
const {
data,
columns,
getRowId,
selectable = false,
onSelectionChange,
renderActions,
actionsHeader = '',
defaultSortKey,
defaultSortDirection = 'asc',
isLoading = false, // 👈 Neu
} = props;
const [sort, setSort] = React.useState<SortState<T>>({
key: defaultSortKey ?? null,
direction: defaultSortDirection,
});
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const headerCheckboxRef = React.useRef<HTMLInputElement | null>(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 (
<div className="relative overflow-visible rounded-lg border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/40">
<div className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-gray-200 text-left text-sm dark:divide-white/10">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
{selectable && (
<th scope="col" className="w-12 px-4">
<div className="group grid size-4 grid-cols-1">
<input
ref={headerCheckboxRef}
type="checkbox"
onChange={toggleAll}
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"
/>
<svg
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
viewBox="0 0 14 14"
fill="none"
>
<path
className="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</th>
)}
{columns.map((col) => {
const isSorted = sort.key === col.key;
const isSortable = col.sortable;
const isHideable = col.canHide;
return (
<th
key={String(col.key)}
scope="col"
className={classNames(
'py-3.5 px-2 text-left text-sm font-semibold text-gray-900 dark:text-white',
col.headerClassName,
isHideable ? 'hidden lg:table-cell' : '',
)}
>
{isSortable ? (
<button
type="button"
onClick={() => toggleSort(col.key)}
className="group inline-flex items-center"
>
{col.header}
<span
className={classNames(
'ml-2 flex-none rounded-sm',
isSorted
? 'bg-gray-100 text-gray-900 group-hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:group-hover:bg-gray-700'
: 'invisible text-gray-400 group-hover:visible group-focus:visible dark:text-gray-500',
)}
>
<ChevronDownIcon
aria-hidden="true"
className={classNames(
'size-4',
isSorted &&
sort.direction === 'desc' &&
'rotate-180',
)}
/>
</span>
</button>
) : (
col.header
)}
</th>
);
})}
{renderActions && (
<th
scope="col"
className="py-3.5 px-2 text-sm font-semibold text-gray-900 dark:text-white"
>
{actionsHeader}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-900/40">
{isLoading ? (
// 🔹 Loading-State: Spinner-Zeile statt Daten
<tr>
<td
colSpan={colSpan}
className="px-2 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
<div className="flex items-center justify-center gap-3">
<LoadingSpinner />
<span>Wird geladen </span>
</div>
</td>
</tr>
) : sortedData.length === 0 ? (
// 🔹 Empty-State
<tr>
<td
colSpan={colSpan}
className="px-2 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
Keine Einträge vorhanden.
</td>
</tr>
) : (
// 🔹 Normale Zeilen
sortedData.map((row) => {
const id = getRowId(row);
const isSelected = selectedIds.includes(id);
return (
<tr
key={id}
className={classNames(
isSelected && 'bg-gray-50 dark:bg-gray-800/60',
)}
>
{selectable && (
<td className="px-4">
<div className="group grid size-4 grid-cols-1">
<input
type="checkbox"
checked={isSelected}
onChange={() => 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"
/>
<svg
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white"
viewBox="0 0 14 14"
fill="none"
>
<path
className="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</td>
)}
{columns.map((col) => (
<td
key={String(col.key)}
className={classNames(
'px-2 py-3 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300',
col.cellClassName,
col.canHide && 'hidden lg:table-cell',
)}
>
{col.render
? col.render(row)
: String((row as any)[col.key] ?? '—')}
</td>
))}
{renderActions && (
<td className="px-2 py-3 text-sm whitespace-nowrap text-right">
{renderActions(row)}
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}