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
286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
/**
|
||
* Zentrales Chart-Theme — abgeleitet aus IBM Carbon Data Visualization.
|
||
*
|
||
* Die Werte stammen aus dem ausgelieferten Stylesheet von @carbon/charts
|
||
* (dist/styles.css, Light Theme):
|
||
* Gridlines #e0e0e0 (--cds-layer-accent-01)
|
||
* Achse #8d8d8d (--cds-border-strong-01)
|
||
* Text primär #161616 (--cds-text-primary)
|
||
* Text sek. #525252 (--cds-text-secondary)
|
||
* Tooltip #393939 (--cds-background-inverse) auf #ffffff Text
|
||
*
|
||
* Warum kein bunter "Marketing"-Farbsatz: Die Carbon-Palette ist auf
|
||
* Unterscheidbarkeit und Kontrast optimiert und in dieser Reihenfolge
|
||
* getestet. Reihenfolge deshalb nie umsortieren, nur von vorne nehmen.
|
||
*
|
||
* Siehe docs/DESIGN-SYSTEM.md.
|
||
*/
|
||
|
||
/** Carbon "14-color" categorical palette, Light Theme, in Originalreihenfolge. */
|
||
export const KATEGORIE_FARBEN = [
|
||
"#6929c4", // purple 70
|
||
"#1192e8", // cyan 50
|
||
"#005d5d", // teal 70
|
||
"#9f1853", // magenta 70
|
||
"#fa4d56", // red 50
|
||
"#520408", // red 100
|
||
"#198038", // green 60
|
||
"#002d9c", // blue 80
|
||
"#ee5396", // magenta 50
|
||
"#b28600", // yellow 50
|
||
"#009d9a", // teal 50
|
||
"#012749", // cyan 90
|
||
"#8a3800", // orange 70
|
||
"#a56eff", // purple 50
|
||
] as const;
|
||
|
||
/**
|
||
* Carbon empfiehlt maximal 5 gleichzeitig unterscheidbare Kategorien.
|
||
* Alles darüber wird in "Sonstige" zusammengefasst (siehe gruppiereRest()).
|
||
*/
|
||
export const MAX_KATEGORIEN = 5;
|
||
|
||
/** Ampelfarben für Status-Aussagen (nicht für Kategorien verwenden). */
|
||
export const STATUS_FARBEN = {
|
||
positiv: "#198038",
|
||
warnung: "#b28600",
|
||
negativ: "#da1e28",
|
||
neutral: "#8d8d8d",
|
||
info: "#1192e8",
|
||
} as const;
|
||
|
||
/**
|
||
* Feste Farbe je Gewerk.
|
||
*
|
||
* Eine Kategorie behält ihre Farbe über alle Charts und Seiten hinweg —
|
||
* sonst ist Heizung im Ring lila und im Balkendiagramm blau, und man muss
|
||
* jede Legende neu lesen. Die Werte stammen aus der Carbon-Palette.
|
||
*/
|
||
export const GEWERK_FARBEN: Record<string, string> = {
|
||
Heizung: "#6929c4",
|
||
Sanitär: "#1192e8",
|
||
"Klima & Lüftung": "#005d5d",
|
||
Photovoltaik: "#9f1853",
|
||
Elektro: "#198038",
|
||
};
|
||
|
||
/**
|
||
* Feste Farben für Statuswerte.
|
||
*
|
||
* Hier trägt die Farbe Bedeutung: „Verloren" und „Überfällig" sind rot,
|
||
* „Bezahlt" und „In Arbeit" grün, Neutrales grau. Eine zufällige
|
||
* Palettenfarbe für „Verloren" würde die Aussage der Grafik verwässern.
|
||
*/
|
||
export const STATUS_KATEGORIE_FARBEN: Record<string, string> = {
|
||
// Auftragsstatus
|
||
Anfrage: "#a56eff",
|
||
"Vor-Aufmaß": "#1192e8",
|
||
Angebot: "#6929c4",
|
||
Beauftragt: "#009d9a",
|
||
"In Arbeit": "#198038",
|
||
Abgeschlossen: "#005d5d",
|
||
Verloren: "#fa4d56",
|
||
// Rechnungsstatus
|
||
Bezahlt: "#198038",
|
||
Gestellt: "#1192e8",
|
||
Überfällig: "#da1e28",
|
||
Entwurf: "#8d8d8d",
|
||
// Förderung
|
||
Bewilligt: "#198038",
|
||
Beantragt: "#b28600",
|
||
"Nicht beantragt": "#8d8d8d",
|
||
// Abwesenheit
|
||
Urlaub: "#1192e8",
|
||
Krank: "#fa4d56",
|
||
Fortbildung: "#6929c4",
|
||
// Sammelposten
|
||
Sonstige: "#8d8d8d",
|
||
};
|
||
|
||
/**
|
||
* Textfarben für Statusaussagen in Tabellen und Listen.
|
||
*
|
||
* Nicht dieselben Werte wie STATUS_FARBEN: Flächen dürfen heller sein, Text
|
||
* braucht auf Weiß mehr Kontrast (WCAG AA, 4,5:1 bei 12–13 px). Deshalb je
|
||
* eine Stufe dunkler aus derselben Carbon-Familie.
|
||
*/
|
||
export const STATUS_TEXT = {
|
||
negativ: "#a2191f", // red 70
|
||
warnung: "#8a3800", // orange 70
|
||
positiv: "#0e6027", // green 70
|
||
} as const;
|
||
|
||
/**
|
||
* Vierstufige Risikorampe für geordnete Dimensionen (Forderungsalter).
|
||
*
|
||
* Durchgehend Statusfarben aus der Carbon-Skala, keine Palettenfarbe
|
||
* dazwischen: Altersklassen sind eine Reihenfolge mit wachsendem Risiko,
|
||
* keine unabhängigen Kategorien.
|
||
*/
|
||
export const RISIKO_RAMPE = [
|
||
"#198038", // green 60 — im Zahlungsziel
|
||
"#b28600", // yellow 50 — beobachten
|
||
"#ba4e00", // orange 60 — mahnen
|
||
"#da1e28", // red 60 — Abschreibungsrisiko
|
||
] as const;
|
||
|
||
/**
|
||
* Badge-Töne: helle Fläche mit dunklem Text derselben Carbon-Familie.
|
||
* Alle Paare erreichen auf ihrer Fläche mindestens 7:1 — Badges sind klein
|
||
* (11 px) und brauchen mehr Reserve als Fließtext.
|
||
*/
|
||
export const BADGE_TON = {
|
||
neutral: "bg-neutral-100 text-neutral-700",
|
||
information: "bg-[#e5f6ff] text-[#00539a]",
|
||
lila: "bg-[#e8daff] text-[#491d8b]",
|
||
teal: "bg-[#d9fbfb] text-[#005d5d]",
|
||
positiv: "bg-[#defbe6] text-[#0e6027]",
|
||
negativ: "bg-[#fff1f1] text-[#a2191f]",
|
||
} as const;
|
||
|
||
/** Einzelserie (Umsatzverlauf o. Ä.) — Carbon Cyan 50. */
|
||
export const PRIMAER_FARBE = "#1192e8";
|
||
/** Vergleichsserie (Vorjahr) — bewusst neutral, damit sie zurücktritt. */
|
||
export const VERGLEICH_FARBE = "#8d8d8d";
|
||
|
||
export const CHART = {
|
||
grid: "#e0e0e0",
|
||
achse: "#8d8d8d",
|
||
text: "#161616",
|
||
textSekundaer: "#525252",
|
||
tooltipBg: "#393939",
|
||
tooltipFg: "#ffffff",
|
||
} as const;
|
||
|
||
/**
|
||
* Chart-Innenabstände.
|
||
* Carbon rechnet Achsenbeschriftungen in den Chart-Rahmen ein; die Karte selbst
|
||
* liefert bereits 16 px Padding, deshalb hier nur der Platz für Achsen.
|
||
*/
|
||
export const MARGIN = {
|
||
/** Vertikale Balken / Flächen mit Y-Achse links.
|
||
* Rechts genug Platz, damit die letzte Achsenbeschriftung nicht abschneidet. */
|
||
standard: { top: 8, right: 24, bottom: 4, left: 4 },
|
||
/** Horizontale Balken: links Platz für Kategorienamen. */
|
||
horizontal: { top: 4, right: 56, bottom: 4, left: 4 },
|
||
/** Sparkline / Mini-Chart ohne Achsen. */
|
||
kompakt: { top: 2, right: 2, bottom: 2, left: 2 },
|
||
} as const;
|
||
|
||
/** Typografie im Chart (Carbon: 12 px Labels, 14 px Titel). */
|
||
export const CHART_FONT = {
|
||
achse: 12,
|
||
label: 12,
|
||
legende: 12,
|
||
titel: 14,
|
||
} as const;
|
||
|
||
/** Gemeinsame Achsen-Props für Recharts (Carbon-Look: keine Ticks, dünne Achse). */
|
||
export const achseX = {
|
||
tickLine: false,
|
||
axisLine: { stroke: CHART.achse, strokeWidth: 1 },
|
||
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
|
||
dy: 4,
|
||
} as const;
|
||
|
||
export const achseY = {
|
||
tickLine: false,
|
||
axisLine: false,
|
||
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
|
||
// 58 px laut docs/DESIGN-SYSTEM.md §2 — reicht für "245 T€".
|
||
// Werte im Mio.-Bereich bekommen über achsenFormat() eine kurze Einheit.
|
||
width: 58,
|
||
} as const;
|
||
|
||
/** Balkenbreiten nach Carbon: Abstand zwischen Balken ≥ halbe Balkenbreite. */
|
||
export const BALKEN = {
|
||
maxBreite: 48,
|
||
/** Recharts-Kategorieabstand in Prozent der Kategoriebreite. */
|
||
kategorieAbstand: "28%",
|
||
radius: 2,
|
||
hoeheHorizontal: 20,
|
||
} as const;
|
||
|
||
/** Donut-Proportionen (Carbon: kräftiger Ring, Gesamtwert in der Mitte). */
|
||
export const DONUT = {
|
||
innenRadius: "62%",
|
||
aussenRadius: "88%",
|
||
segmentAbstand: 2,
|
||
} as const;
|
||
|
||
/**
|
||
* Reduziert eine Kategorienliste auf MAX_KATEGORIEN und fasst den Rest
|
||
* zu "Sonstige" zusammen — verhindert unlesbare Donuts mit 12 Segmenten.
|
||
*/
|
||
export function gruppiereRest<T extends { name: string; value: number }>(
|
||
daten: T[],
|
||
max: number = MAX_KATEGORIEN
|
||
): { name: string; value: number }[] {
|
||
const sortiert = [...daten].sort((a, b) => b.value - a.value);
|
||
if (sortiert.length <= max) return sortiert.map((d) => ({ name: d.name, value: d.value }));
|
||
|
||
const kopf = sortiert.slice(0, max - 1).map((d) => ({ name: d.name, value: d.value }));
|
||
const rest = sortiert.slice(max - 1).reduce((summe, d) => summe + d.value, 0);
|
||
// "Sonstige" wird einsortiert, nicht angehaengt: der Ring laeuft sonst nicht
|
||
// mehr absteigend, obwohl DESIGN-SYSTEM.md §4 genau das verlangt.
|
||
return [...kopf, { name: "Sonstige", value: rest }].sort((a, b) => b.value - a.value);
|
||
}
|
||
|
||
/**
|
||
* Rundes Achsenraster.
|
||
*
|
||
* Recharts leitet Ticks aus max/(n-1) ab und rundet den Rohschritt auf — bei
|
||
* 219 T€ ergibt das 0/55/110/165/220. Krumme Achsenwerte kosten den Leser
|
||
* bei jedem Blick eine Rechenoperation. Deshalb Schrittweiten aus der
|
||
* 1-2-2,5-5-Reihe erzwingen.
|
||
*/
|
||
export function nettesRaster(
|
||
max: number,
|
||
maxIntervalle = 5
|
||
): { domain: [number, number]; ticks: number[] } {
|
||
if (!Number.isFinite(max) || max <= 0) return { domain: [0, 1], ticks: [0, 1] };
|
||
const groessenordnung = 10 ** Math.floor(Math.log10(max));
|
||
const kandidaten = [1, 2, 2.5, 5, 10]
|
||
.flatMap((k) => [(k * groessenordnung) / 10, k * groessenordnung, k * groessenordnung * 10])
|
||
.sort((a, b) => a - b);
|
||
for (const schritt of kandidaten) {
|
||
const anzahl = Math.ceil(max / schritt);
|
||
if (anzahl <= maxIntervalle) {
|
||
return {
|
||
domain: [0, schritt * anzahl],
|
||
ticks: Array.from({ length: anzahl + 1 }, (_, i) => i * schritt),
|
||
};
|
||
}
|
||
}
|
||
return { domain: [0, max], ticks: [0, max] };
|
||
}
|
||
|
||
/**
|
||
* Ab wie vielen Datenpunkten nur noch jede n-te Beschriftung gezeigt wird.
|
||
* Eine Konstante, damit Flaeche und Balken nicht unterschiedlich ausduennen.
|
||
*/
|
||
export const MAX_ACHSENLABELS = 12;
|
||
|
||
/** Farbe nach Index, rotiert sicher innerhalb der Palette. */
|
||
export function farbe(index: number): string {
|
||
return KATEGORIE_FARBEN[index % KATEGORIE_FARBEN.length];
|
||
}
|
||
|
||
/**
|
||
* Farbe für einen Kategorienamen: feste Zuordnung wenn vorhanden,
|
||
* sonst Position in der Palette.
|
||
*/
|
||
export function farbeFuer(name: string, index: number): string {
|
||
return GEWERK_FARBEN[name] ?? STATUS_KATEGORIE_FARBEN[name] ?? farbe(index);
|
||
}
|
||
|
||
/** Abgestufte Einfärbung für Trichterstufen — eine Farbe, abnehmende Sättigung. */
|
||
export function trichterFarbe(index: number, gesamt: number): string {
|
||
const anteil = gesamt > 1 ? index / (gesamt - 1) : 0;
|
||
// Von Carbon Purple 70 nach Purple 40: gleiche Farbfamilie, klare Reihenfolge.
|
||
const start = { r: 0x69, g: 0x29, b: 0xc4 };
|
||
const ende = { r: 0xbe, g: 0x95, b: 0xff };
|
||
const misch = (a: number, b: number) => Math.round(a + (b - a) * anteil);
|
||
return `rgb(${misch(start.r, ende.r)} ${misch(start.g, ende.g)} ${misch(start.b, ende.b)})`;
|
||
}
|