Files
dejanandClaude Opus 5 4ef7c1b27f Legende: gleicher Marker fuer alle Reihen, nur die Farbe unterscheidet
Der Eintrag "Vorjahr" hatte erst ein Kaestchen mit gestricheltem Rand (auf
10 px nur noch Eckstriche), dann ein gestricheltes Linienstueck. Beides war
zu viel Aufwand fuer eine Auskunft, die die Farbe schon gibt.

Jetzt derselbe abgerundete Marker wie beim aktuellen Zeitraum, in Grau. Die
Reihe liest sich ruhiger, weil alle Eintraege dieselbe Form haben.

Die Eigenschaft `gestrichelt` faellt damit weg, in der Legende und an ihren
vier Aufrufstellen. Eine Eigenschaft, die nichts mehr aendert, waere sonst
beim Einbau mitgewandert.

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

450 lines
20 KiB
TypeScript

"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer } from "../components/layout/PageContainer";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { AlertList } from "../components/cards/AlertList";
import { StatZeile } from "../components/cards/StatZeile";
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { Trichter } from "../components/charts/Trichter";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { ChartLeer } from "../components/charts/ChartLeer";
import { ChartLegende } from "../components/charts/ChartLegende";
import { AuftragBadge, RechnungBadge } from "../components/tables/StatusBadge";
import { SeitenZustand } from "../components/layout/SeitenZustand";
import { WidgetKachel } from "../components/dashboard/WidgetKachel";
import { ReservierteKachel } from "../components/dashboard/ReservierteKachel";
import { RESERVIERTE_BEREICHE } from "../view-models/dashboard.vm";
import { Begruessung } from "../components/dashboard/Begruessung";
import type { VMState } from "../view-models";
import {
KATEGORIE_FARBEN,
MAX_KATEGORIEN,
PRIMAER_FARBE,
STATUS_FARBEN,
VERGLEICH_FARBE,
} from "../utils/chartTheme";
import {
formatEuro,
formatEuroAchse,
formatEuroKompakt,
formatProzent,
formatZahl,
} from "../utils/formatters";
import { DATENSATZ_INFO } from "../data/seed";
/** Beispieldaten, damit die Galerie unabhängig von Filtern immer etwas zeigt. */
const BEISPIEL_REIHE = Array.from({ length: 12 }, (_, i) => ({
datum: `2026-${String(i + 1).padStart(2, "0")}`,
label: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"][i],
value: [148, 162, 171, 158, 166, 149, 152, 161, 189, 205, 198, 176][i] * 1000,
vergleich: [131, 140, 152, 146, 151, 138, 141, 149, 168, 181, 176, 158][i] * 1000,
}));
const BEISPIEL_KATEGORIEN = [
{ name: "Heizung", value: 842_000 },
{ name: "Sanitär", value: 486_000 },
{ name: "Photovoltaik", value: 421_000 },
{ name: "Klima & Lüftung", value: 268_000 },
{ name: "Elektro", value: 194_000 },
{ name: "Sonstiges", value: 61_000 },
];
export function KomponentenSeite() {
return (
<>
<PageHeader
titel="Komponenten & Regeln"
beschreibung="Jeder Baustein einzeln und in allen Zuständen: die Seite zum Draufzeigen in der Abstimmung."
/>
<PageContainer>
<Abschnitt
titel="Warum diese Seite existiert"
text="Auf den Analyse-Seiten sieht man von jedem Baustein immer nur den einen Zustand, den die Daten gerade hergeben: die Kennzahlenkarte mit steigendem Trend, das Diagramm mit Werten darin. Die anderen Zustände existieren trotzdem und müssen mitgestaltet werden. Diese Seite zeigt sie alle nebeneinander, damit man in der Abstimmung darauf zeigen kann, ohne erst einen Filter zu suchen, der den passenden Fall herstellt."
/>
<Hinweis text="Diese Seite ist kein Produktfeature. Sie gehört weder zu Dashboard noch zu Analytics, steht in der Seitenleiste unter dem Trenner und wandert beim Einbau nicht mit. Für die Umsetzung selbst sind die Datei-Pfade unter jedem Abschnitt und docs/DESIGN-SYSTEM.md maßgeblich. Beides steht im Repo und braucht diese Seite nicht." />
<ChartCard
id="komp-farben"
titel="Farbpalette"
untertitel="IBM Carbon, kategoriale Palette (14 Farben): Reihenfolge nie umsortieren"
hinweis="Die Reihenfolge ist auf Unterscheidbarkeit optimiert. Farben immer von vorne vergeben, nicht nach Geschmack auswählen."
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{KATEGORIE_FARBEN.map((farbe, index) => (
<div key={farbe} className="flex w-[104px] flex-col gap-1">
<span className="h-10 w-full rounded-md" style={{ background: farbe }} />
<span className="text-[11px] font-medium text-text-neutral-secondary">
{index + 1}. {farbe}
</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-4 border-t border-solid border-border-default pt-3">
{Object.entries(STATUS_FARBEN).map(([name, farbe]) => (
<span key={name} className="flex items-center gap-2 text-[12px] text-text-neutral-secondary">
<span className="h-4 w-4 rounded" style={{ background: farbe }} />
Status „{name}“ · {farbe}
</span>
))}
</div>
<p className="text-[12px] leading-relaxed text-text-neutral-tertiary">
Kategorien nutzen die Carbon-Palette, Statusaussagen die Ampelfarben. Beides nie
mischen: eine grüne Kategorie neben einem grünen Positiv-Status ist nicht mehr lesbar.
Maximal {MAX_KATEGORIEN} Segmente je Chart, der Rest wird zu „Sonstige“
zusammengefasst.
</p>
</div>
</ChartCard>
<Abschnitt titel="Kennzahlen-Karten" text="features/dashboard/components/cards/KPICard.tsx" />
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KPICard
label="Mit Trend und Sparkline"
wert={formatEuro(238_412)}
zusatz="Zahlungseingang im Zeitraum"
trend={{ wert: 12.4, richtung: "up" }}
icon="Euro"
akzent="positiv"
verlauf={[42, 48, 45, 52, 58, 55, 63, 71]}
/>
<KPICard
label="Negativer Trend"
wert={formatProzent(58.2, 0)}
zusatz="Abschlussquote"
trend={{ wert: 6.1, richtung: "down" }}
icon="Target"
akzent="negativ"
/>
<KPICard
label="Kleiner ist besser"
wert="34,2 Tage"
zusatz="Ø Zahlungsdauer"
trend={{ wert: 8.3, richtung: "down" }}
icon="Clock"
akzent="positiv"
richtungGut="negativ"
/>
<KPICard
label="Ohne Trend"
wert={formatZahl(1900)}
zusatz="Vorgänge im Datensatz"
icon="ClipboardList"
akzent="neutral"
/>
</div>
<Hinweis text="richtungGut='negativ' dreht die Farblogik um: Bei Zahlungsdauer oder Reaktionszeit ist ein Rückgang gut. Ohne diesen Schalter würde die Karte eine Verbesserung rot einfärben." />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<ChartCard
id="komp-area"
titel="Flächendiagramm mit Vergleich"
untertitel="components/charts/AreaVerlauf.tsx: für Zeitverläufe"
className="xl:col-span-2"
legende={
<ChartLegende
eintraege={[
{ name: "Aktuell", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE },
]}
/>
}
>
<AreaVerlauf
daten={BEISPIEL_REIHE}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={220}
/>
</ChartCard>
<ChartCard
id="komp-donut"
titel="Ringdiagramm"
untertitel="components/charts/Donut.tsx: für Anteile"
>
<Donut daten={BEISPIEL_KATEGORIEN} formatWert={formatEuroKompakt} hoehe={200} />
</ChartCard>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard
id="komp-balken"
titel="Säulendiagramm"
untertitel="components/charts/BalkenVertikal.tsx: Mengen mit Nulllinie"
>
<BalkenVertikal
daten={BEISPIEL_REIHE.map((p) => ({ label: p.label, value: p.value / 1000 }))}
formatWert={(w) => formatZahl(w)}
hoehe={200}
nurGanzzahlen
/>
</ChartCard>
<ChartCard
id="komp-hbalken"
titel="Balkendiagramm (liegend)"
untertitel="components/charts/BalkenHorizontal.tsx: Rangfolgen"
>
<BalkenHorizontal
daten={BEISPIEL_KATEGORIEN}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</ChartCard>
<ChartCard
id="komp-trichter"
titel="Trichter"
untertitel="components/charts/Trichter.tsx: Stufen mit Übergangsquote"
>
<Trichter
stufen={[
{ name: "Anfrage", value: 1900 },
{ name: "Vor-Aufmaß", value: 1420 },
{ name: "Angebot", value: 1180 },
{ name: "Beauftragt", value: 690 },
{ name: "Abgeschlossen", value: 612 },
]}
formatWert={(w) => formatZahl(w)}
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard
id="komp-auslastung"
titel="Auslastungsbalken"
untertitel="components/charts/AuslastungsBalken.tsx: mit Ampel und 100-%-Marke"
>
<AuslastungsBalken
zeilen={[
{ name: "Thomas Krause", wert: 104, zusatz: "Meister · Heizung" },
{ name: "Dennis Meyer", wert: 92, zusatz: "Monteur · Sanitär" },
{ name: "Maike Schmidt", wert: 78, zusatz: "Monteur · Photovoltaik" },
{ name: "Kevin Wolf", wert: 54, zusatz: "Auszubildender · Elektro" },
]}
/>
</ChartCard>
<ChartCard
id="komp-alerts"
titel="Handlungsbedarf"
untertitel="components/cards/AlertList.tsx: nach Dringlichkeit sortiert"
>
<AlertList
eintraege={[
{
id: "a",
text: "25 überfällige Rechnungen",
detail: "Zahlungsziel überschritten",
betrag: 96_194,
aktion: "Mahnlauf starten",
art: "kritisch",
},
{
id: "b",
text: "32 Aufträge über Termin",
detail: "Ältester seit 99 Tagen fällig",
betrag: 148_600,
aktion: "Termine neu planen",
art: "warnung",
},
{
id: "c",
text: "23 Förderanträge in Bearbeitung",
detail: "Bescheid ausstehend",
betrag: 116_100,
aktion: "Status prüfen",
art: "hinweis",
},
]}
/>
</ChartCard>
<ChartCard
id="komp-leer"
titel="Leerzustand"
untertitel="components/charts/ChartLeer.tsx: greift, wenn Filter alles wegschneiden"
>
<ChartLeer hoehe={180} />
</ChartCard>
</div>
<Abschnitt
titel="Seitenzustände"
text="features/dashboard/components/layout/SeitenZustand.tsx: die vier Zustände außer „fertig“. Sie sind in HiveGrid Pflicht (VMState: idle · loading · ready · empty · error) und im laufenden Prototyp nie zu sehen, weil die Demo-Daten immer liefern. Gestaltet werden müssen sie trotzdem: „lädt“, „nichts gefunden“ und „kaputt“ sind drei verschiedene Aussagen und dürfen nicht gleich aussehen."
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{([
["Lädt", { status: "loading" } as VMState<unknown>],
["Leer: Filter schneiden alles weg", { status: "empty" } as VMState<unknown>],
[
"Fehler",
{
status: "error",
error: { messageKey: "Zeitüberschreitung beim Laden der Kennzahlen" },
} as VMState<unknown>,
],
] as const).map(([titel, vm]) => (
<div key={titel} className="flex flex-col gap-2">
<span className="text-[12px] font-medium text-text-neutral-tertiary">{titel}</span>
{/* -mx-4: SeitenZustand bringt seinen eigenen Seitenrand mit, weil
er im Normalfall die ganze Seite füllt. Hier steht er in einer
Rasterspalte und braucht ihn nicht.
Ohne zusätzlichen Rahmen: Die Komponente hat bereits eine
gestrichelte Kante. Ein Kasten darum wäre ein zweiter Rahmen
und zeigte den Baustein anders, als er in der Anwendung
aussieht. */}
<div className="-mx-4 sm:-mx-6">
<SeitenZustand vm={vm} />
</div>
</div>
))}
</div>
<Abschnitt
titel="Dashboard-Kacheln"
text="features/dashboard/components/dashboard/: das Dashboard benutzt nicht ChartCard, sondern einen eigenen Kachelrahmen. Gleiche Kante, gleiche Abstände, aber ohne das ⋯-Menü von Analytics; dafür mit Domänen-Icon, eigenem Zeitraum und Absprung. Der Grund steht in docs/SCOPE-ANALYTICS-DASHBOARD.md: In Analytics gilt ein Filter für alle Karten, im Dashboard trägt jede Kachel ihren eigenen."
/>
{/* Wie bei den Seitenzuständen: Die Begrüßung trägt ihren eigenen
Seitenrand, weil sie im Dashboard direkt auf der Seite steht. In der
Galerie liegt sie schon im Innenabstand des Containers und wäre
sonst doppelt eingerückt. */}
<div className="-mx-4 sm:-mx-6">
<Begruessung
anrede="Guten Tag"
name="[Vorname]"
datum="Donnerstag, 30. Juli"
rolle="Geschäftsführung"
lage="2 Punkte brauchen heute deine Aufmerksamkeit."
kritisch
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<WidgetKachel
titel="Umsatz nach Gewerk"
untertitel="Zahlungseingang der letzten 30 Tage"
hinweis="Der Zeitraum steht im Kopf, weil es auf dem Dashboard keinen seitenweiten Filter gibt."
domaene="finance"
zeitraum="30_tage"
absprung="/analytics/finanzen"
className="lg:col-span-2"
>
<BalkenHorizontal
daten={BEISPIEL_KATEGORIEN.slice(0, 5)}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</WidgetKachel>
<ReservierteKachel bereich={RESERVIERTE_BEREICHE[2]} />
</div>
<Abschnitt
titel="Reservierte Kacheln: was noch ins Dashboard kommt"
text="features/dashboard/components/dashboard/ReservierteKachel.tsx: das Dashboard zeigt heute nur Auswertungen. Diese drei Bereiche kommen dazu; alle drei stehen im Designer-Brief P11 und haben in HiveGrid bereits ihre Widget-Domäne (tasks, schedule, hr). Sie brauchen kein neues Raster, nur Daten."
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{RESERVIERTE_BEREICHE.map((bereich) => (
<ReservierteKachel key={bereich.key} bereich={bereich} />
))}
</div>
<Hinweis text="Diese Kacheln stehen hier und nicht auf dem Dashboard. Ein Begrüßungsbildschirm ist kein Ort für Ankündigungen. Wer sich morgens anmeldet, will seine Lage sehen und nicht lesen, was später einmal kommt. Für den Einbau ist die Stelle im Raster als Kommentar in DashboardSeite.tsx markiert; der Zuschnitt steht in docs/SCOPE-ANALYTICS-DASHBOARD.md, Abschnitt 3a." />
<Hinweis text="Sie zeigen bewusst keine Zahl. „2 neue Anträge“ aus erfundenen Daten liest sich in einer Abnahme wie eine fertige Funktion, und irgendwann fragt jemand, warum die Zahl nie hochzählt. Die gestrichelte Kante sagt dasselbe ehrlicher: Hier kommt etwas hin, die Fläche ist dafür da."/>
<ChartCard
id="komp-badges"
titel="Status-Kennzeichnung"
untertitel="components/tables/StatusBadge.tsx"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="w-24 text-[12px] text-text-neutral-tertiary">Auftrag</span>
<AuftragBadge status="anfrage" />
<AuftragBadge status="aufmass" />
<AuftragBadge status="angebot" />
<AuftragBadge status="beauftragt" />
<AuftragBadge status="in_arbeit" />
<AuftragBadge status="abgeschlossen" />
<AuftragBadge status="verloren" />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="w-24 text-[12px] text-text-neutral-tertiary">Rechnung</span>
<RechnungBadge status="entwurf" />
<RechnungBadge status="gestellt" />
<RechnungBadge status="bezahlt" />
<RechnungBadge status="ueberfaellig" />
</div>
<StatZeile
eintraege={[
{ label: "Aufträge", wert: formatZahl(DATENSATZ_INFO.auftraege), hinweis: "im Demo-Datensatz" },
{ label: "Rechnungen", wert: formatZahl(DATENSATZ_INFO.rechnungen) },
{ label: "Kunden", wert: formatZahl(DATENSATZ_INFO.kunden) },
{ label: "Zeiteinträge", wert: formatZahl(DATENSATZ_INFO.zeiteintraege) },
]}
/>
</div>
</ChartCard>
<ChartCard
id="komp-regeln"
titel="Regeln, die beim Einbau erhalten bleiben sollten"
untertitel="Ausführlich in docs/DESIGN-SYSTEM.md"
>
<ul className="flex flex-col gap-2.5">
{[
"Nur horizontale Gridlines in #e0e0e0. Vertikale Linien lenken vom Verlauf ab.",
"Achsenbeschriftungen 12 px in #525252, keine Tick-Marks, Achsenlinie #8d8d8d.",
"Ein Tooltip für alle Charts: weiße Fläche #ffffff, Text #161616, Schatten statt Rahmen. Seit Carbon v11 ist er hell, nicht mehr dunkel.",
"Maximal fünf Kategorien je Chart, alles darüber wird zu „Sonstige“ zusammengefasst.",
"Balkenabstand mindestens halbe Balkenbreite; Balken nie breiter als 48 px.",
"Zahlen rechtsbündig und in Tabellenziffern, Text linksbündig.",
"Karten: 16 px Innenabstand, 16 px Abstand zwischen Karten; innen nie größer als außen.",
"Jede Karte braucht einen Leerzustand. Filter können jedes Chart leer laufen lassen.",
"Karten einer Zeile nur strecken, wenn sie dieselbe Bauart haben. Ein Diagramm mit fester Höhe neben einer Balkenliste bleibt oben bündig, sonst wird aus einer Karte mit einem Balken eine 427 px hohe Fläche.",
].map((regel) => (
<li key={regel} className="flex gap-2.5 text-[13px] leading-relaxed text-text-neutral-secondary">
<span className="mt-1.5 h-1.5 w-1.5 flex-none rounded-full bg-neutral-400" />
{regel}
</li>
))}
</ul>
</ChartCard>
</PageContainer>
</>
);
}
function Abschnitt({ titel, text }: { titel: string; text: string }) {
return (
<div className="flex flex-col gap-1 pt-2">
<h2 className="text-[15px] font-semibold text-text-neutral-primary">{titel}</h2>
{/* break-words: Diese Absätze beginnen mit einem Dateipfad, und ein Pfad
ist ein einziges Wort ohne Trennstelle. Bei 375 px lief
"features/dashboard/components/layout/SeitenZustand.tsx" neun Pixel
über den Rand. */}
<p className="max-w-3xl break-words text-[13px] leading-relaxed text-text-neutral-tertiary">
{text}
</p>
</div>
);
}
function Hinweis({ text }: { text: string }) {
return (
<p className="rounded-md border border-solid border-border-default bg-white px-3 py-2 text-[12px] leading-relaxed text-text-neutral-secondary">
{text}
</p>
);
}