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