Files
Dashboard/features/dashboard/components/cards/KPICard.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

140 lines
4.9 KiB
TypeScript

"use client";
import React from "react";
import { FeatherArrowDownRight, FeatherArrowUpRight, FeatherMinus } from "@subframe/core";
import { FeatherIcon } from "../layout/FeatherIcon";
import { Sparkline } from "../charts/Sparkline";
import { CHART, STATUS_FARBEN } from "../../utils/chartTheme";
/** Zeitbezug einer Kennzahl — siehe docs/KPI-KATALOG.md. */
export type Bezug = "zeitraum" | "stichtag" | "gesamt";
const BEZUG_LABEL: Record<Bezug, string> = {
zeitraum: "im gewählten Zeitraum",
stichtag: "zum Stichtag",
gesamt: "über alle Zeiträume",
};
/**
* KPI-Karte nach UI_UX_GUIDELINES.md, Abschnitt 4:
* Label oben links · Icon oben rechts · Wert · Zusatz · Fußzeile.
*
* Alle Karten einer Reihe haben dieselbe Anatomie — auch die ohne Trend.
* Wo kein Vorperiodenvergleich möglich ist (stichtagsbezogene Kennzahlen),
* trägt die Fußzeile den Zeitbezug. Ohne das steht in einer Reihe eine
* Karte mit Trendzeile neben vier Karten mit leerem Fuß, und das Auge
* sucht eine Regel, die es nicht gibt.
*
* Der Trend vergleicht immer mit der gleich langen Vorperiode
* (30 Tage gegen die 30 Tage davor) — nicht mit dem Vorjahr.
*/
export function KPICard({
label,
wert,
zusatz,
trend,
trendLabel = "Vorperiode",
bezug = "zeitraum",
icon,
verlauf,
akzent = "neutral",
/** Bei "positiv" ist ein steigender Wert gut, bei "negativ" schlecht. */
richtungGut = "positiv",
}: {
label: string;
wert: string;
zusatz?: string;
trend?: { wert: number; richtung: "up" | "down" | "flat" };
trendLabel?: string;
bezug?: Bezug;
icon: string;
verlauf?: number[];
/**
* Färbt das Icon. Nur setzen, wenn die Farbe aus einem Schwellenvergleich
* kommt — ein dekorativ grünes Icon behauptet "gut", ohne es zu belegen
* (docs/DESIGN-SYSTEM.md §1: Statusfarben nur für Aussagen).
*/
akzent?: "neutral" | "positiv" | "warnung" | "negativ" | "info";
richtungGut?: "positiv" | "negativ";
}) {
const gut =
trend?.richtung === "flat"
? null
: richtungGut === "positiv"
? trend?.richtung === "up"
: trend?.richtung === "down";
const trendFarbe =
gut === null ? STATUS_FARBEN.neutral : gut ? STATUS_FARBEN.positiv : STATUS_FARBEN.negativ;
const akzentFarbe = {
neutral: CHART.textSekundaer,
positiv: STATUS_FARBEN.positiv,
warnung: STATUS_FARBEN.warnung,
negativ: STATUS_FARBEN.negativ,
info: STATUS_FARBEN.info,
}[akzent];
return (
<article className="flex min-h-[136px] flex-col rounded-lg border border-solid border-border-default bg-white p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<span className="text-[12px] font-medium leading-tight text-text-neutral-tertiary">
{label}
</span>
<span
className="flex h-8 w-8 flex-none items-center justify-center rounded-md bg-neutral-50"
style={{ color: akzentFarbe }}
>
<FeatherIcon name={icon} className="text-[15px]" />
</span>
</div>
<div className="mt-3 flex flex-col gap-1">
<span className="text-[28px] font-semibold leading-none tracking-[-0.02em] text-text-neutral-primary">
{wert}
</span>
{zusatz ? (
<span className="text-[12px] leading-snug text-text-neutral-tertiary">{zusatz}</span>
) : null}
</div>
{/* Fußzeile: immer vorhanden, damit die Reihe eine gemeinsame Grundlinie hat. */}
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
{trend ? (
<span
className="flex min-w-0 items-center gap-1 whitespace-nowrap text-[12px] font-medium"
style={{ color: trendFarbe }}
>
{trend.richtung === "up" ? (
<FeatherArrowUpRight className="text-[13px] flex-none" />
) : trend.richtung === "down" ? (
<FeatherArrowDownRight className="text-[13px] flex-none" />
) : (
<FeatherMinus className="text-[13px] flex-none" />
)}
{trend.richtung === "flat"
? "unverändert"
: `${trend.wert.toFixed(1).replace(".", ",")} %`}
<span className="hidden truncate font-normal text-text-neutral-tertiary xl:inline">
{trendLabel}
</span>
</span>
) : (
<span className="text-[11px] leading-tight text-text-neutral-tertiary">
{BEZUG_LABEL[bezug]}
</span>
)}
{verlauf && verlauf.length > 1 ? (
<span className="h-8 w-16 flex-none">
{/* Die Kurve trägt dieselbe Aussage wie der Pfeil daneben und
deshalb auch dieselbe Farbe — sonst zeigt die Karte zwei
widersprüchliche Signale. */}
<Sparkline werte={verlauf} farbe={trend ? trendFarbe : CHART.achse} />
</span>
) : null}
</div>
</article>
);
}