diff --git a/HANDOVER.md b/HANDOVER.md index 50418b8..68f403b 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -15,7 +15,9 @@ Was echt ist, was Attrappe ist und was beim Einbau zu tun bleibt: | Chart-Komponenten | `features/dashboard/components/charts/` | Vollständig, mit Leerzuständen und Tooltips | | Karten & Tabellen | `features/dashboard/components/cards/`, `tables/` | Vollständig | | Filtersystem | `features/dashboard/context/FilterContext.tsx`, `data/filtering.ts` | State und Relevanzlogik fertig | -| Seitenaufbau | `features/dashboard/pages/` | Raster und Reihenfolge stehen | +| Seitenaufbau | `features/dashboard/pages/` | **Reine Komponenten** `({ vm })` — kein Store-Zugriff, direkt übernehmbar | +| View-Model-Builder | `features/dashboard/view-models/*.vm.ts` | Reine Funktionen `(daten, filter) → VMState<…>`; im Loader wiederverwendbar | +| Seitenzustände | `components/layout/SeitenZustand.tsx` | idle · loading · empty · error ausgestaltet | --- @@ -99,9 +101,16 @@ halb zu machen. 1. `data/types.ts` gegen die echten Entity-Typen abgleichen 2. `data/seed.ts` durch die echte Datenquelle ersetzen 3. `HEUTE` in `utils/dateUtils.ts` auf `new Date()` umstellen -4. Eine Seite vollständig durchziehen (Übersicht) — dabei zeigt sich, welche Felder fehlen -5. Filter serverseitig nachziehen, sobald die Datenmengen es erfordern -6. Export, Absprünge und Persistenz am Benutzerprofil ergänzen +4. `view-models/index.ts` gegen `@hivegrid/view-models` tauschen — der Vertrag + (`VMState`) ist wortgleich, es ist ein Import-Wechsel +5. Eine Seite vollständig durchziehen (Übersicht): Loader ruft + `baueUebersichtVM()`, die Route rendert ``. Der + Container unter `routes/` entfällt dabei, die Seite bleibt unverändert. + Dabei zeigt sich, welche Felder in den echten Daten fehlen +6. Texte auf i18n-Keys ziehen, `data-ui-id` ergänzen, `mobile:`-Verhalten + nachrüsten (siehe `docs/LEITPLANKEN.md`) +7. Filter serverseitig nachziehen, sobald die Datenmengen es erfordern +8. Export, Absprünge und Persistenz am Benutzerprofil ergänzen --- diff --git a/README.md b/README.md index fe07985..9fbba66 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ Bezeichner im Code. Das erspart beim Einbau die ständige Übersetzung zwischen ``` app/ Next.js App Router — je Seite nur ein Wrapper features/dashboard/ + routes/ Verdrahtung Store → View-Model → Seite (wird beim + Einbau durch den Loader ersetzt) + view-models/ VMState-Vertrag + reine Builder je Seite components/ layout/ AppShell, PageHeader, PageContainer, Navigation cards/ KPICard, ChartCard, AlertList, StatZeile @@ -41,14 +44,21 @@ features/dashboard/ context/ Filter-, Widget- und Toast-State data/ types · seed (Dummy-Daten) · calculations · filtering hooks/ useAnalyseDaten - pages/ die sechs Seitenkompositionen + pages/ die sechs Seiten — reine Komponenten ({ vm }) utils/ chartTheme (Carbon-Tokens), formatters, dateUtils ui/ Subframe-Komponentenbibliothek (unverändert übernommen) docs/ Design-System, KPI-Katalog, Navigation, Datenmodell, Herkunft, Zielsystem, Leitplanken ``` -**Wichtigste Datei für den Einbau:** `features/dashboard/data/calculations.ts`. -Dort steht jede Kennzahl als reine Funktion — Datenquelle austauschen, Rest bleibt. +**Wichtigste Dateien für den Einbau:** `features/dashboard/data/calculations.ts` +(jede Kennzahl als reine Funktion) und `features/dashboard/view-models/` +(je Seite ein reiner Builder `(daten, filter) → VMState<…>`). + +Die Seiten unter `pages/` sind **reine Komponenten** — sie bekommen ihr +View-Model als Prop und greifen auf keinen Store zu. Das ist genau das Muster, +das HiveGrid Cloud verlangt (``): Beim Einbau tritt der +Loader an die Stelle des Containers unter `routes/`, die Seiten selbst bleiben +unverändert. Details in [`docs/ZIELSYSTEM.md`](docs/ZIELSYSTEM.md) Abschnitt 3.1. --- diff --git a/app/auftraege/page.tsx b/app/auftraege/page.tsx index 29bf9f2..1687ab0 100644 --- a/app/auftraege/page.tsx +++ b/app/auftraege/page.tsx @@ -1,5 +1,5 @@ -import { AuftraegeSeite } from "@/features/dashboard/pages/AuftraegeSeite"; +import { AuftraegeRoute } from "@/features/dashboard/routes/AuftraegeRoute"; export default function Page() { - return ; + return ; } diff --git a/app/finanzen/page.tsx b/app/finanzen/page.tsx index 0fc5bf8..1fa8fc6 100644 --- a/app/finanzen/page.tsx +++ b/app/finanzen/page.tsx @@ -1,5 +1,5 @@ -import { FinanzenSeite } from "@/features/dashboard/pages/FinanzenSeite"; +import { FinanzenRoute } from "@/features/dashboard/routes/FinanzenRoute"; export default function Page() { - return ; + return ; } diff --git a/app/kunden/page.tsx b/app/kunden/page.tsx index 94aadac..ca0bf0a 100644 --- a/app/kunden/page.tsx +++ b/app/kunden/page.tsx @@ -1,5 +1,5 @@ -import { KundenSeite } from "@/features/dashboard/pages/KundenSeite"; +import { KundenRoute } from "@/features/dashboard/routes/KundenRoute"; export default function Page() { - return ; + return ; } diff --git a/app/page.tsx b/app/page.tsx index 267a6d7..dfa0f70 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ -import { UebersichtSeite } from "@/features/dashboard/pages/UebersichtSeite"; +import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute"; export default function Page() { - return ; + return ; } diff --git a/app/team/page.tsx b/app/team/page.tsx index 7618476..db88633 100644 --- a/app/team/page.tsx +++ b/app/team/page.tsx @@ -1,5 +1,5 @@ -import { TeamSeite } from "@/features/dashboard/pages/TeamSeite"; +import { TeamRoute } from "@/features/dashboard/routes/TeamRoute"; export default function Page() { - return ; + return ; } diff --git a/docs/LEITPLANKEN.md b/docs/LEITPLANKEN.md index f7735b4..85f2516 100644 --- a/docs/LEITPLANKEN.md +++ b/docs/LEITPLANKEN.md @@ -204,8 +204,13 @@ Umgekehrt übernommen wurden: keine Versalien in Spaltenköpfen, Raster mit 16 p Abstand, Kennzahlenreihe über Filterleiste über Liste, Leerzustände an jeder Visualisierung. -**Offen für den Einbau** (aus dem Cloud-Abgleich, hier bewusst nicht gebaut): -Screens als reine `({ vm, actions })`-Komponenten, Texte über i18n-Keys, -`mobile:`-Verhalten, `data-ui-id` auf allen Flächen, Textalternative je Diagramm. -Das ist Portierungsarbeit, keine Gestaltung — Details in +**Bereits umgesetzt:** Die Seiten sind reine `({ vm })`-Komponenten mit dem +wortgleich gespiegelten `VMState`-Vertrag, die Berechnung liegt in reinen +Buildern unter `view-models/`, und die fünf Seitenzustände sind ausgestaltet. +Store-Zugriff gibt es ausschließlich in `routes/` — genau der Schicht, die beim +Einbau durch den Loader ersetzt wird. + +**Offen für den Einbau** (Portierungsarbeit, keine Gestaltung): Texte über +i18n-Keys, `mobile:`-Verhalten, `data-ui-id` auf allen Flächen, Textalternative +je Diagramm, `actions`-Callbacks statt Demo-Toasts. Details in [ZIELSYSTEM.md](ZIELSYSTEM.md) Abschnitt 3. diff --git a/docs/ZIELSYSTEM.md b/docs/ZIELSYSTEM.md index f1e649e..1db655e 100644 --- a/docs/ZIELSYSTEM.md +++ b/docs/ZIELSYSTEM.md @@ -61,12 +61,44 @@ Grund, warum der Einbau realistisch ein Port ist und keine Neuimplementierung. ## 3. Was beim Einbau konkret anders wird -### 3.1 Daten kommen als View-Model-Prop +### 3.1 Daten kommen als View-Model-Prop — bereits umgesetzt -Der Prototyp importiert `DATENSATZ` direkt. In der Cloud gilt das Muster -``: Die Route besitzt Loader, SSE und Actions, die -Screen-Komponente ist rein und bekommt alles als Prop. Kein `useLoaderData` -in der Komponente. +**Das ist im Prototyp schon so gebaut.** Jede Analyse-Seite ist eine reine +Komponente: + +```tsx +export function UebersichtSeite({ vm }: { vm: VMState }) { + if (vm.status !== "ready") return ; + const d = vm.data; + … +} +``` + +Die Verdrahtung sitzt in einem eigenen Container unter +`features/dashboard/routes/`, die Berechnung in einem reinen Builder unter +`features/dashboard/view-models/`: + +``` +view-models/uebersicht.vm.ts baueUebersichtVM(daten, filter) → VMState +pages/UebersichtSeite.tsx ({ vm }) → JSX ← das ist der Teil, der portiert wird +routes/UebersichtRoute.tsx Store + Builder + Seite ← das ist der Teil, der ersetzt wird +app/page.tsx rendert den Container +``` + +Beim Einbau tritt der Loader an die Stelle des Containers: + +```tsx +// apps/web/app/routes/app.$tenant/dashboard.tsx +export default function Route() { + const { vm } = useLoaderData(); + return ; +} +``` + +`features/dashboard/view-models/index.ts` spiegelt `VMState` wortgleich aus +`@hivegrid/view-models`. Der Import wird getauscht, die Seiten bleiben +unberührt. Die fünf Zustände (`idle` · `loading` · `ready` · `empty` · `error`) +sind über `SeitenZustand` bereits ausgestaltet. ```ts export type DashboardPageVM = VMState; @@ -83,10 +115,27 @@ Fünf Zustände statt einem. Der Prototyp kennt nur „Daten da"; `loading`, `empty` und `error` sind je Seite zu ergänzen. Die Chart-Leerzustände (`ChartLeer`) decken davon nur einen Teil ab. -`DashboardPageData` führt außerdem `activeRole`, `layout`, `generatedAt`, -`streamUrl`, `canCustomize`, `liveUpdatesEnabled`, `layoutSource` und -`catalog` — das Dashboard ist dort rollen- und berechtigungsabhängig, was der -Prototyp nicht abbildet. +Was der Builder noch **nicht** kennt: `DashboardPageData` führt in der Cloud +zusätzlich `activeRole`, `layout`, `generatedAt`, `streamUrl`, `canCustomize`, +`liveUpdatesEnabled`, `layoutSource` und `catalog`. Das Dashboard ist dort +rollen- und berechtigungsabhängig — diese Felder kommen beim Einbau dazu. + +### 3.1a Warum der Prototyp auf Next.js bleibt + +Die Frage liegt nahe, weil die Software auf React Router 7 läuft. Ein Umbau +brächte aber nichts: Im gesamten Prototyp gibt es **vier** Next-spezifische +Importe, alle in zwei Dateien — + +| Datei | Import | Schicksal beim Einbau | +|---|---|---| +| `components/layout/AppShell.tsx` | `next/link`, `next/navigation` | entfällt, die Cloud bringt Shell und Sidebar mit | +| `app/layout.tsx` | `next`, `next/font/google` | entfällt, Root-Layout gehört der Cloud | + +Portiert wird nicht die Anwendung, sondern der Ordner +`features/dashboard/{pages,components,view-models,utils}` — und der enthält +keine Framework-Bindung. React 19, Tailwind v4 und Recharts sind in beiden +Repos dieselben. Ein Framework-Wechsel im Prototyp wäre Aufwand ohne Ertrag und +würde die Gestaltungsarbeit nur verzögern. ### 3.2 Kein eigenes App-Chrome diff --git a/features/dashboard/components/filters/AnalyticsToolbar.tsx b/features/dashboard/components/filters/AnalyticsToolbar.tsx index 5b1a6bf..6429714 100644 --- a/features/dashboard/components/filters/AnalyticsToolbar.tsx +++ b/features/dashboard/components/filters/AnalyticsToolbar.tsx @@ -5,8 +5,8 @@ import { FeatherEye, FeatherRotateCcw, FeatherX } from "@subframe/core"; import { MehrfachAuswahl } from "./MehrfachAuswahl"; import { useFilter, type MehrfachFeld } from "../../context/FilterContext"; import { useWidgets } from "../../context/WidgetContext"; -import { DATENSATZ } from "../../data/seed"; import { FILTER_RELEVANZ, type SeitenId } from "../../data/filtering"; +import type { FilterOptionen } from "../../view-models/filter-optionen"; import { AUFTRAG_STATUS_LABEL, GEWERKE, @@ -25,27 +25,20 @@ const ZEITRAEUME: ZeitraumPreset[] = ["heute", "7_tage", "30_tage", "quartal", " * sonst wirkungslos wirken und Vertrauen kosten. * Aktive Einschränkungen erscheinen darunter als entfernbare Chips (FILT-06). */ -export function AnalyticsToolbar({ seite }: { seite: SeitenId }) { +export function AnalyticsToolbar({ + seite, + optionen, +}: { + seite: SeitenId; + /** Auswahllisten aus dem vollständigen Datensatz — in der Software aus dem Loader. */ + optionen: FilterOptionen; +}) { const { filter, setFilter, toggleWert, leereFeld, zuruecksetzen } = useFilter(); const { versteckt, titel, zeige, zeigeAlle } = useWidgets(); const erlaubt = new Set(FILTER_RELEVANZ[seite]); - const quellen = React.useMemo( - () => [...new Set(DATENSATZ.kunden.map((k) => k.quelle))].sort(), - [] - ); - const regionen = React.useMemo( - () => [...new Set(DATENSATZ.kunden.map((k) => k.region))].sort(), - [] - ); - const monteure = React.useMemo( - () => - DATENSATZ.mitarbeiter - .filter((m) => m.rolle === "Monteur" || m.rolle === "Meister") - .map((m) => ({ wert: m.id, label: m.name, zusatz: m.gewerk })), - [] - ); + const { quellen, regionen, monteure } = optionen; const chips: { feld: MehrfachFeld; wert: string; label: string }[] = []; if (erlaubt.has("gewerke")) @@ -69,7 +62,7 @@ export function AnalyticsToolbar({ seite }: { seite: SeitenId }) { ...filter.mitarbeiterIds.map((id) => ({ feld: "mitarbeiterIds" as const, wert: id, - label: DATENSATZ.mitarbeiter.find((m) => m.id === id)?.name ?? id, + label: optionen.mitarbeiterNamen[id] ?? id, })) ); diff --git a/features/dashboard/components/layout/SeitenZustand.tsx b/features/dashboard/components/layout/SeitenZustand.tsx new file mode 100644 index 0000000..8e29cd3 --- /dev/null +++ b/features/dashboard/components/layout/SeitenZustand.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React from "react"; +import { FeatherAlertTriangle, FeatherInbox } from "@subframe/core"; +import { STATUS_FARBEN } from "../../utils/chartTheme"; +import { PageContainer } from "./PageContainer"; +import type { VMState } from "../../view-models"; + +/** + * Nicht-fertige Zustände einer Seite: idle · loading · empty · error. + * + * Der Prototyp lief bisher nur im Zustand „Daten da". Die Cloud verlangt alle + * fünf (`VMState`), und die Zustände sind dort Pflicht in der Definition of + * Done. Sie hier zu haben kostet wenig und erspart beim Einbau, sie nachträglich + * in jede Seite einzuweben. + * + * Gestaltung bewusst zurückhaltend: Das sind Übergänge, keine Inhalte. + */ +export function SeitenZustand({ vm }: { vm: VMState }) { + if (vm.status === "ready") return null; + + return ( + +
+ {vm.status === "loading" || vm.status === "idle" ? ( + <> + + + Kennzahlen werden berechnet … + + {/* Skelett statt Sprung: die Karten stehen später an derselben Stelle. */} +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ + ) : vm.status === "empty" ? ( + <> + + + Keine Daten für diese Auswahl + + + Die gesetzten Filter schneiden alles weg. Zeitraum erweitern oder einzelne + Einschränkungen in der Filterleiste entfernen. + + + ) : ( + <> + + + Kennzahlen konnten nicht geladen werden + + + {vm.error?.messageKey ?? "Unbekannter Fehler"} + + + )} +
+
+ ); +} diff --git a/features/dashboard/pages/AuftraegeSeite.tsx b/features/dashboard/pages/AuftraegeSeite.tsx index 2b54621..7e51726 100644 --- a/features/dashboard/pages/AuftraegeSeite.tsx +++ b/features/dashboard/pages/AuftraegeSeite.tsx @@ -13,19 +13,6 @@ import { Donut } from "../components/charts/Donut"; import { Trichter } from "../components/charts/Trichter"; import { DataTable, type Spalte } from "../components/tables/DataTable"; import { AuftragBadge } from "../components/tables/StatusBadge"; -import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; -import { - abschlussquote, - aktiveAuftraege, - auftraegeImZeitraum, - auftraegeNachGewerk, - auftraegeNachStatus, - auftragseingang, - durchlaufzeit, - foerderQuote, - ueberfaelligeAuftraege, - vertriebsTrichter, -} from "../data/calculations"; import { berechneTrend, formatDatum, @@ -39,31 +26,33 @@ import { tageLabelDativ, } from "../utils/formatters"; import { STATUS_TEXT } from "../utils/chartTheme"; -import { HEUTE, vorperiode, imFenster } from "../utils/dateUtils"; +import { HEUTE } from "../utils/dateUtils"; import type { Auftrag } from "../data/types"; +import { SeitenZustand } from "../components/layout/SeitenZustand"; +import type { VMState } from "../view-models"; +import type { AuftraegeDaten } from "../view-models/auftraege.vm"; +import type { FilterOptionen } from "../view-models/filter-optionen"; -export function AuftraegeSeite() { - const { daten, filter } = useAnalyseDaten("auftraege"); - - const imZeitraum = auftraegeImZeitraum(daten.auftraege, filter.zeitraum); - const vorher = daten.auftraege.filter((a) => imFenster(a.angelegtAm, vorperiode(filter.zeitraum))); - const aktiv = aktiveAuftraege(daten.auftraege); - const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege); - const quote = abschlussquote(daten.auftraege); - const laufzeit = durchlaufzeit(daten.auftraege); - const foerderung = foerderQuote(daten.auftraege); - - const eingang = auftragseingang(daten.auftraege, filter.zeitraum); - const bestand = aktiv.reduce((s, a) => s + a.wert, 0); - - const mitarbeiterName = React.useMemo( - () => new Map(daten.mitarbeiter.map((m) => [m.id, m.name])), - [daten.mitarbeiter] - ); - const kundeName = React.useMemo( - () => new Map(daten.kunden.map((k) => [k.id, k.name])), - [daten.kunden] - ); +export function AuftraegeSeite({ + vm, + optionen, +}: { + vm: VMState; + optionen: FilterOptionen; +}) { + if (vm.status !== "ready") { + return ( + <> + + + + + ); + } + const d = vm.data; const spalten: Spalte[] = [ { @@ -74,7 +63,7 @@ export function AuftraegeSeite() {
{a.titel} - {a.nummer} · {kundeName.get(a.kundeId) ?? "Unbekannt"} + {a.nummer} · {d.kundeName.get(a.kundeId) ?? "Unbekannt"}
), @@ -92,10 +81,10 @@ export function AuftraegeSeite() { titel: "Verantwortlich", zelle: (a) => ( - {mitarbeiterName.get(a.mitarbeiterId) ?? "—"} + {d.mitarbeiterName.get(a.mitarbeiterId) ?? "—"} ), - sortWert: (a) => mitarbeiterName.get(a.mitarbeiterId) ?? "", + sortWert: (a) => d.mitarbeiterName.get(a.mitarbeiterId) ?? "", }, { key: "faellig", @@ -128,47 +117,47 @@ export function AuftraegeSeite() { titel="Aufträge" beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft." /> - + p.value)} + verlauf={d.sparkline} /> - formatZahl(w)} hoehe={250} nurGanzzahlen /> + formatZahl(w)} hoehe={250} nurGanzzahlen /> s + a.wert, 0) / imZeitraum.length - : 0 - ), + wert: formatEuro(d.durchschnittAuftragswert), }, ]} /> @@ -206,7 +191,7 @@ export function AuftraegeSeite() { untertitel="Angebotsgeschäft ab 6.000 €, nach erreichter Stufe" hinweis="Nur Vorhaben mit schriftlichem Angebot. Wartung und Notdienst gehen ohne Angebot direkt in die Ausführung — mitgezählt wäre der Trichter unten breiter als oben. Verlorene Vorgänge zählen bis zu der Stufe, auf der sie abgesprungen sind." > - formatZahl(w)} /> + formatZahl(w)} /> @@ -217,7 +202,7 @@ export function AuftraegeSeite() { untertitel="Alle Vorgänge seit Beginn — unabhängig vom Zeitraumfilter" > formatZahl(w)} hoehe={220} mitteLabel="Vorgänge" @@ -230,7 +215,7 @@ export function AuftraegeSeite() { untertitel="Anzahl seit Beginn, zum Stichtag" > formatZahl(w)} einfarbig={false} maxBalken={5} @@ -240,13 +225,13 @@ export function AuftraegeSeite() { formatZahl(w)} hoehe={200} mitteLabel="Förderfähig" @@ -254,12 +239,10 @@ export function AuftraegeSeite() { @@ -268,15 +251,15 @@ export function AuftraegeSeite() { - `${a.titel} ${a.nummer} ${a.gewerk} ${mitarbeiterName.get(a.mitarbeiterId) ?? ""}` + `${a.titel} ${a.nummer} ${a.gewerk} ${d.mitarbeiterName.get(a.mitarbeiterId) ?? ""}` } leerText="Keine aktiven Aufträge" /> diff --git a/features/dashboard/pages/FinanzenSeite.tsx b/features/dashboard/pages/FinanzenSeite.tsx index 1617694..be3ed83 100644 --- a/features/dashboard/pages/FinanzenSeite.tsx +++ b/features/dashboard/pages/FinanzenSeite.tsx @@ -14,17 +14,6 @@ import { Donut } from "../components/charts/Donut"; import { ChartLegende } from "../components/charts/ChartLegende"; import { DataTable, type Spalte } from "../components/tables/DataTable"; import { RechnungBadge } from "../components/tables/StatusBadge"; -import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; -import { - marge, - offenePosten, - offeneNachStatus, - umsatz, - umsatzNachGewerk, - umsatzVerlauf, - umsatzVorperiode, - zahlungsdauer, -} from "../data/calculations"; import { berechneTrend, formatDatum, @@ -44,24 +33,34 @@ import { VERGLEICH_FARBE, } from "../utils/chartTheme"; import type { Rechnung } from "../data/types"; +import { SeitenZustand } from "../components/layout/SeitenZustand"; +import type { VMState } from "../view-models"; +import type { FinanzenDaten } from "../view-models/finanzen.vm"; +import type { FilterOptionen } from "../view-models/filter-optionen"; /** Ampelfarben für die Altersstruktur: je älter die Forderung, desto kritischer. */ const ALTER_FARBEN = [...RISIKO_RAMPE]; -export function FinanzenSeite() { - const { daten, filter } = useAnalyseDaten("finanzen"); - - const umsatzAktuell = umsatz(daten.rechnungen, filter.zeitraum); - const umsatzVorher = umsatzVorperiode(daten.rechnungen, filter.zeitraum); - const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum); - const posten = offenePosten(daten.rechnungen); - const dauer = zahlungsdauer(daten.rechnungen, filter.zeitraum); - const margeProzent = marge(daten.auftraege, filter.zeitraum); - - const kundeName = React.useMemo( - () => new Map(daten.kunden.map((k) => [k.id, k.name])), - [daten.kunden] - ); +export function FinanzenSeite({ + vm, + optionen, +}: { + vm: VMState; + optionen: FilterOptionen; +}) { + if (vm.status !== "ready") { + return ( + <> + + + + + ); + } + const d = vm.data; const spalten: Spalte[] = [ { @@ -71,7 +70,7 @@ export function FinanzenSeite() {
{r.nummer} - {kundeName.get(r.kundeId) ?? "Unbekannt"} + {d.kundeName.get(r.kundeId) ?? "Unbekannt"}
), @@ -121,44 +120,44 @@ export function FinanzenSeite() { titel="Finanzen" beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb." /> - + p.value)} + verlauf={d.sparkline} /> 0 ? "negativ" : "positiv"} + akzent={d.posten.ueberfaelligBetrag > 0 ? "negativ" : "positiv"} /> 30 ? "warnung" : "positiv"} + akzent={d.zahlungsdauerTage > 30 ? "warnung" : "positiv"} richtungGut="negativ" /> @@ -181,7 +180,7 @@ export function FinanzenSeite() { } > = umsatzVorher ? "+" : "−"}${formatEuro(Math.abs(umsatzAktuell - umsatzVorher))}`, + wert: `${d.umsatzAktuell >= d.umsatzVorher ? "+" : "−"}${formatEuro(Math.abs(d.umsatzAktuell - d.umsatzVorher))}`, }, ]} /> @@ -206,7 +205,7 @@ export function FinanzenSeite() { hinweis="Bezugsgröße ist ausschließlich der offene Bestand zum Stichtag. Bezahlte Rechnungen sind nicht enthalten — sie würden über 90 % des Rings belegen und die Aussage erdrücken." > b.name === "> 90 Tage")?.value ?? 0), + wert: formatEuro( + d.posten.nachAlter.find((b) => b.name === "> 90 Tage")?.value ?? 0 + ), hinweis: "Abschreibungsrisiko", }, - { label: "Überfällig gesamt", wert: formatEuro(posten.ueberfaelligBetrag) }, + { label: "Überfällig gesamt", wert: formatEuro(d.posten.ueberfaelligBetrag) }, ]} />
@@ -249,7 +250,7 @@ export function FinanzenSeite() { untertitel="Zahlungseingang im Zeitraum" > `${r.nummer} ${kundeName.get(r.kundeId) ?? ""}`} + suchFelder={(r) => `${r.nummer} ${d.kundeName.get(r.kundeId) ?? ""}`} leerText="Keine offenen Rechnungen" />
diff --git a/features/dashboard/pages/KundenSeite.tsx b/features/dashboard/pages/KundenSeite.tsx index fc5483e..6da15f3 100644 --- a/features/dashboard/pages/KundenSeite.tsx +++ b/features/dashboard/pages/KundenSeite.tsx @@ -10,16 +10,6 @@ import { BalkenVertikal } from "../components/charts/BalkenVertikal"; import { BalkenHorizontal } from "../components/charts/BalkenHorizontal"; import { Donut } from "../components/charts/Donut"; import { DataTable, type Spalte } from "../components/tables/DataTable"; -import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; -import { - inaktiveKunden, - kundenEntwicklung, - kundenNach, - neueKunden, - topKunden, - wiederkehrquote, - zahlungsauffaellig, -} from "../data/calculations"; import { berechneTrend, formatDatum, @@ -30,26 +20,32 @@ import { tageLabel, } from "../utils/formatters"; import { STATUS_TEXT } from "../utils/chartTheme"; -import { imFenster, vorperiode } from "../utils/dateUtils"; import type { Kunde } from "../data/types"; +import { SeitenZustand } from "../components/layout/SeitenZustand"; +import type { VMState } from "../view-models"; +import type { KundenDaten } from "../view-models/kunden.vm"; +import type { FilterOptionen } from "../view-models/filter-optionen"; -export function KundenSeite() { - const { daten, filter } = useAnalyseDaten("kunden"); - - const neu = neueKunden(daten.kunden, filter.zeitraum); - const neuVorher = daten.kunden.filter((k) => - imFenster(k.angelegtAm, vorperiode(filter.zeitraum)) - ).length; - - const mitAuftrag = daten.kunden.filter((k) => k.auftraegeGesamt > 0); - const wiederkehrend = wiederkehrquote(daten.kunden); - const kundenwert = - mitAuftrag.length > 0 - ? mitAuftrag.reduce((s, k) => s + k.umsatzGesamt, 0) / mitAuftrag.length - : 0; - const inaktiv = inaktiveKunden(daten.kunden); - const auffaellig = zahlungsauffaellig(daten.kunden); - const entwicklung = kundenEntwicklung(daten.kunden, filter.zeitraum); +export function KundenSeite({ + vm, + optionen, +}: { + vm: VMState; + optionen: FilterOptionen; +}) { + if (vm.status !== "ready") { + return ( + <> + + + + + ); + } + const d = vm.data; const spalten: Spalte[] = [ { @@ -117,46 +113,46 @@ export function KundenSeite() { titel="Kunden" beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt." /> - + p.value)} + verlauf={d.sparkline} /> 0 ? "warnung" : "neutral"} + akzent={d.inaktive.length > 0 ? "warnung" : "neutral"} /> @@ -167,7 +163,7 @@ export function KundenSeite() { untertitel="Anlagedatum des Kundendatensatzes" className="xl:col-span-2" > - formatZahl(w)} hoehe={250} nurGanzzahlen /> + formatZahl(w)} hoehe={250} nurGanzzahlen /> formatZahl(w)} hoehe={220} mitteLabel="Kunden" @@ -188,7 +184,7 @@ export function KundenSeite() {
formatZahl(w)} hoehe={200} mitteLabel="Kunden" @@ -201,18 +197,18 @@ export function KundenSeite() { titel="Top-Kunden nach Umsatz" untertitel="Bezahlter Gesamtumsatz" > - +
s.key !== "zahlung")} proSeite={6} suchFelder={(k) => `${k.name} ${k.ort} ${k.quelle}`} @@ -222,11 +218,11 @@ export function KundenSeite() { s.key !== "letzter")} proSeite={6} suchFelder={(k) => `${k.name} ${k.ort}`} diff --git a/features/dashboard/pages/TeamSeite.tsx b/features/dashboard/pages/TeamSeite.tsx index 6b7d5eb..8daa23f 100644 --- a/features/dashboard/pages/TeamSeite.tsx +++ b/features/dashboard/pages/TeamSeite.tsx @@ -10,47 +10,34 @@ import { AuslastungsBalken } from "../components/charts/AuslastungsBalken"; import { BalkenHorizontal } from "../components/charts/BalkenHorizontal"; import { Donut } from "../components/charts/Donut"; import { DataTable, type Spalte } from "../components/tables/DataTable"; -import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; -import { teamKennzahlen, type MitarbeiterKennzahl } from "../data/calculations"; import { formatEuro, formatEuroKompakt, formatProzent, formatStunden, formatZahl } from "../utils/formatters"; -import { imFenster, zeitfenster } from "../utils/dateUtils"; +import { SeitenZustand } from "../components/layout/SeitenZustand"; +import type { VMState } from "../view-models"; +import type { TeamDaten, TeamZeile } from "../view-models/team.vm"; +import type { FilterOptionen } from "../view-models/filter-optionen"; -export function TeamSeite() { - const { daten, filter } = useAnalyseDaten("team"); +export function TeamSeite({ + vm, + optionen, +}: { + vm: VMState; + optionen: FilterOptionen; +}) { + if (vm.status !== "ready") { + return ( + <> + + + + + ); + } + const d = vm.data; - const kennzahlen = teamKennzahlen( - daten.mitarbeiter, - daten.zeiten, - daten.auftraege, - filter.zeitraum - ); - const aktive = kennzahlen.filter((k) => k.mitarbeiter.status === "aktiv"); - - const durchschnitt = - aktive.length > 0 ? aktive.reduce((s, k) => s + k.auslastungProzent, 0) / aktive.length : 0; - const stundenGesamt = aktive.reduce((s, k) => s + k.stunden, 0); - const produktivGesamt = aktive.reduce((s, k) => s + k.produktivStunden, 0); - const produktivQuote = stundenGesamt > 0 ? (produktivGesamt / stundenGesamt) * 100 : 0; - - const fenster = zeitfenster(filter.zeitraum); - const abwesenheiten = daten.zeiten.filter( - (z) => z.art === "abwesenheit" && imFenster(z.datum, fenster) - ); - const abwesenheitNach = React.useMemo(() => { - const zaehler = new Map(); - const label: Record = { - urlaub: "Urlaub", - krank: "Krank", - fortbildung: "Fortbildung", - }; - for (const eintrag of abwesenheiten) { - const name = label[eintrag.abwesenheitsgrund ?? "sonstige"] ?? "Sonstige"; - zaehler.set(name, (zaehler.get(name) ?? 0) + eintrag.stunden); - } - return [...zaehler.entries()].map(([name, value]) => ({ name, value })); - }, [abwesenheiten]); - - const spalten: Spalte[] = [ + const spalten: Spalte[] = [ { key: "name", titel: "Mitarbeiter", @@ -134,47 +121,45 @@ export function TeamSeite() { }, ]; - const tabellenZeilen = kennzahlen.map((k) => ({ ...k, id: k.mitarbeiter.id })); - return ( <> - + 95 ? "warnung" : "positiv"} + akzent={d.durchschnittAuslastung > 95 ? "warnung" : "positiv"} /> s + a.stunden, 0))} + wert={formatStunden(d.abwesenheitStunden)} zusatz="Urlaub, Krankheit, Fortbildung" icon="CalendarOff" richtungGut="negativ" @@ -188,7 +173,7 @@ export function TeamSeite() { hinweis="Werte über 100 % bedeuten Überstunden. Dauerhaft über 95 % ist ein Warnsignal für Terminrisiken." > ({ + zeilen={d.aktive.map((k) => ({ name: k.mitarbeiter.name, wert: k.auslastungProzent, zusatz: `${k.mitarbeiter.rolle} · ${k.mitarbeiter.gewerk}`, @@ -205,7 +190,7 @@ export function TeamSeite() { hinweis="Der Balken zeigt die Anzahl — sie ist die vergleichbare Größe. Der Auftragswert steht daneben, ist aber ausdrücklich kein Leistungsvergleich: Ein Wärmepumpen-Auftrag ist um ein Vielfaches größer als eine Wartung." > ({ + daten={d.aktive.map((k) => ({ name: k.mitarbeiter.name, value: k.abgeschlossen, zweitwert: k.umsatz, @@ -222,7 +207,7 @@ export function TeamSeite() { untertitel="Stunden im gewählten Zeitraum" > formatStunden(w)} hoehe={210} mitteLabel="Stunden" @@ -236,7 +221,7 @@ export function TeamSeite() { untertitel="Alle gewerblich Beschäftigten mit Kennzahlen" > `${k.mitarbeiter.name} ${k.mitarbeiter.rolle} ${k.mitarbeiter.gewerk}`} diff --git a/features/dashboard/pages/UebersichtSeite.tsx b/features/dashboard/pages/UebersichtSeite.tsx index ef967cc..f4a3fdd 100644 --- a/features/dashboard/pages/UebersichtSeite.tsx +++ b/features/dashboard/pages/UebersichtSeite.tsx @@ -15,23 +15,6 @@ import { Trichter } from "../components/charts/Trichter"; import { ChartLegende } from "../components/charts/ChartLegende"; import { DataTable, type Spalte } from "../components/tables/DataTable"; import { AuftragBadge } from "../components/tables/StatusBadge"; -import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; -import { - aktiveAuftraege, - handlungsbedarf, - marge, - neueKunden, - neueKundenVorperiode, - zahlungsdauer, - offenePosten, - teamKennzahlen, - ueberfaelligeAuftraege, - umsatz, - umsatzNachGewerk, - umsatzVerlauf, - umsatzVorperiode, - vertriebsTrichter, -} from "../data/calculations"; import { berechneTrend, formatEuro, @@ -48,29 +31,31 @@ import { PRIMAER_FARBE, VERGLEICH_FARBE, STATUS_TEXT, } from "../utils/chartTheme"; import type { Auftrag } from "../data/types"; +import { SeitenZustand } from "../components/layout/SeitenZustand"; +import type { VMState } from "../view-models"; +import type { UebersichtDaten } from "../view-models/uebersicht.vm"; +import type { FilterOptionen } from "../view-models/filter-optionen"; -export function UebersichtSeite() { - const { daten, filter } = useAnalyseDaten("uebersicht"); - - const umsatzAktuell = umsatz(daten.rechnungen, filter.zeitraum); - const umsatzVorher = umsatzVorperiode(daten.rechnungen, filter.zeitraum); - const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum); - const posten = offenePosten(daten.rechnungen); - const aktiv = aktiveAuftraege(daten.auftraege); - const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege); - const team = teamKennzahlen(daten.mitarbeiter, daten.zeiten, daten.auftraege, filter.zeitraum); - const alerts = handlungsbedarf(daten); - - const monteureAktiv = team.filter((t) => t.mitarbeiter.status === "aktiv"); - const durchschnittAuslastung = - monteureAktiv.length > 0 - ? monteureAktiv.reduce((s, t) => s + t.auslastungProzent, 0) / monteureAktiv.length - : 0; - - const sparkline = verlauf.slice(-10).map((p) => p.value); - - const gewerkUmsatz = umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum); - const trichter = vertriebsTrichter(daten.auftraege); +export function UebersichtSeite({ + vm, + optionen, +}: { + vm: VMState; + optionen: FilterOptionen; +}) { + if (vm.status !== "ready") { + return ( + <> + + + + + ); + } + const d = vm.data; const auftragSpalten: Spalte[] = [ { @@ -118,48 +103,45 @@ export function UebersichtSeite() { titel="Übersicht" beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf." /> - + 0 ? "warnung" : "neutral"} + akzent={d.posten.ueberfaelligAnzahl > 0 ? "warnung" : "neutral"} /> s + a.wert, 0))} Auftragsbestand`} + wert={formatZahl(d.aktiveAuftraege.length)} + zusatz={`${formatEuro(d.auftragsbestand)} Auftragsbestand`} icon="ClipboardList" bezug="stichtag" /> 95 ? "warnung" : durchschnittAuslastung < 70 ? "info" : "positiv"} + akzent={d.durchschnittAuslastung > 95 ? "warnung" : d.durchschnittAuslastung < 70 ? "info" : "positiv"} /> k.auftraegeGesamt > 0).length)} Kunden mit Auftrag insgesamt`} - trend={berechneTrend( - neueKunden(daten.kunden, filter.zeitraum), - neueKundenVorperiode(daten.kunden, filter.zeitraum) - )} + wert={formatZahl(d.neueKunden)} + zusatz={`${formatZahl(d.kundenMitAuftrag)} Kunden mit Auftrag insgesamt`} + trend={berechneTrend(d.neueKunden, d.neueKundenVorher)} icon="Users" /> @@ -169,7 +151,7 @@ export function UebersichtSeite() { titel="Handlungsbedarf" untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste" > - +
@@ -189,7 +171,7 @@ export function UebersichtSeite() { } > s + a.wert, 0) / daten.auftraege.length - : 0 - ), + wert: formatEuro(d.durchschnittAuftragswert), }, - { label: "Ø Zahlungsdauer", wert: formatTage(zahlungsdauer(daten.rechnungen, filter.zeitraum)) }, + { label: "Ø Zahlungsdauer", wert: formatTage(d.zahlungsdauerTage) }, ]} /> @@ -219,7 +197,7 @@ export function UebersichtSeite() { hinweis="Fünf Gewerke in fester Rangfolge. Dieselbe Kodierung wie auf der Finanzseite — ein Ring und eine Balkenliste für dieselbe Zahl wären zwei Bilder für einen Sachverhalt." > @@ -233,7 +211,7 @@ export function UebersichtSeite() { untertitel="Angebotsgeschäft ab 6.000 €, zum Stichtag" hinweis="Nur Vorhaben, für die ein schriftliches Angebot erstellt wird. Wartung und Notdienst laufen ohne Angebot und würden die Quoten verfälschen. Rechte Spalte: Übergang von der vorherigen Stufe." > - formatZahl(w)} /> + formatZahl(w)} /> ({ + zeilen={d.monteureAktiv.slice(0, 6).map((t) => ({ name: t.mitarbeiter.name, wert: t.auslastungProzent, zusatz: t.mitarbeiter.gewerk, @@ -255,11 +233,11 @@ export function UebersichtSeite() { `${a.titel} ${a.nummer} ${a.gewerk}`} diff --git a/features/dashboard/routes/AuftraegeRoute.tsx b/features/dashboard/routes/AuftraegeRoute.tsx new file mode 100644 index 0000000..f271e17 --- /dev/null +++ b/features/dashboard/routes/AuftraegeRoute.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; +import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { baueAuftraegeVM } from "../view-models/auftraege.vm"; +import { AuftraegeSeite } from "../pages/AuftraegeSeite"; + +/** + * Route-Container der Auftragsseite. + * + * Die einzige Stelle, an der Datenquelle und Screen zusammenkommen. In HiveGrid + * Cloud übernimmt das der Loader von `app.$tenant/auftraege.tsx`: + * + * export default function Route() { + * const { vm } = useLoaderData(); + * return ; + * } + * + * Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei + * ersetzt, nicht angepasst — die Seite darunter bleibt unberührt. + */ +export function AuftraegeRoute() { + const { daten, filter, gesamt } = useAnalyseDaten("auftraege"); + return ( + + ); +} diff --git a/features/dashboard/routes/FinanzenRoute.tsx b/features/dashboard/routes/FinanzenRoute.tsx new file mode 100644 index 0000000..492d3e5 --- /dev/null +++ b/features/dashboard/routes/FinanzenRoute.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; +import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { baueFinanzenVM } from "../view-models/finanzen.vm"; +import { FinanzenSeite } from "../pages/FinanzenSeite"; + +/** + * Route-Container der Finanzseite. + * + * Die einzige Stelle, an der Datenquelle und Screen zusammenkommen. In HiveGrid + * Cloud übernimmt das der Loader von `app.$tenant/finanzen.tsx`: + * + * export default function Route() { + * const { vm } = useLoaderData(); + * return ; + * } + * + * Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei + * ersetzt, nicht angepasst — die Seite darunter bleibt unberührt. + */ +export function FinanzenRoute() { + const { daten, filter, gesamt } = useAnalyseDaten("finanzen"); + return ( + + ); +} diff --git a/features/dashboard/routes/KundenRoute.tsx b/features/dashboard/routes/KundenRoute.tsx new file mode 100644 index 0000000..6dae285 --- /dev/null +++ b/features/dashboard/routes/KundenRoute.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; +import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { baueKundenVM } from "../view-models/kunden.vm"; +import { KundenSeite } from "../pages/KundenSeite"; + +/** + * Route-Container der Kundenseite. + * + * Die einzige Stelle, an der Datenquelle und Screen zusammenkommen. In HiveGrid + * Cloud übernimmt das der Loader von `app.$tenant/kunden.tsx`: + * + * export default function Route() { + * const { vm } = useLoaderData(); + * return ; + * } + * + * Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei + * ersetzt, nicht angepasst — die Seite darunter bleibt unberührt. + */ +export function KundenRoute() { + const { daten, filter, gesamt } = useAnalyseDaten("kunden"); + return ( + + ); +} diff --git a/features/dashboard/routes/TeamRoute.tsx b/features/dashboard/routes/TeamRoute.tsx new file mode 100644 index 0000000..a007462 --- /dev/null +++ b/features/dashboard/routes/TeamRoute.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; +import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { baueTeamVM } from "../view-models/team.vm"; +import { TeamSeite } from "../pages/TeamSeite"; + +/** + * Route-Container der Teamseite. + * + * Die einzige Stelle, an der Datenquelle und Screen zusammenkommen. In HiveGrid + * Cloud übernimmt das der Loader von `app.$tenant/team.tsx`: + * + * export default function Route() { + * const { vm } = useLoaderData(); + * return ; + * } + * + * Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei + * ersetzt, nicht angepasst — die Seite darunter bleibt unberührt. + */ +export function TeamRoute() { + const { daten, filter, gesamt } = useAnalyseDaten("team"); + return ( + + ); +} diff --git a/features/dashboard/routes/UebersichtRoute.tsx b/features/dashboard/routes/UebersichtRoute.tsx new file mode 100644 index 0000000..5501d0f --- /dev/null +++ b/features/dashboard/routes/UebersichtRoute.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; +import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { baueUebersichtVM } from "../view-models/uebersicht.vm"; +import { UebersichtSeite } from "../pages/UebersichtSeite"; + +/** + * Route-Container der Übersichtsseite. + * + * Die einzige Stelle, an der Datenquelle und Screen zusammenkommen. In HiveGrid + * Cloud übernimmt das der Loader von `app.$tenant/dashboard.tsx`: + * + * export default function Route() { + * const { vm } = useLoaderData(); + * return ; + * } + * + * Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei + * ersetzt, nicht angepasst — die Seite darunter bleibt unberührt. + */ +export function UebersichtRoute() { + const { daten, filter, gesamt } = useAnalyseDaten("uebersicht"); + return ( + + ); +} diff --git a/features/dashboard/view-models/auftraege.vm.ts b/features/dashboard/view-models/auftraege.vm.ts new file mode 100644 index 0000000..4aab007 --- /dev/null +++ b/features/dashboard/view-models/auftraege.vm.ts @@ -0,0 +1,87 @@ +/** + * View-Model der Auftragsseite. + * + * Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein + * Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der + * Software ruft der Loader denselben Builder mit echten Daten auf, die + * Screen-Komponente bleibt unverändert. + */ + +import { + abschlussquote, + aktiveAuftraege, + auftraegeImZeitraum, + auftraegeNachGewerk, + auftraegeNachStatus, + auftragseingang, + durchlaufzeit, + foerderQuote, + ueberfaelligeAuftraege, + vertriebsTrichter, +} from "../data/calculations"; +import type { + Auftrag, + ChartPunkt, + Datensatz, + FilterState, + ZeitreihePunkt, +} from "../data/types"; +import { imFenster, vorperiode } from "../utils/dateUtils"; +import { bereit, leer, type VMState } from "./index"; + +export interface AuftraegeDaten { + readonly imZeitraum: Auftrag[]; + readonly vorher: Auftrag[]; + readonly aktiv: Auftrag[]; + readonly ueberfaellig: Auftrag[]; + readonly quote: number; + readonly laufzeit: number; + readonly eingang: ZeitreihePunkt[]; + readonly sparkline: number[]; + readonly bestand: number; + readonly durchschnittAuftragswert: number; + readonly trichter: ChartPunkt[]; + readonly statusVerteilung: ChartPunkt[]; + readonly gewerkAnzahl: ChartPunkt[]; + readonly foerderung: ReturnType; + readonly foerderungJeAuftrag: number; + /** + * Namen je Id. Der Auftrag trägt nur die Ids; die Auflösung gehört zu den + * Daten, nicht in die Zellen-Funktion der Tabelle. + */ + readonly mitarbeiterName: ReadonlyMap; + readonly kundeName: ReadonlyMap; +} + +export function baueAuftraegeVM(daten: Datensatz, filter: FilterState): VMState { + // Kein Auftrag und keine Rechnung heisst: Die Filter schneiden alles weg. + // Das ist ein Leerzustand, kein Fehler. + if (!daten.auftraege.length && !daten.rechnungen.length) return leer(); + + const imZeitraum = auftraegeImZeitraum(daten.auftraege, filter.zeitraum); + const aktiv = aktiveAuftraege(daten.auftraege); + const eingang = auftragseingang(daten.auftraege, filter.zeitraum); + const foerderung = foerderQuote(daten.auftraege); + + return bereit({ + imZeitraum, + vorher: daten.auftraege.filter((a) => imFenster(a.angelegtAm, vorperiode(filter.zeitraum))), + aktiv, + ueberfaellig: ueberfaelligeAuftraege(daten.auftraege), + quote: abschlussquote(daten.auftraege), + laufzeit: durchlaufzeit(daten.auftraege), + eingang, + sparkline: eingang.slice(-10).map((p) => p.value), + bestand: aktiv.reduce((s, a) => s + a.wert, 0), + durchschnittAuftragswert: imZeitraum.length + ? imZeitraum.reduce((s, a) => s + a.wert, 0) / imZeitraum.length + : 0, + trichter: vertriebsTrichter(daten.auftraege), + statusVerteilung: auftraegeNachStatus(daten.auftraege), + gewerkAnzahl: auftraegeNachGewerk(daten.auftraege), + foerderung, + foerderungJeAuftrag: foerderung.bewilligt ? foerderung.summe / foerderung.bewilligt : 0, + mitarbeiterName: new Map(daten.mitarbeiter.map((m) => [m.id, m.name])), + kundeName: new Map(daten.kunden.map((k) => [k.id, k.name])), + }); +} diff --git a/features/dashboard/view-models/filter-optionen.ts b/features/dashboard/view-models/filter-optionen.ts new file mode 100644 index 0000000..3f54488 --- /dev/null +++ b/features/dashboard/view-models/filter-optionen.ts @@ -0,0 +1,34 @@ +/** + * Auswahlmöglichkeiten der Filterleiste. + * + * Reine Funktion über dem vollständigen Datensatz — bewusst **nicht** über den + * gefilterten. Sonst verschwindet eine Option, sobald man sie abwählt, und der + * Filter lässt sich nicht mehr zurücknehmen. + * + * In HiveGrid Cloud kommen diese Listen aus dem Loader (dort „Facetten", siehe + * `BoardFilterBar` mit `facets` aus dem View-Model). Deshalb werden sie auch + * hier von außen hereingereicht und nicht in der Leiste selbst ermittelt. + */ + +import type { Datensatz } from "../data/types"; + +export interface FilterOptionen { + readonly quellen: string[]; + readonly regionen: string[]; + readonly monteure: { wert: string; label: string; zusatz: string }[]; + /** Id → Name, für die Beschriftung der aktiven Filter-Chips. */ + readonly mitarbeiterNamen: Record; +} + +export function baueFilterOptionen(daten: Datensatz): FilterOptionen { + const monteure = daten.mitarbeiter + .filter((m) => m.rolle === "Monteur" || m.rolle === "Meister") + .map((m) => ({ wert: m.id, label: m.name, zusatz: m.gewerk })); + + return { + quellen: [...new Set(daten.kunden.map((k) => k.quelle))].sort(), + regionen: [...new Set(daten.kunden.map((k) => k.region))].sort(), + monteure, + mitarbeiterNamen: Object.fromEntries(daten.mitarbeiter.map((m) => [m.id, m.name])), + }; +} diff --git a/features/dashboard/view-models/finanzen.vm.ts b/features/dashboard/view-models/finanzen.vm.ts new file mode 100644 index 0000000..cde2a38 --- /dev/null +++ b/features/dashboard/view-models/finanzen.vm.ts @@ -0,0 +1,60 @@ +/** + * View-Model der Finanzseite. + * + * Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein + * Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der + * Software ruft der Loader denselben Builder mit echten Daten auf, die + * Screen-Komponente bleibt unverändert. + */ + +import { + marge, + offeneNachStatus, + offenePosten, + umsatz, + umsatzNachGewerk, + umsatzVerlauf, + umsatzVorperiode, + zahlungsdauer, + type OffenePosten, +} from "../data/calculations"; +import type { ChartPunkt, Datensatz, FilterState, ZeitreihePunkt } from "../data/types"; +import { bereit, leer, type VMState } from "./index"; + +export interface FinanzenDaten { + readonly umsatzAktuell: number; + readonly umsatzVorher: number; + readonly verlauf: ZeitreihePunkt[]; + readonly sparkline: number[]; + readonly posten: OffenePosten; + readonly zahlungsdauerTage: number; + readonly margeProzent: number; + readonly offenNachStatus: ChartPunkt[]; + readonly gewerkUmsatz: ChartPunkt[]; + /** + * Kundenname je Id. Die Rechnung trägt nur die Id; die Auflösung gehört zu den + * Daten, nicht in die Zellen-Funktion der Tabelle. + */ + readonly kundeName: ReadonlyMap; +} + +export function baueFinanzenVM(daten: Datensatz, filter: FilterState): VMState { + // Kein Auftrag und keine Rechnung heisst: Die Filter schneiden alles weg. + // Das ist ein Leerzustand, kein Fehler. + if (!daten.auftraege.length && !daten.rechnungen.length) return leer(); + + const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum); + + return bereit({ + umsatzAktuell: umsatz(daten.rechnungen, filter.zeitraum), + umsatzVorher: umsatzVorperiode(daten.rechnungen, filter.zeitraum), + verlauf, + sparkline: verlauf.slice(-10).map((p) => p.value), + posten: offenePosten(daten.rechnungen), + zahlungsdauerTage: zahlungsdauer(daten.rechnungen, filter.zeitraum), + margeProzent: marge(daten.auftraege, filter.zeitraum), + offenNachStatus: offeneNachStatus(daten.rechnungen), + gewerkUmsatz: umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum), + kundeName: new Map(daten.kunden.map((k) => [k.id, k.name])), + }); +} diff --git a/features/dashboard/view-models/index.ts b/features/dashboard/view-models/index.ts new file mode 100644 index 0000000..bbd6d13 --- /dev/null +++ b/features/dashboard/view-models/index.ts @@ -0,0 +1,48 @@ +/** + * View-Model-Vertrag. + * + * Wortgleicher Spiegel von `VMState` aus `@hivegrid/view-models` in HiveGrid + * Cloud (`packages/view-models/src/index.ts`). Beim Einbau wird dieser Import + * gegen das echte Paket getauscht — die Seiten selbst ändern sich dabei nicht. + * + * Warum das hier überhaupt steht: Die Cloud trennt strikt zwischen Route und + * Screen. Die Route besitzt Loader, Actions und Realtime; die Screen-Komponente + * ist rein und bekommt alles als Prop (``). Eine + * Komponente, die sich ihre Daten selbst aus einem Store holt, muss beim Einbau + * auseinandergenommen werden — genau das soll dieser Prototyp ersparen. + */ + +export type VMStatus = "idle" | "loading" | "ready" | "empty" | "error"; + +export type VMState = + | { status: "idle"; data?: undefined; error?: undefined } + | { status: "loading"; data?: T; error?: undefined } + | { status: "ready"; data: T; error?: undefined } + | { status: "empty"; data?: undefined; error?: undefined } + | { status: "error"; data?: T; error: { messageKey: string; code?: string } }; + +/** Kurzform für den Normalfall im Prototyp: Daten liegen vor. */ +export function bereit(data: T): VMState { + return { status: "ready", data }; +} + +/** Kein Datensatz für die aktuelle Auswahl — nicht dasselbe wie ein Fehler. */ +export function leer(): VMState { + return { status: "empty" }; +} + +/** + * Aktionen einer Seite. + * + * Im Prototyp lösen sie einen Hinweis-Toast aus; in der Software werden daraus + * Navigationen und Schreibpfade. Wichtig ist die Form: Der Screen ruft einen + * Callback auf, er kennt weder Router noch Fetcher. + */ +export interface SeitenAktionen { + /** Absprung in eine Arbeitsliste, z. B. aus dem Handlungsbedarf. */ + readonly onAbsprung?: (ziel: string, kontext?: string) => void; + /** Datensatz in der Tabelle geöffnet. */ + readonly onZeileOeffnen?: (id: string) => void; + /** Ansicht neu laden. */ + readonly onAktualisieren?: () => void; +} diff --git a/features/dashboard/view-models/kunden.vm.ts b/features/dashboard/view-models/kunden.vm.ts new file mode 100644 index 0000000..02c9ac3 --- /dev/null +++ b/features/dashboard/view-models/kunden.vm.ts @@ -0,0 +1,66 @@ +/** + * View-Model der Kundenseite. + * + * Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein + * Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der + * Software ruft der Loader denselben Builder mit echten Daten auf, die + * Screen-Komponente bleibt unverändert. + */ + +import { + inaktiveKunden, + kundenEntwicklung, + kundenNach, + neueKunden, + neueKundenVorperiode, + topKunden, + wiederkehrquote, + zahlungsauffaellig, +} from "../data/calculations"; +import type { ChartPunkt, Datensatz, FilterState, Kunde, ZeitreihePunkt } from "../data/types"; +import { bereit, leer, type VMState } from "./index"; + +export interface KundenDaten { + readonly kundenGesamt: number; + readonly kundenMitAuftrag: number; + readonly neueKunden: number; + readonly neueKundenVorher: number; + readonly entwicklung: ZeitreihePunkt[]; + readonly sparkline: number[]; + readonly wiederkehrendProzent: number; + readonly kundenwert: number; + readonly inaktive: Kunde[]; + readonly auffaellige: Kunde[]; + readonly nachQuelle: ChartPunkt[]; + readonly nachTyp: ChartPunkt[]; + readonly topKunden: ChartPunkt[]; +} + +export function baueKundenVM(daten: Datensatz, filter: FilterState): VMState { + // Kein Kunde in der Auswahl heisst: Die Filter schneiden alles weg. + // Das ist ein Leerzustand, kein Fehler. + if (!daten.kunden.length) return leer(); + + const entwicklung = kundenEntwicklung(daten.kunden, filter.zeitraum); + const mitAuftrag = daten.kunden.filter((k) => k.auftraegeGesamt > 0); + + return bereit({ + kundenGesamt: daten.kunden.length, + kundenMitAuftrag: mitAuftrag.length, + neueKunden: neueKunden(daten.kunden, filter.zeitraum), + neueKundenVorher: neueKundenVorperiode(daten.kunden, filter.zeitraum), + entwicklung, + sparkline: entwicklung.slice(-10).map((p) => p.value), + wiederkehrendProzent: wiederkehrquote(daten.kunden), + // Bezugsgrösse ist der Kunde mit Historie — Karteileichen würden den + // Durchschnitt nach unten ziehen. + kundenwert: mitAuftrag.length + ? mitAuftrag.reduce((s, k) => s + k.umsatzGesamt, 0) / mitAuftrag.length + : 0, + inaktive: inaktiveKunden(daten.kunden), + auffaellige: zahlungsauffaellig(daten.kunden), + nachQuelle: kundenNach(daten.kunden, "quelle"), + nachTyp: kundenNach(daten.kunden, "typ"), + topKunden: topKunden(daten.kunden, 6), + }); +} diff --git a/features/dashboard/view-models/team.vm.ts b/features/dashboard/view-models/team.vm.ts new file mode 100644 index 0000000..14251ef --- /dev/null +++ b/features/dashboard/view-models/team.vm.ts @@ -0,0 +1,84 @@ +/** + * View-Model der Teamseite. + * + * Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein + * Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der + * Software ruft der Loader denselben Builder mit echten Daten auf, die + * Screen-Komponente bleibt unverändert. + */ + +import { teamKennzahlen, type MitarbeiterKennzahl } from "../data/calculations"; +import type { ChartPunkt, Datensatz, FilterState } from "../data/types"; +import { imFenster, zeitfenster } from "../utils/dateUtils"; +import { bereit, leer, type VMState } from "./index"; + +/** + * Tabellenzeile der Mitarbeiterübersicht. + * + * Die `id` hängt hier und nicht in der Seite, weil die Tabelle einen stabilen + * Schlüssel braucht — das ist eine Eigenschaft des Datensatzes, keine der + * Darstellung. + */ +export type TeamZeile = MitarbeiterKennzahl & { readonly id: string }; + +export interface TeamDaten { + readonly mitarbeiterGesamt: number; + readonly aktive: MitarbeiterKennzahl[]; + readonly durchschnittAuslastung: number; + readonly stundenGesamt: number; + readonly produktivGesamt: number; + readonly produktivQuote: number; + readonly abwesenheitStunden: number; + readonly abwesenheitNach: ChartPunkt[]; + readonly zeilen: TeamZeile[]; +} + +/** Anzeigenamen der Abwesenheitsgründe — die Rohwerte sind Kleinschreibung. */ +const ABWESENHEIT_LABEL: Record = { + urlaub: "Urlaub", + krank: "Krank", + fortbildung: "Fortbildung", +}; + +export function baueTeamVM(daten: Datensatz, filter: FilterState): VMState { + // Kein Mitarbeiter in der Auswahl heisst: Die Filter schneiden alles weg. + // Das ist ein Leerzustand, kein Fehler. + if (!daten.mitarbeiter.length) return leer(); + + const kennzahlen = teamKennzahlen( + daten.mitarbeiter, + daten.zeiten, + daten.auftraege, + filter.zeitraum + ); + const aktive = kennzahlen.filter((k) => k.mitarbeiter.status === "aktiv"); + + // Kennzahlen beziehen sich auf die aktiven Kräfte: Ausgetretene hätten null + // Stunden und würden jeden Durchschnitt verzerren. + const stundenGesamt = aktive.reduce((s, k) => s + k.stunden, 0); + const produktivGesamt = aktive.reduce((s, k) => s + k.produktivStunden, 0); + + const fenster = zeitfenster(filter.zeitraum); + const abwesenheiten = daten.zeiten.filter( + (z) => z.art === "abwesenheit" && imFenster(z.datum, fenster) + ); + const zaehler = new Map(); + for (const eintrag of abwesenheiten) { + const name = ABWESENHEIT_LABEL[eintrag.abwesenheitsgrund ?? "sonstige"] ?? "Sonstige"; + zaehler.set(name, (zaehler.get(name) ?? 0) + eintrag.stunden); + } + + return bereit({ + mitarbeiterGesamt: kennzahlen.length, + aktive, + durchschnittAuslastung: aktive.length + ? aktive.reduce((s, k) => s + k.auslastungProzent, 0) / aktive.length + : 0, + stundenGesamt, + produktivGesamt, + produktivQuote: stundenGesamt > 0 ? (produktivGesamt / stundenGesamt) * 100 : 0, + abwesenheitStunden: abwesenheiten.reduce((s, a) => s + a.stunden, 0), + abwesenheitNach: [...zaehler.entries()].map(([name, value]) => ({ name, value })), + zeilen: kennzahlen.map((k) => ({ ...k, id: k.mitarbeiter.id })), + }); +} diff --git a/features/dashboard/view-models/uebersicht.vm.ts b/features/dashboard/view-models/uebersicht.vm.ts new file mode 100644 index 0000000..bd0d88c --- /dev/null +++ b/features/dashboard/view-models/uebersicht.vm.ts @@ -0,0 +1,89 @@ +/** + * View-Model der Übersichtsseite. + * + * Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein + * Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der + * Software ruft der Loader denselben Builder mit echten Daten auf, die + * Screen-Komponente bleibt unverändert. + */ + +import { + aktiveAuftraege, + handlungsbedarf, + marge, + neueKunden, + neueKundenVorperiode, + offenePosten, + teamKennzahlen, + ueberfaelligeAuftraege, + umsatz, + umsatzNachGewerk, + umsatzVerlauf, + umsatzVorperiode, + vertriebsTrichter, + zahlungsdauer, + type AlertEintrag, + type OffenePosten, + type MitarbeiterKennzahl, +} from "../data/calculations"; +import type { Auftrag, ChartPunkt, FilterState, Datensatz, ZeitreihePunkt } from "../data/types"; +import { bereit, leer, type VMState } from "./index"; + +export interface UebersichtDaten { + readonly umsatzAktuell: number; + readonly umsatzVorher: number; + readonly margeProzent: number; + readonly zahlungsdauerTage: number; + readonly verlauf: ZeitreihePunkt[]; + readonly sparkline: number[]; + readonly posten: OffenePosten; + readonly aktiveAuftraege: Auftrag[]; + readonly auftragsbestand: number; + readonly ueberfaellige: Auftrag[]; + readonly monteureAktiv: MitarbeiterKennzahl[]; + readonly durchschnittAuslastung: number; + readonly neueKunden: number; + readonly neueKundenVorher: number; + readonly kundenMitAuftrag: number; + readonly gewerkUmsatz: ChartPunkt[]; + readonly trichter: ChartPunkt[]; + readonly alerts: AlertEintrag[]; + readonly durchschnittAuftragswert: number; +} + +export function baueUebersichtVM(daten: Datensatz, filter: FilterState): VMState { + // Kein Auftrag und keine Rechnung heisst: Die Filter schneiden alles weg. + // Das ist ein Leerzustand, kein Fehler. + if (!daten.auftraege.length && !daten.rechnungen.length) return leer(); + + const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum); + const team = teamKennzahlen(daten.mitarbeiter, daten.zeiten, daten.auftraege, filter.zeitraum); + const monteureAktiv = team.filter((t) => t.mitarbeiter.status === "aktiv"); + const aktiv = aktiveAuftraege(daten.auftraege); + + return bereit({ + umsatzAktuell: umsatz(daten.rechnungen, filter.zeitraum), + umsatzVorher: umsatzVorperiode(daten.rechnungen, filter.zeitraum), + margeProzent: marge(daten.auftraege, filter.zeitraum), + zahlungsdauerTage: zahlungsdauer(daten.rechnungen, filter.zeitraum), + verlauf, + sparkline: verlauf.slice(-10).map((p) => p.value), + posten: offenePosten(daten.rechnungen), + aktiveAuftraege: aktiv, + auftragsbestand: aktiv.reduce((summe, a) => summe + a.wert, 0), + ueberfaellige: ueberfaelligeAuftraege(daten.auftraege), + monteureAktiv, + durchschnittAuslastung: monteureAktiv.length + ? monteureAktiv.reduce((s, t) => s + t.auslastungProzent, 0) / monteureAktiv.length + : 0, + neueKunden: neueKunden(daten.kunden, filter.zeitraum), + neueKundenVorher: neueKundenVorperiode(daten.kunden, filter.zeitraum), + kundenMitAuftrag: daten.kunden.filter((k) => k.auftraegeGesamt > 0).length, + gewerkUmsatz: umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum), + trichter: vertriebsTrichter(daten.auftraege), + alerts: handlungsbedarf(daten), + durchschnittAuftragswert: daten.auftraege.length + ? daten.auftraege.reduce((s, a) => s + a.wert, 0) / daten.auftraege.length + : 0, + }); +}