Files
Dashboard/features/dashboard/view-models/dashboard.vm.ts
T
dejanandClaude Opus 5 6a0b36ddcc Geviertstriche aus Text, Kommentaren und Doku entfernen
467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.

Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:

- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
  Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
  Klammern oder als eigener Satz.

- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
  ("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
  Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.

- Pfade, denen ein Komma statt eines Doppelpunkts folgte.

- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.

Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.

tsc, lint, build, verify:carbon und verify:layout laufen sauber.

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

276 lines
8.9 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",
},
];