Der Begruessungsbildschirm hatte einen Abschnitt "Kommt noch" mit drei leeren
Kacheln. Das ist der falsche Ort: Wer sich morgens anmeldet, will seine Lage
sehen und nicht lesen, was spaeter einmal kommt. Ein Dashboard ist kein Ort
fuer Ankuendigungen.
Die Verabredung bleibt vollstaendig erhalten, nur an Stellen, wo sie hingehoert
— alle drei fuer den Einbau gedacht:
1. RESERVIERTE_BEREICHE in view-models/dashboard.vm.ts, jetzt exportiert: die
Kacheln als Datensatz mit Titel, Widget-Domaene und Quell-Feature.
2. Ein markierter Kommentarblock in pages/DashboardSeite.tsx an genau der
Stelle im Raster, an die sie gehoeren — mit den konkreten Werten
(<Widget breite={4}> mit start 0, 4 und 8) und dem Hinweis, dass das Raster
dafuer nicht angefasst werden muss.
3. Die Bausteingalerie unter /komponenten, neuer Abschnitt "Reservierte
Kacheln" — dort sind alle drei gestaltet zu sehen, aus derselben Konstante
gerendert statt abgeschrieben.
docs/SCOPE-ANALYTICS-DASHBOARD.md nennt die drei Stellen jetzt ausdruecklich
unter "Wo die spaeteren Bereiche stehen", damit beim Port niemand sucht.
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
273 lines
8.8 KiB
TypeScript
273 lines
8.8 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);
|
|
|
|
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,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
},
|
|
];
|