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
140 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|