From 831a6ec9adc7c00f473ec12041a1ddd729edaaa4 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Mon, 31 Aug 2026 16:51:09 +0200 Subject: [PATCH] Dashboard als Widget-Flaeche bauen, Platzhalter ersetzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX --- HANDOVER.md | 8 +- README.md | 21 +- app/dashboard/page.tsx | 4 +- app/page.tsx | 14 +- docs/NAVIGATION.md | 11 +- docs/SCOPE-ANALYTICS-DASHBOARD.md | 110 ++++++- docs/ZIELSYSTEM.md | 19 +- .../dashboard/components/cards/KPICard.tsx | 9 +- .../components/charts/AreaVerlauf.tsx | 24 +- .../components/dashboard/Begruessung.tsx | 62 ++++ .../dashboard/ReservierteKachel.tsx | 43 +++ .../components/dashboard/WidgetKachel.tsx | 127 +++++++++ .../components/dashboard/WidgetRaster.tsx | 97 +++++++ .../dashboard/components/layout/navigation.ts | 18 +- features/dashboard/data/nutzer.ts | 19 ++ .../pages/DashboardPlatzhalterSeite.tsx | 69 ----- features/dashboard/pages/DashboardSeite.tsx | 255 +++++++++++++++++ features/dashboard/routes/DashboardRoute.tsx | 32 +++ .../dashboard/view-models/dashboard.vm.ts | 268 ++++++++++++++++++ 19 files changed, 1085 insertions(+), 125 deletions(-) create mode 100644 features/dashboard/components/dashboard/Begruessung.tsx create mode 100644 features/dashboard/components/dashboard/ReservierteKachel.tsx create mode 100644 features/dashboard/components/dashboard/WidgetKachel.tsx create mode 100644 features/dashboard/components/dashboard/WidgetRaster.tsx create mode 100644 features/dashboard/data/nutzer.ts delete mode 100644 features/dashboard/pages/DashboardPlatzhalterSeite.tsx create mode 100644 features/dashboard/pages/DashboardSeite.tsx create mode 100644 features/dashboard/routes/DashboardRoute.tsx create mode 100644 features/dashboard/view-models/dashboard.vm.ts diff --git a/HANDOVER.md b/HANDOVER.md index 657df1d..35f8753 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -10,9 +10,11 @@ > [`docs/ZIELSYSTEM.md`](docs/ZIELSYSTEM.md) Abschnitt 0. > > **Ebenfalls vor dem Einbau lesen:** -> [`docs/SCOPE-ANALYTICS-DASHBOARD.md`](docs/SCOPE-ANALYTICS-DASHBOARD.md). -> Der Zuschnitt zwischen Analytics und Dashboard ist noch offen und betrifft die -> Übersichtsseite und den Handlungsbedarf. +> [`docs/SCOPE-ANALYTICS-DASHBOARD.md`](docs/SCOPE-ANALYTICS-DASHBOARD.md), +> insbesondere Abschnitt 3a. Der Handlungsbedarf steht dort **absichtlich +> doppelt** — auf dem Dashboard und auf der Analytics-Übersicht —, weil das +> Entfernen eine offene Produktfrage mitentschieden hätte. Das ist vor dem +> Port aufzulösen und nicht zu übernehmen. Dieser Prototyp liefert **Gestaltung und Interaktion**, nicht die Anbindung. Was echt ist, was Attrappe ist und was beim Einbau zu tun bleibt: diff --git a/README.md b/README.md index d3157cf..26ec307 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,19 @@ Gestaltungsvorlage für die Analytics-Dashboards in HiveGrid, fachlich auf einen SHK-/Handwerksbetrieb zugeschnitten. **Ziel dieses Repos ist die Übergabe an die Entwicklung** — nicht der Produktivbetrieb. -> **Offener Scope-Punkt:** „Analytics" und „Dashboard" sind zwei Features, nicht -> eines. Das Dashboard ist der Begrüßungsbildschirm und zeigt die wichtigsten -> Charts aus Analytics **plus** anderes (HR-Anträge, Aufgaben, Termine). Was das -> für den Zuschnitt dieses Prototyps bedeutet, steht in -> [`docs/SCOPE-ANALYTICS-DASHBOARD.md`](docs/SCOPE-ANALYTICS-DASHBOARD.md) — -> festgehalten, noch nicht entschieden, noch nichts danach gebaut. +> **Zwei Features, nicht eines.** „Analytics" ist der Analysebereich mit fünf +> Seiten und einem seitenweiten Filter; „Dashboard" ist der +> Begrüßungsbildschirm — eine Seite aus Kacheln, jede mit eigenem Zeitraum. Es +> zeigt die wichtigsten Auswertungen **plus** anderes (HR-Anträge, Aufgaben, +> Termine), das noch nicht gebaut ist und im Raster als reservierte Plätze +> steht. Beide sind gestaltet; offen ist, was aus der Analytics-Übersichtsseite +> wird, deren Inhalt sich mit dem Dashboard überschneidet. Stand und offene +> Punkte in +> [`docs/SCOPE-ANALYTICS-DASHBOARD.md`](docs/SCOPE-ANALYTICS-DASHBOARD.md). ```bash npm install -npm run dev # http://localhost:4300 → leitet auf /analytics +npm run dev # http://localhost:4300 → leitet auf /dashboard ``` --- @@ -46,12 +49,14 @@ features/dashboard/ components/ layout/ AppShell, PageHeader, PageContainer, Navigation cards/ KPICard, ChartCard, AlertList, StatZeile + dashboard/ Kachelraster, Kachelrahmen, Begrüßung (nur /dashboard) charts/ alle Visualisierungen + gemeinsamer Tooltip filters/ Filterleiste und Mehrfachauswahl tables/ DataTable (sortieren, suchen, blättern) + Badges context/ Filter-, Widget- und Toast-State data/ types · seed (Dummy-Daten) · calculations · filtering - hooks/ useAnalyseDaten + · nutzer (Attrappe des angemeldeten Benutzers) + hooks/ useAnalyseDaten, useIstSchmal pages/ die sechs Seiten — reine Komponenten ({ vm }) utils/ chartTheme (Carbon-Tokens), formatters, dateUtils ui/ Subframe-Komponentenbibliothek (unverändert übernommen) diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 117726b..6ed89e0 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { DashboardPlatzhalterSeite } from "@/features/dashboard/pages/DashboardPlatzhalterSeite"; +import { DashboardRoute } from "@/features/dashboard/routes/DashboardRoute"; export default function Page() { - return ; + return ; } diff --git a/app/page.tsx b/app/page.tsx index 05e7de5..dd8bc1f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,15 @@ import { redirect } from "next/navigation"; -import { ANALYTICS_START } from "@/features/dashboard/components/layout/navigation"; +import { DASHBOARD_START } from "@/features/dashboard/components/layout/navigation"; /** - * Der Prototyp bildet das Analytics-Feature ab, nicht das Dashboard. - * Die Wurzel leitet deshalb auf den Analytics-Einstieg weiter — in der - * Software liegt unter "/" das Dashboard, das hier nicht enthalten ist. + * Die Wurzel leitet auf das Dashboard weiter — so wie in der Software, wo + * nach der Anmeldung der Begrüßungsbildschirm steht und nicht der + * Analysebereich. + * + * Bis zum 31.08.2026 zeigte sie auf Analytics, weil es das Dashboard noch + * nicht gab. Jetzt gibt es beides, und die Reihenfolge folgt wieder dem + * Zielsystem. */ export default function Page() { - redirect(ANALYTICS_START); + redirect(DASHBOARD_START); } diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index 9f20ae7..d77bf23 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -9,7 +9,7 @@ was dazukommt, nicht was schon da ist. ``` HIVEGRID -├── Dashboard Platzhalterseite — eigenes Feature, hier nicht gestaltet +├── Dashboard Begrüßungsbildschirm — Widget-Fläche /dashboard └── Analytics ◄── der Prototyp lebt hier /analytics ├───────────────────── └── Komponenten Bausteingalerie des Prototyps /komponenten @@ -69,10 +69,11 @@ Leads mit seinen acht. Drei Gründe geben den Ausschlag: Ein weiterer Vorteil: Die Leiste wächst. Käme eine sechste Analyse-Seite dazu, ist das eine Zeile mehr — ein Reiterband würde eng. -„Dashboard" führt auf eine **Platzhalterseite** unter `/dashboard`: ein -Leerzustand, der erklärt, warum hier nichts steht und was später hinkommt. Ein -Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob etwas fehlt oder -etwas kaputt ist. Siehe +„Dashboard" führt auf den **Begrüßungsbildschirm** unter `/dashboard`. Er hat +bewusst **keine innere Leiste und keine Filterleiste**: Er ist eine einzelne +Seite aus Kacheln, und jede Kachel trägt ihren eigenen Zeitraum. Das ist der +sichtbarste Unterschied zwischen den beiden Menüpunkten — Analytics ist ein +Bereich, das Dashboard eine Seite. Siehe [SCOPE-ANALYTICS-DASHBOARD.md](SCOPE-ANALYTICS-DASHBOARD.md). ### Schmale Fenster diff --git a/docs/SCOPE-ANALYTICS-DASHBOARD.md b/docs/SCOPE-ANALYTICS-DASHBOARD.md index 950f36b..cfcb16e 100644 --- a/docs/SCOPE-ANALYTICS-DASHBOARD.md +++ b/docs/SCOPE-ANALYTICS-DASHBOARD.md @@ -1,8 +1,9 @@ # Scope: Analytics und Dashboard sind zwei Features -**Stand: 29.08.2026.** Von den offenen Punkten in Abschnitt 5 ist bisher genau -einer entschieden und gebaut: die Navigation (5.5). Alles andere ist mit den -Kollegen zu klären, bevor jemand Code anfasst. +**Stand: 31.08.2026.** Entschieden und gebaut sind die Navigation (5.5) und +der erste Aufschlag des Dashboards (Abschnitt 3a). Offen und mit den Kollegen +zu klären bleiben 5.1, 5.3 und 5.6 — sie betreffen eine **Doppelung, die +gerade absichtlich im Repo steht** und in Abschnitt 3a benannt ist. --- @@ -92,6 +93,76 @@ wie sie sind. --- +## 3a. Was am 31.08.2026 gebaut wurde + +Das Dashboard existiert unter `/dashboard` — als **erster Aufschlag mit +Analytics-Inhalten**, aber als Widget-Fläche gebaut, nicht als Analytics-Seite +mit anderem Titel. + +### Aufbau + +| Reihe | Kacheln | Herkunft | +|---|---|---| +| Begrüßung | Anrede, Datum, Rolle, ein Satz zur Lage | neu (`hero`-Widget) | +| Kennzahlen | Umsatz · Offene Forderungen · Aktive Aufträge · Ø Auslastung | Analytics, je ein Link in die Analyse | +| Handlungsbedarf | die Liste über volle Breite | Analytics-Übersicht | +| Diagramme | Zahlungseingang · Umsatz nach Gewerk · Vertriebstrichter · Auslastung | Analytics | +| Kommt noch | Meine Aufgaben · Termine heute · HR-Anträge | reservierte Plätze, **keine Daten** | + +### Drei Entscheidungen, die dabei getroffen wurden + +**Kein seitenweiter Filter.** Jede Kachel trägt ihren eigenen Zeitraum und +schreibt ihn in den Kopf („letzte 30 Tage", „zum Stichtag"). Das ist das +Bedienmodell der Software (5.4) und war die unbequemere Variante: Eine +Filterleiste über der Seite wäre schneller gebaut gewesen und hätte beim +Einbau alle Kacheln wieder auseinandergenommen. + +**Das Raster ist aus der Software übernommen, nicht erfunden.** Es ist +zeichengleich das Raster aus `DashboardPage.tsx`; jede Kachel steht in +``, und diese beiden Zahlen sind dort +`widget.size.w` und `widget.position.x`. Der Unterschied ist die Herkunft: Im +Prototyp sind sie geschrieben, in der Software gespeichert und vom Benutzer +bearbeitbar. + +*Eine Zeile fehlt der Software dafür:* `dashboardWidgetGridClass` kennt keinen +Fall `position.x === 9`, weil bisher keine Rolle vier Kacheln nebeneinander +stellt. Unsere Kennzahlenreihe tut das. Ohne die Zeile fließt die vierte +Kachel dank `grid-flow-row-dense` trotzdem an die richtige Stelle — nur ohne +festen Anfang. Vermerkt in `components/dashboard/WidgetRaster.tsx`. + +**Die reservierten Plätze zeigen keine erfundenen Zahlen.** „2 neue Anträge" +aus dem Nichts liest sich in einer Abnahme wie eine fertige Funktion, und +irgendwann fragt jemand, warum die Zahl nie hochzählt. Die drei Kacheln stehen +gestrichelt und leer da und nennen, aus welchem Feature sie ihre Daten +bekommen werden. + +### Die Doppelung, die jetzt im Repo steht + +**Der Handlungsbedarf steht ab sofort an zwei Stellen:** auf dem Dashboard und +weiterhin auf der Analytics-Übersicht. Das ist Absicht und vorläufig. + +Nach Abschnitt 3.2 gehört er ins Dashboard. Ihn gleichzeitig aus der Übersicht +zu entfernen, hätte aber 5.1 mitentschieden — die Frage, ob es die +Übersichtsseite überhaupt weiter gibt —, und das ist keine Entscheidung, die +beim Bauen nebenbei fällt. Solange 5.1 offen ist, steht er doppelt. + +**Beim Einbau ist das zu bereinigen, nicht zu übernehmen.** Zwei +Einstiegsseiten mit derselben Arbeitsliste sind kein Entwurf, sondern ein +Versehen, das sich als Entwurf tarnt. + +### Was das Dashboard noch nicht hat + +- **Keine Rollenumschaltung.** Gestaltet ist die Sicht der Geschäftsführung. + Büro und Monteur entstehen aus denselben Kacheln in anderer Zusammenstellung + (P11) — das ist eine Produktentscheidung, keine Gestaltungsfrage. +- **Keinen Bearbeitungsmodus.** Die Software hat ihn (`DashboardEditPanel`, + Widget-Katalog, gespeichertes Layout). Der Prototyp bildet ihn nicht nach; + er zeigt das Ergebnis, nicht das Werkzeug. +- **Keine Live-Aktualisierung.** Ebenfalls vorhanden in der Software + (`liveUpdatesEnabled`) und hier ohne Gegenstück. + +--- + ## 4. Wie die beiden zusammenhängen ``` @@ -122,13 +193,17 @@ Nicht entschieden. Reihenfolge nach Tragweite: Begrüßungscharakter und wird zur reinen Kennzahlen-Zusammenfassung. - *C:* Sie entfällt; Analytics besteht aus vier fachlichen Seiten. -**5.2 Welche Analytics-Charts werden Dashboard-Widgets?** -Der Katalog braucht eine Auswahl. Nicht jedes Chart taugt als Kachel — der -Vertriebstrichter etwa lebt von der Übergangsquote und wird klein unlesbar. +**5.2 Welche Analytics-Charts werden Dashboard-Widgets? — Vorschlag steht.** +Gebaut sind vier: Zahlungseingang, Umsatz nach Gewerk, Vertriebstrichter, +Auslastung. Der Trichter war der Zweifelsfall (er lebt von der Übergangsquote +und wird klein unlesbar); er steht deshalb auf sechs von zwölf Spalten und +nicht auf vier. Die Auswahl ist ein Vorschlag zur Abnahme, keine Festlegung. -**5.3 Wo lebt der Handlungsbedarf?** -Vermutlich Dashboard. Dann fehlt Analytics die Handlungsebene — bewusst -akzeptieren oder anders lösen. +**5.3 Wo lebt der Handlungsbedarf? — jetzt an beiden Orten.** +Er steht seit dem 31.08. auf dem Dashboard und weiterhin auf der +Analytics-Übersicht, weil das Entfernen 5.1 mitentschieden hätte. Siehe +Abschnitt 3a. Das ist der dringlichste der offenen Punkte, weil er als +einziger sichtbar doppelt im Repo steht. **5.4 Zeitraum-Modell im Dashboard.** Globaler Filter wie in Analytics oder pro Widget wie in P11? Die Software hat @@ -151,8 +226,15 @@ Unabhängig davon offen, weil Zeiterfassung im v2-Backlog steht — siehe ## 6. Bis zur Klärung -Am Code ändert sich nichts. Der Prototyp bleibt in seinem jetzigen Zuschnitt -brauchbar: Die Diagrammbausteine, Kennzahl-Definitionen und Regeln gelten -unabhängig davon, wie die beiden Features später geschnitten werden. Die -Übersichtsseite ist im Zweifel der Entwurf für das Dashboard — dann wäre sie -nicht falsch, sondern nur am falschen Ort. +Die Diagrammbausteine, Kennzahl-Definitionen und Regeln gelten unabhängig +davon, wie die beiden Features später geschnitten werden — sie sind von der +Frage nicht betroffen. + +Betroffen ist genau eine Stelle: die Übersichtsseite. Fällt 5.1 auf „sie wird +das Dashboard", dann ist die Arbeit größtenteils getan — der Handlungsbedarf +steht schon dort, die Kennzahlen auch —, und die Übersichtsseite entfällt. +Fällt sie anders, verliert das Dashboard nichts; die Übersicht behält den +Handlungsbedarf und das Dashboard gibt ihn ab. + +In beiden Fällen ist die Doppelung aus Abschnitt 3a aufzulösen, bevor der Port +beginnt. diff --git a/docs/ZIELSYSTEM.md b/docs/ZIELSYSTEM.md index caee16b..a543c3c 100644 --- a/docs/ZIELSYSTEM.md +++ b/docs/ZIELSYSTEM.md @@ -53,13 +53,20 @@ Nur das Fachliche und das Analytics-Eigene: ### Was aus diesem Repo Gerüst ist und wegfällt `components/layout/AppShell.tsx`, unsere Kopie der Seitenleiste, `PageHeader`, -die Kopfzeile mit Suche und Glocke, die Routen unter `app/` und die -Platzhalterseite für das Dashboard. All das existiert, damit der Prototyp -alleine lauffähig und beurteilbar ist. Beim Einbau übernehmen -`SubframeAppShell`, `AppSidebar`, `PageShell` und die Loader der Software. +die Kopfzeile mit Suche und Glocke, die Routen unter `app/` und die Attrappe +des angemeldeten Benutzers (`data/nutzer.ts`). All das existiert, damit der +Prototyp alleine lauffähig und beurteilbar ist. Beim Einbau übernehmen +`SubframeAppShell`, `AppSidebar`, `PageShell`, `requireMember` und die Loader +der Software. -**Kurz:** Aus diesem Repo kommt der Inhalt des Analytics-Bereichs und die -Verabredung, wo er in der Navigation sitzt. Alles andere kommt aus HiveGrid. +Das **Kachelraster des Dashboards** ist die Ausnahme in dieser Liste: Es ist +kein Gerüst, sondern aus `DashboardPage.tsx` der Software übernommen — +dieselben Klassen, dieselbe Ableitung aus `size.w` und `position.x`. Es fällt +nicht weg, es trifft dort auf sein Original. + +**Kurz:** Aus diesem Repo kommt der Inhalt des Analytics-Bereichs, der Aufbau +des Dashboards und die Verabredung, wo beide in der Navigation sitzen. Alles +andere kommt aus HiveGrid. --- diff --git a/features/dashboard/components/cards/KPICard.tsx b/features/dashboard/components/cards/KPICard.tsx index 6161098..00f1944 100644 --- a/features/dashboard/components/cards/KPICard.tsx +++ b/features/dashboard/components/cards/KPICard.tsx @@ -35,6 +35,12 @@ export function KPICard({ 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", @@ -47,6 +53,7 @@ export function KPICard({ trend?: { wert: number; richtung: "up" | "down" | "flat" }; trendLabel?: string; bezug?: Bezug; + bezugText?: string; icon: string; verlauf?: number[]; /** @@ -131,7 +138,7 @@ export function KPICard({ ) : ( - {BEZUG_LABEL[bezug]} + {bezugText ?? BEZUG_LABEL[bezug]} )} diff --git a/features/dashboard/components/charts/AreaVerlauf.tsx b/features/dashboard/components/charts/AreaVerlauf.tsx index b70a114..6c442c8 100644 --- a/features/dashboard/components/charts/AreaVerlauf.tsx +++ b/features/dashboard/components/charts/AreaVerlauf.tsx @@ -66,9 +66,25 @@ export function AreaVerlauf({ const hatWerte = daten.some((d) => d.value > 0 || (d.vergleich ?? 0) > 0); if (!daten.length || !hatWerte) return ; - // Bei vielen Punkten nur jede n-te Beschriftung zeigen, sonst überlappen sie. - const intervall = - daten.length > MAX_ACHSENLABELS ? Math.ceil(daten.length / MAX_ACHSENLABELS) - 1 : 0; + /** + * Beschriftungen der Zeitachse ausdünnen — zweistufig, weil zwei + * verschiedene Dinge sie zum Kollidieren bringen. + * + * Viele Punkte: Bei mehr als zwölf Werten wird fest jede n-te Beschriftung + * gezeigt. Das ist eine Entscheidung über die Lesbarkeit der Reihe und darf + * nicht von der Kartenbreite abhängen, sonst zeigt dieselbe Reihe auf zwei + * Bildschirmen verschiedene Stützstellen. + * + * Wenige Punkte in einer schmalen Karte: Fünf Kalenderwochen brauchen rund + * 200 px. Eine halbe Kachel bei 768 px hat 150 px — dort liefen "KW 27" und + * "KW 28" ineinander. `preserveStartEnd` mit `minTickGap` überlässt Recharts + * das Weglassen und hält dabei die erste und letzte Beschriftung, damit der + * Zeitraum ablesbar bleibt. + */ + const vieleWerte = daten.length > MAX_ACHSENLABELS; + const intervall = vieleWerte + ? Math.ceil(daten.length / MAX_ACHSENLABELS) - 1 + : ("preserveStartEnd" as const); const maximum = Math.max( ...daten.map((d) => Math.max(d.value, zeigeVergleich ? (d.vergleich ?? 0) : 0)) @@ -87,7 +103,7 @@ export function AreaVerlauf({ - + +
+ + {datum} · {rolle} + +

+ {anrede}, {name} +

+
+ + + {kritisch ? ( + + ) : ( + + )} + {lage} + + + ); +} diff --git a/features/dashboard/components/dashboard/ReservierteKachel.tsx b/features/dashboard/components/dashboard/ReservierteKachel.tsx new file mode 100644 index 0000000..630fd38 --- /dev/null +++ b/features/dashboard/components/dashboard/ReservierteKachel.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { FeatherIcon } from "../layout/FeatherIcon"; +import type { ReservierterBereich } from "../../view-models/dashboard.vm"; + +/** + * Ein Platz, der noch kein Widget ist. + * + * Das Dashboard zeigt heute nur Analytics, wird aber mehr: eigene Aufgaben, + * Termine des Tages, offene HR-Anträge — alle drei stehen im Designer-Brief + * P11 und haben in der Software bereits ihre Widget-Domäne (`tasks`, + * `schedule`, `hr`). Diese Kacheln halten ihnen den Platz frei. + * + * Sie zeigen ausdrücklich **keine erfundenen Zahlen**. Eine Kachel mit + * „2 neue Anträge" aus dem Nichts liest sich in einer Abnahme wie eine + * fertige Funktion, und irgendwann fragt jemand, warum die Zahl nie + * hochzählt. Der leere Platz sagt dasselbe ehrlicher: Hier kommt etwas hin, + * die Fläche ist dafür vorgesehen, das Raster muss dafür nicht umgebaut + * werden. + * + * Gestrichelte Kante statt durchgezogener: Das ist die Konvention für + * „noch nicht da" und unterscheidet die Kachel auf einen Blick von den + * echten daneben, ohne sie zu verstecken. + */ +export function ReservierteKachel({ bereich }: { bereich: ReservierterBereich }) { + return ( +
+
+ + + +

+ {bereich.titel} +

+
+ +

{bereich.beschreibung}

+ + + Folgt mit {"„"}{bereich.quelle}{"“"} + +
+ ); +} diff --git a/features/dashboard/components/dashboard/WidgetKachel.tsx b/features/dashboard/components/dashboard/WidgetKachel.tsx new file mode 100644 index 0000000..391afa6 --- /dev/null +++ b/features/dashboard/components/dashboard/WidgetKachel.tsx @@ -0,0 +1,127 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { FeatherArrowRight, FeatherInfo } from "@subframe/core"; +import { FeatherIcon } from "../layout/FeatherIcon"; +import { ZEITRAUM_LABEL, type WidgetDomaene, type WidgetZeitraum } from "../../view-models/dashboard.vm"; + +/** + * Rahmen einer Dashboard-Kachel. + * + * Sieht aus wie `ChartCard` aus Analytics — gleiche Kante, gleiche Abstände, + * gleiche Schriftgrößen — trägt aber nicht deren Logik. Das ⋯-Menü und das + * Ausblenden gehören zu Analytics; im Dashboard macht das die Widget-Registry + * der Software (`docs/SCOPE-ANALYTICS-DASHBOARD.md`, Abschnitt 3.3). + * + * Zwei Dinge hat die Kachel dafür, die eine Analytics-Karte nicht braucht: + * + * 1. **Ein Domänen-Icon oben rechts.** Die Software führt dafür eine feste + * Zuordnung (`domainIcons` in `DashboardPage.tsx`); sie ist unten + * übernommen. Auf einer Fläche, die Zahlen aus vier Bereichen mischt, sagt + * das Icon in einem Blick, woher eine Kachel kommt. + * + * 2. **Einen eigenen Zeitraum.** In Analytics gilt ein Filter für alle Karten. + * Im Dashboard trägt jede Kachel ihren eigenen (P11: „Edit = nur + * Widget-Filter/Zeitraum"). Deshalb steht er auf der Kachel — ohne das + * zeigt eine Kachel stillschweigend einen anderen Zeitraum als ihre + * Nachbarin, und niemand merkt es. + */ + +/** Zeichengleich zu `domainIcons` in `DashboardPage.tsx` der Software. */ +const DOMAENEN_ICON: Record = { + general: "Home", + sales: "TrendingUp", + finance: "CreditCard", + projects: "Folder", + tasks: "CheckSquare", + hr: "Users", + activity: "Activity", + comms: "PhoneCall", + schedule: "Calendar", +}; + +export function WidgetKachel({ + titel, + untertitel, + hinweis, + domaene, + zeitraum, + absprung, + absprungText = "Details ansehen", + children, + className = "", +}: { + titel: string; + untertitel?: string; + /** Erklärt die Kennzahl; erscheint als Tooltip am (i) — wie in Analytics. */ + hinweis?: string; + domaene: WidgetDomaene; + zeitraum: WidgetZeitraum; + /** Ziel des Absprungs in die Analyse — `widget.drilldownHref` in der Software. */ + absprung?: string; + absprungText?: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+
+
+

+ {titel} +

+ {hinweis ? ( + + + + ) : null} +
+ {untertitel ? ( +

+ {untertitel} +

+ ) : null} +
+ + + {/* Der Zeitraum steht nur dort, wo Platz für Wörter ist: ab 24rem + Kachelbreite. Darunter nimmt er dem Untertitel so viel Raum, dass + der auf 77 px Breite zerfällt und in das Diagramm darunter läuft. + Er ist dann über den Untertitel weiterhin erschließbar, der die + Periode ausschreibt. */} + + {ZEITRAUM_LABEL[zeitraum]} + + + + + +
+ +
{children}
+ + {absprung ? ( +
+ + {absprungText} + + +
+ ) : null} +
+ ); +} diff --git a/features/dashboard/components/dashboard/WidgetRaster.tsx b/features/dashboard/components/dashboard/WidgetRaster.tsx new file mode 100644 index 0000000..de9a099 --- /dev/null +++ b/features/dashboard/components/dashboard/WidgetRaster.tsx @@ -0,0 +1,97 @@ +import React from "react"; + +/** + * Das Kachelraster des Dashboards. + * + * Übernommen aus `DashboardPage.tsx` der Software — dort steht wörtlich + * + *
+ * + * und jede Kachel bekommt ihre Spaltenzahl aus `widget.size.w` und ihren + * Anfang aus `widget.position.x`. Genau dieses Raster ist hier nachgebaut, + * damit die Kacheln beim Einbau nicht umgerechnet werden müssen: Aus + * `` wird dort ein Datensatz + * `{ size: { w: 8 }, position: { x: 0 } }`. + * + * Der Unterschied ist die Herkunft der Zahlen, nicht ihre Bedeutung. Im + * Prototyp sind sie geschrieben, in der Software gespeichert — dort kann der + * Benutzer das Layout bearbeiten. + */ +export function WidgetRaster({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** + * Eine Rasterzelle. Zeichengleich zu `dashboardWidgetGridClass` in der + * Software — bis auf einen Zusatz: Dort fehlt der Fall `x === 9`, weil noch + * keine Rolle vier Kacheln nebeneinander stellt. Unsere Kennzahlenreihe tut + * das, deshalb steht er hier. Beim Einbau ist das die eine Zeile, die in + * `dashboardWidgetGridClass` zu ergänzen ist; ohne sie fließt die vierte + * Kachel dank `grid-flow-row-dense` trotzdem an die richtige Stelle, nur + * ohne festen Anfang. + */ +export function Widget({ + breite, + start, + mittelVoll = false, + children, +}: { + /** Spalten von zwölf — entspricht `widget.size.w`. */ + breite: 3 | 4 | 6 | 8 | 12; + /** Erste Spalte, nullbasiert — entspricht `widget.position.x`. */ + start?: 0 | 3 | 4 | 6 | 8 | 9; + /** + * Volle Breite schon im Zweispalter zwischen `md` und `lg`, obwohl die + * Kachel schmaler als sechs Zwölftel ist. Für die Fälle, in denen die + * Nachbarkachel derselben Zeile breit ist und die schmale sonst allein in + * einer halben Zeile stünde — die andere Hälfte bliebe leer. + */ + mittelVoll?: boolean; + children: React.ReactNode; +}) { + const breiteKlasse = + breite <= 3 + ? "lg:col-span-3" + : breite <= 4 + ? "lg:col-span-4" + : breite <= 6 + ? "lg:col-span-6" + : breite <= 8 + ? "lg:col-span-8" + : "lg:col-span-12"; + + const startKlasse = + start === 0 + ? "lg:col-start-1" + : start === 3 + ? "lg:col-start-4" + : start === 4 + ? "lg:col-start-5" + : start === 6 + ? "lg:col-start-7" + : start === 8 + ? "lg:col-start-9" + : start === 9 + ? "lg:col-start-10" + : ""; + + /** + * Zwischen `md` und `lg` steht das Raster auf zwei Spalten. Was dort halbiert + * werden darf, entscheidet der Inhalt: + * + * Kennzahlen und Listen (drei bis vier Zwölftel) vertragen eine halbe Spalte + * — bei 768 px sind das rund 200 px, und mehr braucht eine Zahl mit Etikett + * nicht. Diagramme (sechs Zwölftel und mehr) vertragen es nicht: Auf 200 px + * rücken die Beschriftungen der Zeitachse ineinander, und die Balken der + * Auslastung schrumpfen auf Strichbreite. Sie nehmen deshalb schon hier die + * volle Breite und teilen sich erst ab `lg` eine Zeile. + */ + const mittel = breite >= 6 || mittelVoll ? "md:col-span-2" : ""; + + return
{children}
; +} diff --git a/features/dashboard/components/layout/navigation.ts b/features/dashboard/components/layout/navigation.ts index fe6bb45..0138f72 100644 --- a/features/dashboard/components/layout/navigation.ts +++ b/features/dashboard/components/layout/navigation.ts @@ -12,9 +12,9 @@ import type { SeitenId } from "../../data/filtering"; * (`ui/components/SidebarWithSections`, aus der Cloud-Fassung portiert), damit * Aussehen und Verhalten übereinstimmen. * - * „Dashboard" führt auf eine Platzhalterseite: Es ist ein eigenes Feature und - * in diesem Prototyp nicht gestaltet (siehe - * `docs/SCOPE-ANALYTICS-DASHBOARD.md`). + * „Dashboard" und „Analytics" sind zwei Features mit zwei Zuschnitten: Das + * Dashboard ist eine einzelne Seite aus Kacheln, Analytics ein Bereich mit + * fünf Seiten und innerer Leiste (siehe `docs/SCOPE-ANALYTICS-DASHBOARD.md`). */ export type NavZiel = { art: "route"; href: string }; @@ -34,22 +34,24 @@ export interface NavGruppe { eintraege: NavEintrag[]; } -/** Einstieg des Analytics-Bereichs — auch das Ziel der Weiterleitung von „/". */ +/** Einstieg des Analytics-Bereichs. */ export const ANALYTICS_START = "/analytics"; +/** Der Begrüßungsbildschirm — Ziel der Weiterleitung von „/", wie in der Software. */ +export const DASHBOARD_START = "/dashboard"; + export const NAV_GRUPPEN: NavGruppe[] = [ { id: "start", titel: "", eintraege: [ { - // Eigenes Feature, in diesem Prototyp nicht gestaltet. Der Punkt führt - // trotzdem auf eine Seite: Ein Menüeintrag, der nur einen Hinweis - // auslöst, lässt offen, ob etwas fehlt oder etwas kaputt ist. + // Der Begrüßungsbildschirm — eine einzelne Seite, deshalb ohne innere + // Leiste. Er ist das Ziel der Weiterleitung von „/". id: "dashboard", label: "Dashboard", icon: "LayoutDashboard", - ziel: { art: "route", href: "/dashboard" }, + ziel: { art: "route", href: DASHBOARD_START }, }, { // Der neue Menüpunkt. Die fünf Analyse-Seiten sind ausschließlich hier diff --git a/features/dashboard/data/nutzer.ts b/features/dashboard/data/nutzer.ts new file mode 100644 index 0000000..e898688 --- /dev/null +++ b/features/dashboard/data/nutzer.ts @@ -0,0 +1,19 @@ +/** + * Der angemeldete Benutzer — im Prototyp eine Attrappe. + * + * In HiveGrid Cloud kommt das aus der Sitzung (`requireMember`) und steht dem + * Loader zur Verfügung, bevor die Seite rendert. Hier ist es eine Konstante, + * damit die Begrüßung etwas zu begrüßen hat. + * + * Die Rolle steht bewusst dabei: Das Dashboard ist laut Designer-Brief P11 + * rollenbezogen (GF, Büro, Monteur). Dieser Prototyp gestaltet die + * Geschäftsführungs-Sicht; die beiden anderen Layouts entstehen aus denselben + * Kacheln in anderer Zusammenstellung. + */ +export const NUTZER = { + name: "Dejan Cosic", + /** Für die Anrede — „Guten Tag, Dejan" liest sich wie eine Ansprache, nicht wie ein Datensatz. */ + vorname: "Dejan", + rolle: "Geschäftsführung", + rolleKey: "gf" as const, +}; diff --git a/features/dashboard/pages/DashboardPlatzhalterSeite.tsx b/features/dashboard/pages/DashboardPlatzhalterSeite.tsx deleted file mode 100644 index 9e56f98..0000000 --- a/features/dashboard/pages/DashboardPlatzhalterSeite.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { FeatherArrowRight } from "@subframe/core"; -import { Button } from "@/ui/components/Button"; -import { EmptyState } from "@/ui/components/EmptyState"; -import { PageHeader } from "../components/layout/PageHeader"; -import { PageContainer } from "../components/layout/PageContainer"; -import { ANALYTICS_START } from "../components/layout/navigation"; - -/** - * Platzhalter für das Dashboard. - * - * Das Dashboard ist ein eigenes Feature und in diesem Prototyp nicht - * gestaltet — siehe `docs/SCOPE-ANALYTICS-DASHBOARD.md`. Der Menüpunkt führt - * trotzdem auf eine echte Seite statt nur einen Hinweis auszulösen: Ein - * Navigationspunkt, der nirgendwohin führt, lässt den Leser im Unklaren, ob - * etwas fehlt oder etwas kaputt ist. Diese Seite sagt es ihm. - * - * Der Inhalt beschreibt, was hier später steht — nicht als Gestaltung, sondern - * als Verabredung. So bleibt die Seite ehrlich leer und trotzdem nützlich. - */ -export function DashboardPlatzhalterSeite() { - return ( - <> - - - - - - Analytics und Dashboard sind zwei Features. Dieser Prototyp - gestaltet Analytics — den Analysebereich mit - Zeitraum- und Dimensionsfiltern. Das Dashboard beantwortet eine - andere Frage: {"„Wie ist meine Lage?“ statt „Warum ist das so?“."} - - - Geplant sind drei feste Rollen-Layouts (Geschäftsführung, Büro, - Monteur) aus Widgets. Ein Teil davon kommt aus Analytics — - Kennzahlen-Kacheln, Trend-Diagramme, Listen. Der andere Teil - nicht: offene HR-Anträge, eigene Aufgaben, Termine des Tages. - - - Zuschnitt und offene Punkte stehen in{" "} - - docs/SCOPE-ANALYTICS-DASHBOARD.md - - - - } - action={ - - - - } - /> - - - ); -} diff --git a/features/dashboard/pages/DashboardSeite.tsx b/features/dashboard/pages/DashboardSeite.tsx new file mode 100644 index 0000000..d9278b5 --- /dev/null +++ b/features/dashboard/pages/DashboardSeite.tsx @@ -0,0 +1,255 @@ +"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 ``. 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 }) { + if (vm.status !== "ready") { + return ; + } + 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 ( + <> + + +
+ + {d.kennzahlen.map((kachel, index) => ( + + + + ))} + + + + + + + + + + + + + + + {/* 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ß. */} + + + + + + + + + formatZahl(w)} /> + + + + + + + + + + {/* 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. */} + +
+

+ Kommt noch +

+

+ 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. +

+
+
+ + {d.reserviert.map((bereich, index) => ( + + + + ))} +
+
+ + ); +} + +/** + * 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 ( + + + + ); +} diff --git a/features/dashboard/routes/DashboardRoute.tsx b/features/dashboard/routes/DashboardRoute.tsx new file mode 100644 index 0000000..826623f --- /dev/null +++ b/features/dashboard/routes/DashboardRoute.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { DATENSATZ } from "../data/seed"; +import { NUTZER } from "../data/nutzer"; +import { HEUTE } from "../utils/dateUtils"; +import { baueDashboardVM } from "../view-models/dashboard.vm"; +import { DashboardSeite } from "../pages/DashboardSeite"; + +/** + * Route-Container des Dashboards. + * + * Anders als die Analytics-Routen greift diese nicht auf den Filter-Store zu: + * Das Dashboard hat keinen seitenweiten Filter, jede Kachel trägt ihren + * eigenen Zeitraum. Deshalb steht hier der ungefilterte Datensatz. + * + * `HEUTE` statt `new Date()`: Der Prototyp rechnet gegen einen festen + * Stichtag, damit die Zahlen über Reloads und Screenshots hinweg gleich + * bleiben — und damit Server- und Client-Rendering dieselbe Begrüßung + * ergeben. In HiveGrid Cloud steht an dieser Stelle die echte Uhr, und der + * Benutzer kommt aus `requireMember`: + * + * export async function loader({ request }: Route.LoaderArgs) { + * const member = await requireMember(request); + * return { vm: buildDashboardVM(await loadData(), member, new Date()) }; + * } + * + * Beim Einbau wird diese Datei ersetzt, nicht angepasst — die Seite darunter + * bleibt unberührt. + */ +export function DashboardRoute() { + return ; +} diff --git a/features/dashboard/view-models/dashboard.vm.ts b/features/dashboard/view-models/dashboard.vm.ts new file mode 100644 index 0000000..9bd5c21 --- /dev/null +++ b/features/dashboard/view-models/dashboard.vm.ts @@ -0,0 +1,268 @@ +/** + * View-Model des Dashboards. + * + * Das Dashboard ist **kein zweiter Analytics-Bereich**, sondern der + * Begrüßungsbildschirm: „Wie ist meine Lage?" statt „Warum ist das so?". + * Es zeigt heute ausschließlich Analytics-Kacheln, ist aber als Widget-Fläche + * gebaut — andere Bereiche (Aufgaben, Termine, HR-Anträge) kommen später als + * weitere Kacheln dazu, nicht als Umbau. + * + * Die Typen unten spiegeln `DashboardWidgetDomain`, `DashboardWidgetKind` und + * `DashboardWidgetPeriod` aus `@hivegrid/view-models` wortgleich. Beim Einbau + * wird der Import getauscht, die Kacheln bleiben. + * + * Wichtig: Anders als in Analytics gibt es **keinen seitenweiten Filter**. + * Jede Kachel trägt ihren eigenen Zeitraum und sagt ihn an. So hält es die + * Software (P11: „Edit = nur Widget-Filter/Zeitraum"). + */ + +import { + aktiveAuftraege, + handlungsbedarf, + offenePosten, + teamKennzahlen, + umsatz, + umsatzNachGewerk, + umsatzVerlauf, + umsatzVorperiode, + vertriebsTrichter, + type AlertEintrag, +} from "../data/calculations"; +import type { ChartPunkt, Datensatz, ZeitreihePunkt } from "../data/types"; +import { berechneTrend } from "../utils/formatters"; +import { bereit, leer, type VMState } from "./index"; + +export type WidgetDomaene = + | "general" + | "sales" + | "finance" + | "projects" + | "tasks" + | "hr" + | "activity" + | "comms" + | "schedule"; + +export type WidgetArt = "hero" | "kpi" | "list" | "trend" | "chart"; +export type WidgetZeitraum = "heute" | "30_tage" | "stichtag" | "gesamt"; + +export const ZEITRAUM_LABEL: Record = { + heute: "heute", + "30_tage": "letzte 30 Tage", + stichtag: "zum Stichtag", + gesamt: "über alle Zeiträume", +}; + +/** Eine Kennzahl-Kachel: Wert, Einordnung, Sprung in die Analyse. */ +export interface KennzahlKachel { + readonly key: string; + readonly domaene: WidgetDomaene; + readonly titel: string; + readonly wert: number; + /** Wie der Wert zu schreiben ist — Formatierung ist Darstellung, nicht Modell. */ + readonly formatAls: "euro" | "zahl" | "prozent"; + readonly zusatz: string; + readonly zeitraum: WidgetZeitraum; + readonly icon: string; + readonly href: string; + readonly trend?: { wert: number; richtung: "up" | "down" | "flat" }; + readonly richtungGut?: "positiv" | "negativ"; + /** Nur setzen, wenn die Farbe aus einem Schwellenvergleich kommt. */ + readonly akzent?: "neutral" | "positiv" | "warnung" | "negativ" | "info"; +} + +/** + * Ein Bereich, der später eigene Kacheln bekommt. + * + * Bewusst als Datensatz und nicht als hartkodiertes Stück Oberfläche: Sobald + * das jeweilige Feature Daten liefert, wird aus dem Eintrag eine echte Kachel, + * ohne dass das Raster angefasst werden muss. + */ +export interface ReservierterBereich { + readonly key: string; + readonly domaene: WidgetDomaene; + readonly titel: string; + readonly beschreibung: string; + readonly icon: string; + /** Woher die Daten kommen werden — als Verabredung, nicht als Versprechen. */ + readonly quelle: string; +} + +export interface DashboardDaten { + readonly begruessung: { + readonly anrede: string; + readonly name: string; + readonly rolle: string; + readonly datum: string; + }; + /** Ein Satz zur Lage — was der Betrachter wissen muss, bevor er weiterliest. */ + readonly lage: string; + readonly kennzahlen: KennzahlKachel[]; + readonly handlungsbedarf: AlertEintrag[]; + readonly umsatzVerlauf: ZeitreihePunkt[]; + readonly umsatzGesamt: number; + readonly umsatzVorher: number; + readonly gewerkUmsatz: ChartPunkt[]; + readonly trichter: ChartPunkt[]; + /** Sparkline unter der Umsatzkachel — dieselbe Reihe wie in Analytics. */ + readonly sparkline: number[]; + readonly auslastung: { name: string; wert: number; zusatz: string }[]; + readonly reserviert: ReservierterBereich[]; +} + +/** + * Tageszeit-Anrede. Die Uhrzeit kommt von außen, damit diese Funktion rein + * bleibt — der Prototyp reicht seinen festen Stichtag herein, die Software + * die echte Uhr. + */ +function anredeFuer(stunde: number): string { + if (stunde < 11) return "Guten Morgen"; + if (stunde < 18) return "Guten Tag"; + return "Guten Abend"; +} + +/** Der angemeldete Benutzer — in der Software aus der Sitzung, hier eine Attrappe. */ +export interface Nutzer { + readonly vorname: string; + readonly rolle: string; +} + +export function baueDashboardVM( + daten: Datensatz, + nutzer: Nutzer, + jetzt: Date +): VMState { + if (!daten.auftraege.length && !daten.rechnungen.length) return leer(); + + const posten = offenePosten(daten.rechnungen); + const aktiv = aktiveAuftraege(daten.auftraege); + const team = teamKennzahlen(daten.mitarbeiter, daten.zeiten, daten.auftraege, "30_tage"); + const monteure = team.filter((t) => t.mitarbeiter.status === "aktiv"); + const auslastung = monteure.length + ? monteure.reduce((s, t) => s + t.auslastungProzent, 0) / monteure.length + : 0; + const verlauf = umsatzVerlauf(daten.rechnungen, "30_tage"); + const umsatzAktuell = umsatz(daten.rechnungen, "30_tage"); + const umsatzVorher = umsatzVorperiode(daten.rechnungen, "30_tage"); + const alerts = handlungsbedarf(daten); + + const kritisch = alerts.filter((a) => a.art === "kritisch").length; + const lage = kritisch + ? `${kritisch} Punkte brauchen heute Ihre Aufmerksamkeit.` + : "Keine kritischen Punkte offen."; + + return bereit({ + begruessung: { + anrede: anredeFuer(jetzt.getHours()), + name: nutzer.vorname, + rolle: nutzer.rolle, + datum: jetzt.toLocaleDateString("de-DE", { + weekday: "long", + day: "2-digit", + month: "long", + }), + }, + lage, + kennzahlen: [ + { + key: "umsatz", + wert: umsatzAktuell, + formatAls: "euro", + domaene: "finance", + titel: "Umsatz", + zusatz: "Zahlungseingang", + zeitraum: "30_tage", + icon: "Euro", + href: "/analytics/finanzen", + trend: berechneTrend(umsatzAktuell, umsatzVorher), + }, + { + key: "forderungen", + wert: posten.gesamt, + formatAls: "euro", + domaene: "finance", + titel: "Offene Forderungen", + zusatz: `${posten.anzahl} Rechnungen · ${posten.ueberfaelligAnzahl} überfällig`, + zeitraum: "stichtag", + icon: "FileText", + href: "/analytics/finanzen", + akzent: posten.ueberfaelligAnzahl > 0 ? "warnung" : "neutral", + }, + { + key: "auftraege", + wert: aktiv.length, + formatAls: "zahl", + domaene: "projects", + titel: "Aktive Aufträge", + zusatz: "beauftragt oder in Arbeit", + zeitraum: "stichtag", + icon: "ClipboardList", + href: "/analytics/auftraege", + }, + { + key: "auslastung", + wert: auslastung, + formatAls: "prozent", + domaene: "hr", + titel: "Ø Auslastung", + zusatz: `${monteure.length} Personen im Einsatz`, + zeitraum: "30_tage", + icon: "HardHat", + href: "/analytics/team", + akzent: auslastung > 95 ? "warnung" : auslastung < 70 ? "info" : "positiv", + }, + ], + handlungsbedarf: alerts, + umsatzVerlauf: verlauf, + umsatzGesamt: umsatzAktuell, + umsatzVorher, + gewerkUmsatz: umsatzNachGewerk(daten.auftraege, daten.rechnungen, "30_tage"), + trichter: vertriebsTrichter(daten.auftraege), + sparkline: verlauf.slice(-10).map((p) => p.value), + // Sechs Zeilen: mehr passt nicht in eine halbe Kachel, ohne dass die + // Balken auf Haarbreite schrumpfen. Die vollständige Liste steht in + // Analytics — deshalb führt die Kachel dorthin. + auslastung: monteure.slice(0, 6).map((t) => ({ + name: t.mitarbeiter.name, + wert: t.auslastungProzent, + zusatz: t.mitarbeiter.gewerk, + })), + reserviert: RESERVIERTE_BEREICHE, + }); +} + +/** + * Bereiche, die das Dashboard später trägt. + * + * Alle drei sind im Designer-Brief P11 für die Rollen-Dashboards benannt + * („Büro: … meine Tasks, Anrufe heute, HR-Anträge"). Sie stehen hier als + * reservierte Plätze, damit sichtbar ist, dass das Dashboard mehr wird als + * eine Analytics-Zusammenfassung — und damit niemand das Raster dafür + * umbauen muss. + */ +const RESERVIERTE_BEREICHE: ReservierterBereich[] = [ + { + key: "tasks", + domaene: "tasks", + titel: "Meine Aufgaben", + beschreibung: "Was heute ansteht und wer zugewiesen ist.", + icon: "CheckSquare", + quelle: "Aufgaben", + }, + { + key: "schedule", + domaene: "schedule", + titel: "Termine heute", + beschreibung: "Einsätze, Aufmaße und Abnahmen des Tages.", + icon: "Calendar", + quelle: "Termine", + }, + { + key: "hr", + domaene: "hr", + titel: "HR-Anträge", + beschreibung: "Offene Urlaubs- und Zeitkorrekturanträge.", + icon: "UserCheck", + quelle: "HR-Hub", + }, +];