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

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