"use client"; import React from "react"; import { FeatherArrowDown, FeatherArrowUp, FeatherChevronLeft, FeatherChevronRight, FeatherSearch, } from "@subframe/core"; export interface Spalte { key: string; titel: string; /** Zellinhalt; darf beliebige Knoten liefern (Badges, Links). */ zelle: (zeile: T) => React.ReactNode; /** Sortierwert, ohne diesen ist die Spalte nicht sortierbar. */ sortWert?: (zeile: T) => number | string; /** Zahlen gehören rechtsbündig und in Tabellenziffern. */ numerisch?: boolean; breite?: string; } /** * Tabelle für Arbeitslisten (offene Rechnungen, Aufträge, Kunden). * * Enthält die Interaktionen, die man in einer Demo wirklich braucht: * Sortieren per Klick auf den Spaltenkopf, Volltextsuche, Blättern. * Alles clientseitig, bei echten Datenmengen gehört das auf den Server. */ export function DataTable({ zeilen, spalten, proSeite = 8, suchbar = true, suchFelder, leerText = "Keine Einträge", }: { zeilen: T[]; spalten: Spalte[]; proSeite?: number; suchbar?: boolean; /** Felder, die die Suche durchsucht. */ suchFelder?: (zeile: T) => string; leerText?: string; }) { const [suche, setSuche] = React.useState(""); const [seite, setSeite] = React.useState(0); const [sortierung, setSortierung] = React.useState<{ key: string; richtung: "asc" | "desc" } | null>( null ); const gefiltert = React.useMemo(() => { if (!suche.trim() || !suchFelder) return zeilen; const begriff = suche.toLowerCase(); return zeilen.filter((zeile) => suchFelder(zeile).toLowerCase().includes(begriff)); }, [zeilen, suche, suchFelder]); const sortiert = React.useMemo(() => { if (!sortierung) return gefiltert; const spalte = spalten.find((s) => s.key === sortierung.key); if (!spalte?.sortWert) return gefiltert; return [...gefiltert].sort((a, b) => { const wertA = spalte.sortWert!(a); const wertB = spalte.sortWert!(b); const vergleich = typeof wertA === "number" && typeof wertB === "number" ? wertA - wertB : String(wertA).localeCompare(String(wertB), "de"); return sortierung.richtung === "asc" ? vergleich : -vergleich; }); }, [gefiltert, sortierung, spalten]); const seiten = Math.max(1, Math.ceil(sortiert.length / proSeite)); const aktuelleSeite = Math.min(seite, seiten - 1); const sichtbar = sortiert.slice(aktuelleSeite * proSeite, (aktuelleSeite + 1) * proSeite); const sortiereNach = (key: string) => { setSeite(0); setSortierung((aktuell) => aktuell?.key === key ? { key, richtung: aktuell.richtung === "asc" ? "desc" : "asc" } : { key, richtung: "desc" } ); }; return (
{suchbar && suchFelder ? (
{ setSuche(event.target.value); setSeite(0); }} placeholder="In Liste suchen …" className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle" />
) : null} {/* Der waagerechte Scrollbereich der Tabelle. Ohne min-w-0 am Elternteil greift er nicht: Ein Flex-Kind wird nicht schmaler als sein Inhalt, die Tabelle bliebe also breit und schöbe stattdessen die Seite auf. Vorher trug der Bereich zusätzlich -mx-1/px-1: das machte ihn 8 px breiter als seine Spalte und ließ die Karte um 4 px überlaufen. Den Fokusring der Sortierknöpfe deckt bereits das px-2 der Kopfzelle. */}
{spalten.map((spalte) => { const aktiv = sortierung?.key === spalte.key; return ( ); })} {sichtbar.map((zeile) => ( {spalten.map((spalte) => ( ))} ))} {!sichtbar.length ? ( ) : null}
{spalte.sortWert ? ( ) : ( spalte.titel )}
{spalte.zelle(zeile)}
{leerText}
{sortiert.length > proSeite ? (
{aktuelleSeite * proSeite + 1}–{Math.min((aktuelleSeite + 1) * proSeite, sortiert.length)} von{" "} {sortiert.length}
{aktuelleSeite + 1} / {seiten}
) : null}
); }