Dashboard als Widget-Flaeche bauen, Platzhalter ersetzen

Das Dashboard ist ein eigenes Feature neben Analytics: der
Begruessungsbildschirm, nicht ein zweiter Analysebereich. Es zeigt heute nur
Auswertungen, ist aber so gebaut, dass die spaeteren Bereiche als weitere
Kacheln dazukommen und nicht als Umbau.

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

Drei Entscheidungen, die dabei getroffen wurden:

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

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

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

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

Nebenbei zwei Fehler behoben, die auch Analytics betrafen:

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-31 16:51:09 +02:00
1 parent 4cb0b5c799
commit 831a6ec9ad
19 files changed
+1085 -125

No files matched your search

+5 -3
View File
@@ -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:
+13 -8
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
import { DashboardPlatzhalterSeite } from "@/features/dashboard/pages/DashboardPlatzhalterSeite";
import { DashboardRoute } from "@/features/dashboard/routes/DashboardRoute";
export default function Page() {
return <DashboardPlatzhalterSeite />;
return <DashboardRoute />;
}
+9 -5
View File
@@ -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);
}
+6 -5
View File
@@ -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
+96 -14
View File
@@ -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
`<Widget breite={…} start={…}>`, 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.
+13 -6
View File
@@ -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.
---
@@ -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({
</span>
) : (
<span className="text-[11px] leading-tight text-text-neutral-tertiary">
{BEZUG_LABEL[bezug]}
{bezugText ?? BEZUG_LABEL[bezug]}
</span>
)}
@@ -66,9 +66,25 @@ export function AreaVerlauf({
const hatWerte = daten.some((d) => d.value > 0 || (d.vergleich ?? 0) > 0);
if (!daten.length || !hatWerte) return <ChartLeer hoehe={hoehe} />;
// 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({
</defs>
<CartesianGrid stroke={CHART.grid} vertical={false} />
<XAxis dataKey="label" interval={intervall} {...achseX} />
<XAxis dataKey="label" interval={intervall} minTickGap={12} {...achseX} />
<YAxis
domain={raster.domain}
ticks={raster.ticks}
@@ -0,0 +1,62 @@
"use client";
import React from "react";
import { FeatherAlertTriangle, FeatherCheckCircle } from "@subframe/core";
import { STATUS_FARBEN } from "../../utils/chartTheme";
/**
* Der Begrüßungskopf des Dashboards — in der Software das `hero`-Widget
* (`DashboardWidgetKind = "hero"`, siehe `dashboard-page.vm.ts`).
*
* Er beantwortet die einzige Frage, die vor allen Zahlen kommt: „Muss ich
* heute etwas tun?" Deshalb steht neben der Anrede ein Satz zur Lage und
* nicht etwa eine weitere Kennzahl — die kommen darunter.
*
* Bewusst ohne Rahmen und ohne Karte: Eine Karte behauptet, hier stünden
* Daten, die man mit den Nachbarkarten vergleichen kann. Das ist nicht der
* Fall — das ist eine Ansprache. Sie trägt die Seitenüberschrift, deshalb
* hat diese Seite keinen `PageHeader` mit „Dashboard" darüber: Zwei
* Überschriften übereinander, von denen eine den Namen der Seite und die
* andere den Namen des Lesers nennt, sind eine zu viel.
*/
export function Begruessung({
anrede,
name,
datum,
rolle,
lage,
kritisch,
}: {
anrede: string;
name: string;
datum: string;
rolle: string;
lage: string;
/** Färbt den Lage-Satz. Farbe nur, wo sie eine Aussage trägt. */
kritisch: boolean;
}) {
return (
<div className="flex flex-col gap-3 px-4 pb-2 pt-6 sm:px-6">
<div className="flex flex-col gap-1">
<span className="text-[13px] leading-none text-text-neutral-tertiary">
{datum} · {rolle}
</span>
<h1 className="break-words text-[26px] font-semibold leading-tight tracking-[-0.02em] text-text-neutral-primary sm:text-[30px]">
{anrede}, {name}
</h1>
</div>
<span
className="flex items-center gap-2 text-[14px] leading-snug"
style={{ color: kritisch ? STATUS_FARBEN.negativ : STATUS_FARBEN.positiv }}
>
{kritisch ? (
<FeatherAlertTriangle className="flex-none text-[16px]" />
) : (
<FeatherCheckCircle className="flex-none text-[16px]" />
)}
<span className="min-w-0 break-words">{lage}</span>
</span>
</div>
);
}
@@ -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 (
<section className="flex h-full min-h-[168px] flex-col gap-2 rounded-lg border border-dashed border-border-default bg-neutral-50 px-4 py-4">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-white text-text-neutral-subtle">
<FeatherIcon name={bereich.icon} className="text-[14px]" />
</span>
<h2 className="min-w-0 break-words text-[14px] font-semibold leading-tight text-text-neutral-secondary">
{bereich.titel}
</h2>
</div>
<p className="text-[12px] leading-snug text-text-neutral-tertiary">{bereich.beschreibung}</p>
<span className="mt-auto inline-flex w-fit items-center rounded-full border border-solid border-border-default bg-white px-2 py-0.5 text-[11px] font-medium text-text-neutral-tertiary">
Folgt mit {"„"}{bereich.quelle}{"“"}
</span>
</section>
);
}
@@ -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<WidgetDomaene, string> = {
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 (
<section
/* @container: Die Kachelbreite hängt am Raster, nicht am Fenster. Eine
Vier-Zwölftel-Kachel ist bei 1024 px rund 235 px breit — dieselbe
Kachel bei 1920 px doppelt so viel. Was sich im Kopf einblendet, muss
deshalb die Kachel messen und nicht den Viewport. */
className={`@container flex h-full flex-col overflow-hidden rounded-lg border border-solid border-border-default bg-white shadow-sm ${className}`}
>
<header className="flex flex-none items-start justify-between gap-3 px-4 pb-3 pt-4">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<h2 className="min-w-0 break-words text-[14px] font-semibold leading-tight text-text-neutral-primary">
{titel}
</h2>
{hinweis ? (
<span
title={hinweis}
className="flex h-4 w-4 flex-none cursor-help items-center justify-center text-text-neutral-subtle hover:text-text-neutral-secondary"
>
<FeatherInfo className="text-[13px]" />
</span>
) : null}
</div>
{untertitel ? (
<p className="line-clamp-2 break-words text-[12px] leading-snug text-text-neutral-tertiary">
{untertitel}
</p>
) : null}
</div>
<span className="flex flex-none items-center gap-2">
{/* 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. */}
<span className="hidden whitespace-nowrap rounded-full bg-neutral-50 px-2 py-0.5 text-[11px] font-medium text-text-neutral-tertiary @[24rem]:inline">
{ZEITRAUM_LABEL[zeitraum]}
</span>
<span className="text-text-neutral-subtle">
<FeatherIcon name={DOMAENEN_ICON[domaene]} className="text-[16px]" />
</span>
</span>
</header>
<div className="min-h-0 grow px-4 pb-4">{children}</div>
{absprung ? (
<footer className="dv-no-print flex flex-none items-center px-4 pb-4">
<Link
href={absprung}
className="flex items-center gap-1 text-[12px] font-medium text-text-feedback-link-default no-underline hover:underline"
>
{absprungText}
<FeatherArrowRight className="text-[13px]" />
</Link>
</footer>
) : null}
</section>
);
}
@@ -0,0 +1,97 @@
import React from "react";
/**
* Das Kachelraster des Dashboards.
*
* Übernommen aus `DashboardPage.tsx` der Software — dort steht wörtlich
*
* <div className="grid grid-cols-1 gap-4 md:grid-cols-2
* lg:grid-flow-row-dense lg:grid-cols-12">
*
* 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
* `<Widget breite={8} start={0}>` 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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-flow-row-dense lg:grid-cols-12">
{children}
</div>
);
}
/**
* 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 <div className={`min-w-0 ${breiteKlasse} ${startKlasse} ${mittel}`}>{children}</div>;
}
@@ -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
+19
View File
@@ -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,
};
@@ -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 (
<>
<PageHeader
titel="Dashboard"
beschreibung="Der Begrüßungsbildschirm nach der Anmeldung — eigenes Feature, in diesem Prototyp noch nicht gestaltet."
standardAktionen={false}
/>
<PageContainer>
<EmptyState
outlined
title="Noch nicht gestaltet"
body={
<span className="flex flex-col gap-3">
<span>
Analytics und Dashboard sind zwei Features. Dieser Prototyp
gestaltet <strong>Analytics</strong> — den Analysebereich mit
Zeitraum- und Dimensionsfiltern. Das Dashboard beantwortet eine
andere Frage: {"„Wie ist meine Lage?“ statt „Warum ist das so?“."}
</span>
<span>
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.
</span>
<span className="text-text-neutral-tertiary">
Zuschnitt und offene Punkte stehen in{" "}
<code className="rounded-sm bg-neutral-100 px-1 py-0.5 text-[12px]">
docs/SCOPE-ANALYTICS-DASHBOARD.md
</code>
</span>
</span>
}
action={
<Link href={ANALYTICS_START} className="no-underline">
<Button variant="primary" iconRight={<FeatherArrowRight />}>
Zu Analytics
</Button>
</Link>
}
/>
</PageContainer>
</>
);
}
+255
View File
@@ -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 `<Widget breite={…} start={…}>`. Diese beiden Zahlen
* sind in der Software `widget.size.w` und `widget.position.x` und kommen dort
* aus dem gespeicherten Layout — der Benutzer kann es bearbeiten. Hier sind
* sie geschrieben. Das ist der einzige Unterschied; das Raster darunter ist
* dasselbe (siehe `WidgetRaster.tsx`).
*
* ## Was hier bewusst fehlt
*
* Kein seitenweiter Filter. In Analytics wirkt eine Filterleiste auf alle
* Karten; im Dashboard trägt jede Kachel ihren eigenen Zeitraum und sagt ihn
* an (P11: „Edit = nur Widget-Filter/Zeitraum"). Eine Filterleiste über
* dieser Seite wäre die bequemere Lösung und der falsche Weg — sie würde die
* Kacheln beim Einbau alle wieder auseinandernehmen.
*
* Auch keine Rollenumschaltung. Der Prototyp gestaltet die Sicht der
* Geschäftsführung; die Layouts für Büro und Monteur entstehen aus denselben
* Kacheln in anderer Zusammenstellung und sind eine Produktentscheidung,
* keine Gestaltungsfrage.
*/
export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
if (vm.status !== "ready") {
return <SeitenZustand vm={vm} />;
}
const d = vm.data;
const kritisch = d.handlungsbedarf.some((a) => a.art === "kritisch");
/** Die vier Kennzahlen sitzen nebeneinander in je drei von zwölf Spalten. */
const kennzahlStart = [0, 3, 6, 9] as const;
return (
<>
<Begruessung
anrede={d.begruessung.anrede}
name={d.begruessung.name}
datum={d.begruessung.datum}
rolle={d.begruessung.rolle}
lage={d.lage}
kritisch={kritisch}
/>
<div className="flex flex-col gap-6 px-4 pb-10 sm:px-6">
<WidgetRaster>
{d.kennzahlen.map((kachel, index) => (
<Widget key={kachel.key} breite={3} start={kennzahlStart[index]}>
<KennzahlWidget kachel={kachel} sparkline={kachel.key === "umsatz" ? d.sparkline : undefined} />
</Widget>
))}
<Widget breite={12} start={0}>
<WidgetKachel
titel="Handlungsbedarf"
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
domaene="general"
zeitraum="stichtag"
>
<AlertList eintraege={d.handlungsbedarf} spalten={2} />
</WidgetKachel>
</Widget>
<Widget breite={8} start={0}>
<WidgetKachel
titel="Zahlungseingang im Zeitverlauf"
untertitel="Aktueller Zeitraum gegen den Vorjahreszeitraum"
hinweis="Gezählt wird der Tag des Zahlungseingangs, nicht das Rechnungsdatum. Dadurch entspricht die Kurve dem tatsächlichen Geldfluss."
domaene="finance"
zeitraum="30_tage"
absprung="/analytics/finanzen"
absprungText="In den Finanzen auswerten"
>
<ChartLegende
eintraege={[
{ name: "Aktueller Zeitraum", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE, gestrichelt: true },
]}
/>
<AreaVerlauf
daten={d.umsatzVerlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={240}
/>
</WidgetKachel>
</Widget>
{/* mittelVoll: Der Verlauf daneben nimmt zwischen `md` und `lg` die
volle Breite. Ohne das stünde diese Kachel dort allein in einer
halben Zeile, und die andere Hälfte bliebe weiß. */}
<Widget breite={4} start={8} mittelVoll>
<WidgetKachel
titel="Umsatz nach Gewerk"
untertitel="Zahlungseingang der letzten 30 Tage"
hinweis="Fünf Gewerke in fester Rangfolge — dieselbe Kodierung wie auf der Finanzseite."
domaene="finance"
zeitraum="30_tage"
absprung="/analytics/finanzen"
>
<BalkenHorizontal
daten={d.gewerkUmsatz}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</WidgetKachel>
</Widget>
<Widget breite={6} start={0}>
<WidgetKachel
titel="Vertriebstrichter"
untertitel="Angebotsgeschäft ab 6.000 €, zum Stichtag"
hinweis="Nur Vorhaben, für die ein schriftliches Angebot erstellt wird. Wartung und Notdienst laufen ohne Angebot und würden die Quoten verfälschen."
domaene="sales"
zeitraum="stichtag"
absprung="/analytics/auftraege"
absprungText="Aufträge auswerten"
>
<Trichter stufen={d.trichter} formatWert={(w) => formatZahl(w)} />
</WidgetKachel>
</Widget>
<Widget breite={6} start={6}>
<WidgetKachel
titel="Auslastung Monteure"
untertitel="Produktive Stunden gegen verfügbare Stunden"
hinweis="Nur auf Aufträge gebuchte Stunden zählen als produktiv. Über 100 % bedeutet Überstunden."
domaene="hr"
zeitraum="30_tage"
absprung="/analytics/team"
absprungText="Ganzes Team ansehen"
>
<AuslastungsBalken zeilen={d.auslastung} />
</WidgetKachel>
</Widget>
{/* Trennung vor den leeren Plätzen: Ohne sie stehen drei Kacheln
ohne Inhalt neben sechs mit Inhalt, und der Leser sucht den
Ladefehler. Die Überschrift beantwortet die Frage vorher. */}
<Widget breite={12} start={0}>
<div className="flex flex-col gap-1 pt-2">
<h2 className="text-[14px] font-semibold leading-tight text-text-neutral-primary">
Kommt noch
</h2>
<p className="text-[12px] leading-snug text-text-neutral-tertiary">
Das Dashboard zeigt heute nur Auswertungen. Diese drei Bereiche sind dafür
vorgesehen und haben in HiveGrid bereits ihre Widget-Domäne — sie brauchen kein
neues Raster, nur Daten.
</p>
</div>
</Widget>
{d.reserviert.map((bereich, index) => (
<Widget key={bereich.key} breite={4} start={([0, 4, 8] as const)[index]}>
<ReservierteKachel bereich={bereich} />
</Widget>
))}
</WidgetRaster>
</div>
</>
);
}
/**
* Eine Kennzahl als Kachel.
*
* Es ist dieselbe `KPICard` wie in Analytics — dieselbe Anatomie, dieselben
* Regeln zu Trend und Farbe. Neu ist nur, dass sie ein Link ist: Auf dem
* Dashboard ist jede Zahl ein Absprung in die Analyse, die sie erklärt. Genau
* so macht es die Software (`widget.href` legt den Link um die `StatsCard`).
*/
function KennzahlWidget({
kachel,
sparkline,
}: {
kachel: KennzahlKachel;
sparkline?: number[];
}) {
const wert =
kachel.formatAls === "euro"
? formatEuro(kachel.wert)
: kachel.formatAls === "prozent"
? formatProzent(kachel.wert, 0)
: formatZahl(kachel.wert);
return (
<Link
href={kachel.href}
className="block h-full rounded-lg no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={`${kachel.titel}: ${wert} — zur Analyse`}
>
<KPICard
label={kachel.titel}
wert={wert}
zusatz={kachel.zusatz}
trend={kachel.trend}
/* Der Zeitraum der Kachel, ausgeschrieben. "im gewählten Zeitraum"
wie in Analytics wäre hier eine Lüge: Auf dem Dashboard hat niemand
etwas gewählt. */
bezugText={ZEITRAUM_LABEL[kachel.zeitraum]}
icon={kachel.icon}
akzent={kachel.akzent}
richtungGut={kachel.richtungGut}
verlauf={sparkline}
/>
</Link>
);
}
@@ -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 <DashboardSeite vm={baueDashboardVM(DATENSATZ, NUTZER, HEUTE)} />;
}
@@ -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<WidgetZeitraum, string> = {
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<DashboardDaten> {
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",
},
];