Files
Dashboard/features/dashboard/view-models/dashboard.vm.ts
T
dejanandClaude Opus 5 84ce322a19 Begruessung: Vorname als Platzhalter, Anwendung duzt
Zwei Aenderungen an der Ansprache.

"Guten Tag, [Vorname]" statt eines erfundenen Namens. Ein fester Name sieht in
einer Abnahme aus wie ein fest verdrahteter Wert, und irgendwann fragt jemand,
warum dort immer derselbe steht. Die Klammern sagen, was an der Stelle
eingesetzt wird; beim Einbau kommt der Wert aus der Sitzung.

Und die Anwendung duzt: "2 Punkte brauchen heute deine Aufmerksamkeit."

Vor der Aenderung geprueft, ob es weitere Siez-Formen gibt — es war genau
diese eine Stelle, einmal im View-Model und einmal in der Bausteingalerie.
Halb geduzt liest sich schlechter als durchgehend gesiezt, deshalb steht die
Ansprache jetzt als Regel in DESIGN-SYSTEM.md, zusammen mit dem Hinweis, dass
beim Einbau im Zweifel die Ansprache von HiveGrid Vorrang hat.

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

276 lines
9.0 KiB
TypeScript

/**
* 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.
*
* Diese Bereiche werden auf dem Dashboard **nicht angezeigt**. Ein
* Begrüßungsbildschirm ist kein Ort für Ankündigungen. Sie sind eine
* Verabredung für den Einbau und stehen deshalb in der Bausteingalerie
* (`/komponenten`), nicht auf der Seite selbst.
*/
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 }[];
}
/**
* 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);
// Die Anwendung duzt. Das ist eine Festlegung fuer den ganzen Prototyp und
// nicht nur fuer diesen Satz — halb geduzt liest sich schlechter als
// durchgehend gesiezt.
const kritisch = alerts.filter((a) => a.art === "kritisch").length;
const lage = kritisch
? `${kritisch} Punkte brauchen heute deine 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,
})),
});
}
/**
* 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") und haben in HiveGrid
* bereits ihre Widget-Domäne. Sie stehen hier, damit beim Einbau feststeht,
* was noch dazukommt und wo — und damit niemand das Raster dafür umbaut.
*
* Gezeigt werden sie in der Bausteingalerie unter `/komponenten`.
*/
export 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",
},
];