369 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|