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
216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|