Files
Dashboard/features/dashboard/components/cards/KPICard.tsx
T
dejanandClaude Opus 5 e209c33916 Drei Stellen im Hover- und Ausrichtungsverhalten nachbessern
Aus dem Sparring, alle drei auch in Analytics wirksam.

1. Vertriebstrichter: Zeilen-Hervorhebung statt Ausgrauen der uebrigen Balken.

   Bisher setzte das Ueberfahren einer Zeile alle anderen Balken auf 45 %
   Deckkraft. Das war aus drei Gruenden falsch: Es liess fuenf von sechs
   Balken verblassen und las sich dadurch wie eine Auswahl statt wie ein
   Ueberfahren; es war in sich widerspruechlich, weil die Balken verblassten
   und die Namen daneben schwarz blieben; und Deckkraft traegt in diesem
   Diagramm bereits Bedeutung — der Balken kodiert einen Anteil, ihn heller zu
   machen heisst dort "weniger" und nicht "gerade nicht beruehrt".

   Jetzt bekommt die beruehrte Zeile einen Untergrund, die uebrigen bleiben
   unveraendert. Derselbe Untergrund wie in der Handlungsbedarf-Liste und in
   den Tabellen. Die Spur des aktiven Balkens wird weiss: neutral-50 und
   neutral-100 liegen nur fuenf Stufen auseinander, grau auf grau haette dem
   Balken den Bezugsrahmen genommen.

2. KPI-Karten einer Reihe auf gleiche Hoehe.

   Die Umsatzkarte war hoeher als ihre drei Nachbarn, weil sie als einzige
   eine Trendzeile mit Verlaufskurve traegt. Auf dem Dashboard steht zwischen
   Rasterzelle und Karte ein Link, und der reichte die Streckung des Rasters
   nicht durch. h-full auf der Karte.

3. Handlungsbedarf: Hover-Flaeche greift ueber den Inhalt hinaus.

   Der Untergrund endete hart am Pfeil — bei zwei Spalten besonders sichtbar,
   weil daneben 32 px Spaltenabstand ungefaerbt blieben. Jetzt acht Pixel
   Ueberstand und runde Ecken.

   Dabei mitgenommen: Der Trennstrich sass unten und brauchte eine Ausnahme
   fuer "die letzte Zeile" — die es bei zwei Spalten zweimal gibt. Die rechte
   Spalte behielt dadurch einen Strich, unter dem nichts mehr kam. Oben
   liegend ist die Ausnahme die erste Zeile jeder Spalte, und die steht immer
   an derselben Stelle, unabhaengig von der Anzahl der Eintraege.

tsc, lint, build und verify:layout laufen sauber; Hover-Zustaende und
Kartenhoehen nachgemessen statt begutachtet.

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

162 lines
6.3 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",
/**
* Überschreibt die Fußzeile. Analytics hat einen seitenweiten Filter, deshalb
* heißt es dort "im gewählten Zeitraum". Auf dem Dashboard gibt es den nicht
* — jede Kachel trägt ihren eigenen Zeitraum und muss ihn benennen können.
*/
bezugText,
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;
bezugText?: string;
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 (
/* h-full: Die Karte füllt ihre Rasterzelle. Ohne das ist die Karte mit
Trendzeile und Verlaufskurve höher als ihre Nachbarn, und eine Reihe
gleichartiger Kennzahlen bekommt eine ungleiche Unterkante — auf dem
Dashboard sichtbar, weil dort ein Link zwischen Zelle und Karte steht
und die Streckung des Rasters nicht mehr durchreicht. */
<article className="flex h-full 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">
{/* min-w-0 + break-words: Ohne das schiebt ein langes Label wie
„Umsatz (Zahlungseingang)" das Icon aus der Karte und laeuft selbst
ueber den Rand. Die Karte wird dadurch eine Zeile hoeher — das ist
der Preis dafuer, dass die Beschriftung vollstaendig lesbar bleibt
(docs/DESIGN-SYSTEM.md §9). */}
<span className="min-w-0 break-words 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">
{/* "277.460 €" und "34,2 Tage" tragen ihre Einheit als eigenes Wort.
Ohne nowrap bricht die schmale Karte zwischen Betrag und Einheit um:
Die Zahl liest sich dann einen Augenblick lang als Wert ohne Einheit,
und die Karte wächst um eine Zeile, wodurch die Reihe ihre gemeinsame
Grundlinie verliert. */}
<span className="whitespace-nowrap 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">
{bezugText ?? 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>
);
}