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
269 lines
8.6 KiB
TypeScript
269 lines
8.6 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.
|
|
*/
|
|
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",
|
|
},
|
|
];
|