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", + }, +];