Files
Dashboard/features/dashboard/components/tables/DataTable.tsx
T
dejanandClaude Opus 5 b8bf987d0e feat(dashboard): Analytics-Dashboard als Gestaltungsvorlage zur Übergabe
UI/UX-Prototyp der Analytics-Dashboards in HiveGrid, fachlich auf einen
SHK-/Handwerksbetrieb zugeschnitten. Ziel ist die Übergabe an die Entwicklung,
nicht der Produktivbetrieb.

Inhalt: sechs Seiten (Übersicht, Finanzen, Aufträge, Kunden, Team, Komponenten),
26 KPIs, neun Chart-Bausteine, Filtersystem mit seitenabhängiger Relevanz und ein
deterministischer Demo-Datensatz (420 Kunden, 1.900 Aufträge, Stichtag 30.07.2026).

Drei Quellen sind zusammengeführt: die fachliche Planung mit dem Branchenpartner
(Januar 2026), die Subframe-Komponentenbibliothek aus dem HiveGrid-Pitch und
IBM Carbon Data Visualization für die Diagrammregeln. Herkunft und bewusste
Abweichungen stehen in docs/HERKUNFT.md.

Dokumentation: README, HANDOVER (was echte Logik braucht), docs/DESIGN-SYSTEM,
KPI-KATALOG, DATENMODELL, NAVIGATION, HERKUNFT, ZIELSYSTEM (Einbauort in
HiveGrid Cloud) und LEITPLANKEN (welche Cloud-Screens als Vorbild taugen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 17:25:20 +02:00

216 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React from "react";
import {
FeatherArrowDown,
FeatherArrowUp,
FeatherChevronLeft,
FeatherChevronRight,
FeatherSearch,
} from "@subframe/core";
export interface Spalte<T> {
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<T extends { id: string }>({
zeilen,
spalten,
proSeite = 8,
suchbar = true,
suchFelder,
leerText = "Keine Einträge",
}: {
zeilen: T[];
spalten: Spalte<T>[];
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 (
<div className="flex flex-col gap-3">
{suchbar && suchFelder ? (
<div className="flex h-8 items-center gap-2 rounded-md border border-solid border-border-default px-2.5">
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
<input
value={suche}
onChange={(event) => {
setSuche(event.target.value);
setSeite(0);
}}
placeholder="In Liste suchen …"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle"
/>
</div>
) : null}
<div className="dv-scroll -mx-1 overflow-x-auto px-1">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-solid border-border-default">
{spalten.map((spalte) => {
const aktiv = sortierung?.key === spalte.key;
return (
<th
key={spalte.key}
style={{ width: spalte.breite }}
aria-sort={
!spalte.sortWert
? undefined
: aktiv
? sortierung!.richtung === "asc"
? "ascending"
: "descending"
: "none"
}
className={`px-2 pb-2 text-[12px] font-medium text-text-neutral-tertiary ${
spalte.numerisch ? "text-right" : "text-left"
}`}
>
{spalte.sortWert ? (
<button
onClick={() => sortiereNach(spalte.key)}
className={`inline-flex items-center gap-1 hover:text-text-neutral-primary ${
spalte.numerisch ? "flex-row-reverse" : ""
} ${aktiv ? "text-text-neutral-primary" : ""}`}
>
{spalte.titel}
{aktiv ? (
sortierung!.richtung === "asc" ? (
<FeatherArrowUp className="text-[11px]" />
) : (
<FeatherArrowDown className="text-[11px]" />
)
) : null}
</button>
) : (
spalte.titel
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sichtbar.map((zeile) => (
<tr
key={zeile.id}
className="border-b border-solid border-border-default last:border-b-0"
>
{spalten.map((spalte) => (
<td
key={spalte.key}
className={`px-2 py-2.5 text-[13px] text-text-neutral-primary ${
spalte.numerisch ? "text-right tabular-nums" : "text-left"
}`}
>
{spalte.zelle(zeile)}
</td>
))}
</tr>
))}
{!sichtbar.length ? (
<tr>
<td
colSpan={spalten.length}
className="px-2 py-10 text-center text-[13px] text-text-neutral-tertiary"
>
{leerText}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{sortiert.length > proSeite ? (
<div className="dv-no-print flex items-center justify-between">
<span className="text-[12px] text-text-neutral-tertiary">
{aktuelleSeite * proSeite + 1}–{Math.min((aktuelleSeite + 1) * proSeite, sortiert.length)} von{" "}
{sortiert.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setSeite((s) => Math.max(0, s - 1))}
disabled={aktuelleSeite === 0}
aria-label="Vorherige Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronLeft className="text-[14px]" />
</button>
<span className="px-1 text-[12px] tabular-nums text-text-neutral-secondary">
{aktuelleSeite + 1} / {seiten}
</span>
<button
onClick={() => setSeite((s) => Math.min(seiten - 1, s + 1))}
disabled={aktuelleSeite >= seiten - 1}
aria-label="Nächste Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronRight className="text-[14px]" />
</button>
</div>
</div>
) : null}
</div>
);
}