Files
Dashboard/features/dashboard/pages/DashboardSeite.tsx
T
dejanandClaude Opus 5 831a6ec9ad Dashboard als Widget-Flaeche bauen, Platzhalter ersetzen
Das Dashboard ist ein eigenes Feature neben Analytics: der
Begruessungsbildschirm, nicht ein zweiter Analysebereich. Es zeigt heute nur
Auswertungen, ist aber so gebaut, dass die spaeteren Bereiche als weitere
Kacheln dazukommen und nicht als Umbau.

Aufbau: Begruessung, vier Kennzahlen (je ein Link in die Analyse),
Handlungsbedarf, vier Diagramme, drei reservierte Plaetze.

Drei Entscheidungen, die dabei getroffen wurden:

- Kein seitenweiter Filter. Jede Kachel traegt ihren eigenen Zeitraum und
  schreibt ihn in den Kopf. Das ist das Bedienmodell der Software (P11) und
  war die unbequemere Variante; eine Filterleiste haette beim Einbau alle
  Kacheln wieder auseinandergenommen.

- Das Raster ist aus DashboardPage.tsx der Software uebernommen, nicht
  erfunden: dieselben Klassen, dieselbe Ableitung aus size.w und position.x.
  Der Software fehlt dafuer eine Zeile (position.x === 9); vermerkt in
  WidgetRaster.tsx.

- Die reservierten Plaetze zeigen keine erfundenen Zahlen. "2 neue Antraege"
  aus dem Nichts liest sich in einer Abnahme wie eine fertige Funktion.

Absichtliche Doppelung: Der Handlungsbedarf steht jetzt auf dem Dashboard und
weiterhin auf der Analytics-Uebersicht. Ihn dort zu entfernen haette die
offene Frage 5.1 mitentschieden. Dokumentiert in Abschnitt 3a des
Scope-Papiers, aufzuloesen vor dem Port.

Nebenbei zwei Fehler behoben, die auch Analytics betrafen:

- Die Beschriftungen der Zeitachse liefen in schmalen Karten ineinander
  (fuenf Kalenderwochen brauchen 200px, eine halbe Kachel bei 768px hat 150).
  Recharts duennt sie jetzt selbst aus und haelt Anfang und Ende.

- Das Zeitraum-Etikett im Kachelkopf schaltete per Viewport-Breakpoint, die
  Kachelbreite haengt aber am Raster. Bei 1024px blieben dem Untertitel 77px,
  und er lief ins Diagramm. Jetzt Container-Query.

Die Wurzel leitet auf /dashboard statt /analytics — wie in der Software, wo
nach der Anmeldung der Begruessungsbildschirm steht.

tsc, lint, build, verify:carbon und verify:layout (7 Seiten x 9 Breiten)
laufen sauber.

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

256 lines
10 KiB
TypeScript

"use client";
import React from "react";
import Link from "next/link";
import { Begruessung } from "../components/dashboard/Begruessung";
import { ReservierteKachel } from "../components/dashboard/ReservierteKachel";
import { Widget, WidgetRaster } from "../components/dashboard/WidgetRaster";
import { WidgetKachel } from "../components/dashboard/WidgetKachel";
import { KPICard } from "../components/cards/KPICard";
import { AlertList } from "../components/cards/AlertList";
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { Trichter } from "../components/charts/Trichter";
import { ChartLegende } from "../components/charts/ChartLegende";
import { SeitenZustand } from "../components/layout/SeitenZustand";
import { PRIMAER_FARBE, VERGLEICH_FARBE } from "../utils/chartTheme";
import {
formatEuro,
formatEuroAchse,
formatEuroKompakt,
formatProzent,
formatZahl,
} from "../utils/formatters";
import type { VMState } from "../view-models";
import { ZEITRAUM_LABEL, type DashboardDaten, type KennzahlKachel } from "../view-models/dashboard.vm";
/**
* Das Dashboard — der Begrüßungsbildschirm nach der Anmeldung.
*
* ## Was hier steht und warum
*
* Das Dashboard beantwortet „Wie ist meine Lage?", Analytics beantwortet
* „Warum ist das so?" (`docs/SCOPE-ANALYTICS-DASHBOARD.md`). Daraus folgt die
* Reihenfolge auf dieser Seite, und zwar in dieser Richtung:
*
* 1. **Ansprache und Lage** — muss ich heute überhaupt etwas tun?
* 2. **Vier Kennzahlen** — die Lage in Zahlen. Jede ist ein Link in die
* Analyse, die sie erklärt.
* 3. **Handlungsbedarf** — was zu tun ist. Laut Scope-Papier (3.2) gehört
* diese Liste hierher und nicht auf die Analytics-Übersicht; das ist
* hiermit umgesetzt.
* 4. **Vier Diagramme** — die Zusammenfassung aus Analytics.
* 5. **Reservierte Plätze** — was das Dashboard noch wird.
*
* Absteigend nach Dringlichkeit, nicht nach Fachgebiet. Ein Dashboard, das
* nach Fachgebieten sortiert ist, ist ein Inhaltsverzeichnis.
*
* ## Wie das Raster zu lesen ist
*
* Jede Kachel steht in `<Widget breite={…} start={…}>`. Diese beiden Zahlen
* sind in der Software `widget.size.w` und `widget.position.x` und kommen dort
* aus dem gespeicherten Layout — der Benutzer kann es bearbeiten. Hier sind
* sie geschrieben. Das ist der einzige Unterschied; das Raster darunter ist
* dasselbe (siehe `WidgetRaster.tsx`).
*
* ## Was hier bewusst fehlt
*
* Kein seitenweiter Filter. In Analytics wirkt eine Filterleiste auf alle
* Karten; im Dashboard trägt jede Kachel ihren eigenen Zeitraum und sagt ihn
* an (P11: „Edit = nur Widget-Filter/Zeitraum"). Eine Filterleiste über
* dieser Seite wäre die bequemere Lösung und der falsche Weg — sie würde die
* Kacheln beim Einbau alle wieder auseinandernehmen.
*
* Auch keine Rollenumschaltung. Der Prototyp gestaltet die Sicht der
* Geschäftsführung; die Layouts für Büro und Monteur entstehen aus denselben
* Kacheln in anderer Zusammenstellung und sind eine Produktentscheidung,
* keine Gestaltungsfrage.
*/
export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
if (vm.status !== "ready") {
return <SeitenZustand vm={vm} />;
}
const d = vm.data;
const kritisch = d.handlungsbedarf.some((a) => a.art === "kritisch");
/** Die vier Kennzahlen sitzen nebeneinander in je drei von zwölf Spalten. */
const kennzahlStart = [0, 3, 6, 9] as const;
return (
<>
<Begruessung
anrede={d.begruessung.anrede}
name={d.begruessung.name}
datum={d.begruessung.datum}
rolle={d.begruessung.rolle}
lage={d.lage}
kritisch={kritisch}
/>
<div className="flex flex-col gap-6 px-4 pb-10 sm:px-6">
<WidgetRaster>
{d.kennzahlen.map((kachel, index) => (
<Widget key={kachel.key} breite={3} start={kennzahlStart[index]}>
<KennzahlWidget kachel={kachel} sparkline={kachel.key === "umsatz" ? d.sparkline : undefined} />
</Widget>
))}
<Widget breite={12} start={0}>
<WidgetKachel
titel="Handlungsbedarf"
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
domaene="general"
zeitraum="stichtag"
>
<AlertList eintraege={d.handlungsbedarf} spalten={2} />
</WidgetKachel>
</Widget>
<Widget breite={8} start={0}>
<WidgetKachel
titel="Zahlungseingang im Zeitverlauf"
untertitel="Aktueller Zeitraum gegen den Vorjahreszeitraum"
hinweis="Gezählt wird der Tag des Zahlungseingangs, nicht das Rechnungsdatum. Dadurch entspricht die Kurve dem tatsächlichen Geldfluss."
domaene="finance"
zeitraum="30_tage"
absprung="/analytics/finanzen"
absprungText="In den Finanzen auswerten"
>
<ChartLegende
eintraege={[
{ name: "Aktueller Zeitraum", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE, gestrichelt: true },
]}
/>
<AreaVerlauf
daten={d.umsatzVerlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={240}
/>
</WidgetKachel>
</Widget>
{/* mittelVoll: Der Verlauf daneben nimmt zwischen `md` und `lg` die
volle Breite. Ohne das stünde diese Kachel dort allein in einer
halben Zeile, und die andere Hälfte bliebe weiß. */}
<Widget breite={4} start={8} mittelVoll>
<WidgetKachel
titel="Umsatz nach Gewerk"
untertitel="Zahlungseingang der letzten 30 Tage"
hinweis="Fünf Gewerke in fester Rangfolge — dieselbe Kodierung wie auf der Finanzseite."
domaene="finance"
zeitraum="30_tage"
absprung="/analytics/finanzen"
>
<BalkenHorizontal
daten={d.gewerkUmsatz}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</WidgetKachel>
</Widget>
<Widget breite={6} start={0}>
<WidgetKachel
titel="Vertriebstrichter"
untertitel="Angebotsgeschäft ab 6.000 €, zum Stichtag"
hinweis="Nur Vorhaben, für die ein schriftliches Angebot erstellt wird. Wartung und Notdienst laufen ohne Angebot und würden die Quoten verfälschen."
domaene="sales"
zeitraum="stichtag"
absprung="/analytics/auftraege"
absprungText="Aufträge auswerten"
>
<Trichter stufen={d.trichter} formatWert={(w) => formatZahl(w)} />
</WidgetKachel>
</Widget>
<Widget breite={6} start={6}>
<WidgetKachel
titel="Auslastung Monteure"
untertitel="Produktive Stunden gegen verfügbare Stunden"
hinweis="Nur auf Aufträge gebuchte Stunden zählen als produktiv. Über 100 % bedeutet Überstunden."
domaene="hr"
zeitraum="30_tage"
absprung="/analytics/team"
absprungText="Ganzes Team ansehen"
>
<AuslastungsBalken zeilen={d.auslastung} />
</WidgetKachel>
</Widget>
{/* Trennung vor den leeren Plätzen: Ohne sie stehen drei Kacheln
ohne Inhalt neben sechs mit Inhalt, und der Leser sucht den
Ladefehler. Die Überschrift beantwortet die Frage vorher. */}
<Widget breite={12} start={0}>
<div className="flex flex-col gap-1 pt-2">
<h2 className="text-[14px] font-semibold leading-tight text-text-neutral-primary">
Kommt noch
</h2>
<p className="text-[12px] leading-snug text-text-neutral-tertiary">
Das Dashboard zeigt heute nur Auswertungen. Diese drei Bereiche sind dafür
vorgesehen und haben in HiveGrid bereits ihre Widget-Domäne — sie brauchen kein
neues Raster, nur Daten.
</p>
</div>
</Widget>
{d.reserviert.map((bereich, index) => (
<Widget key={bereich.key} breite={4} start={([0, 4, 8] as const)[index]}>
<ReservierteKachel bereich={bereich} />
</Widget>
))}
</WidgetRaster>
</div>
</>
);
}
/**
* Eine Kennzahl als Kachel.
*
* Es ist dieselbe `KPICard` wie in Analytics — dieselbe Anatomie, dieselben
* Regeln zu Trend und Farbe. Neu ist nur, dass sie ein Link ist: Auf dem
* Dashboard ist jede Zahl ein Absprung in die Analyse, die sie erklärt. Genau
* so macht es die Software (`widget.href` legt den Link um die `StatsCard`).
*/
function KennzahlWidget({
kachel,
sparkline,
}: {
kachel: KennzahlKachel;
sparkline?: number[];
}) {
const wert =
kachel.formatAls === "euro"
? formatEuro(kachel.wert)
: kachel.formatAls === "prozent"
? formatProzent(kachel.wert, 0)
: formatZahl(kachel.wert);
return (
<Link
href={kachel.href}
className="block h-full rounded-lg no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={`${kachel.titel}: ${wert} — zur Analyse`}
>
<KPICard
label={kachel.titel}
wert={wert}
zusatz={kachel.zusatz}
trend={kachel.trend}
/* Der Zeitraum der Kachel, ausgeschrieben. "im gewählten Zeitraum"
wie in Analytics wäre hier eine Lüge: Auf dem Dashboard hat niemand
etwas gewählt. */
bezugText={ZEITRAUM_LABEL[kachel.zeitraum]}
icon={kachel.icon}
akzent={kachel.akzent}
richtungGut={kachel.richtungGut}
verlauf={sparkline}
/>
</Link>
);
}