refactor(dashboard): Seiten von der Datenquelle entkoppeln

Die Seiten holten ihre Zahlen selbst aus einem globalen Store. HiveGrid Cloud
verlangt reine Screens nach dem Muster <XPage vm={} actions={} /> — jede Seite
hätte beim Einbau auseinandergenommen werden müssen.

Neue Schichtung:

  view-models/<seite>.vm.ts   baue<Seite>VM(daten, filter) → VMState<…>
                              reine Funktion, kein React, kein Store
  pages/<Seite>.tsx           ({ vm, optionen }) → JSX      wird portiert
  routes/<Seite>Route.tsx     Store + Builder + Seite       wird ersetzt

view-models/index.ts spiegelt VMState wortgleich aus @hivegrid/view-models;
beim Einbau ist es ein Import-Wechsel. Der Loader ruft denselben Builder mit
echten Daten, die Seiten bleiben unberührt.

Dabei abgefallen: die fünf Seitenzustände (idle · loading · ready · empty ·
error) über SeitenZustand — bisher lief der Prototyp nur im Zustand "Daten da",
in der Cloud sind alle fünf DoD-Pflicht. Die Filterleiste bekommt ihre
Auswahllisten jetzt als Prop (in der Software die Facetten aus dem Loader)
statt sie selbst aus dem Datensatz zu ziehen.

Store- und Rohdatenzugriff gibt es damit ausschließlich in routes/. Alle sechs
Seiten rendern pixelgleich; typecheck, lint und build sind grün.

Nicht geändert: Next.js bleibt. Im gesamten Projekt gibt es vier
Next-spezifische Importe, alle in AppShell und app/layout — beide Dateien
entfallen beim Einbau ohnehin, weil die Cloud Shell und Root-Layout mitbringt.
Ein Framework-Wechsel wäre Aufwand ohne Ertrag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
This commit is contained in:
dejanandClaude Opus 5 committed 2026-08-29 17:39:41 +02:00
1 parent b8bf987d0e
commit 99be627dad
28 files changed
+1030 -340

No files matched your search

+13 -4
View File
@@ -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 `<UebersichtSeite vm={…} />`. 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
---
+13 -3
View File
@@ -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 (`<XPage vm={} actions={} />`): 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.
---
+2 -2
View File
@@ -1,5 +1,5 @@
import { AuftraegeSeite } from "@/features/dashboard/pages/AuftraegeSeite";
import { AuftraegeRoute } from "@/features/dashboard/routes/AuftraegeRoute";
export default function Page() {
return <AuftraegeSeite />;
return <AuftraegeRoute />;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { FinanzenSeite } from "@/features/dashboard/pages/FinanzenSeite";
import { FinanzenRoute } from "@/features/dashboard/routes/FinanzenRoute";
export default function Page() {
return <FinanzenSeite />;
return <FinanzenRoute />;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { KundenSeite } from "@/features/dashboard/pages/KundenSeite";
import { KundenRoute } from "@/features/dashboard/routes/KundenRoute";
export default function Page() {
return <KundenSeite />;
return <KundenRoute />;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { UebersichtSeite } from "@/features/dashboard/pages/UebersichtSeite";
import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute";
export default function Page() {
return <UebersichtSeite />;
return <UebersichtRoute />;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { TeamSeite } from "@/features/dashboard/pages/TeamSeite";
import { TeamRoute } from "@/features/dashboard/routes/TeamRoute";
export default function Page() {
return <TeamSeite />;
return <TeamRoute />;
}
+9 -4
View File
@@ -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.
+58 -9
View File
@@ -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
`<XPage vm={} actions={} />`: 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<UebersichtDaten> }) {
if (vm.status !== "ready") return <SeitenZustand vm={vm} />;
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<UebersichtDaten>
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<LoaderData>();
return <UebersichtSeite vm={vm} actions={{ onAbsprung }} />;
}
```
`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<DashboardPageData>;
@@ -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
@@ -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,
}))
);
@@ -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<unknown> }) {
if (vm.status === "ready") return null;
return (
<PageContainer>
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-default bg-white px-6 py-12 text-center">
{vm.status === "loading" || vm.status === "idle" ? (
<>
<span className="flex flex-col items-center gap-3" aria-live="polite">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-neutral-200 border-t-neutral-500" />
<span className="text-[13px] text-text-neutral-tertiary">Kennzahlen werden berechnet …</span>
</span>
{/* Skelett statt Sprung: die Karten stehen später an derselben Stelle. */}
<div className="mt-4 grid w-full max-w-3xl grid-cols-2 gap-4 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<span key={i} className="h-[104px] animate-pulse rounded-lg bg-neutral-100" />
))}
</div>
</>
) : vm.status === "empty" ? (
<>
<FeatherInbox className="text-[24px] text-text-neutral-tertiary" />
<span className="text-[15px] font-semibold text-text-neutral-primary">
Keine Daten für diese Auswahl
</span>
<span className="max-w-[380px] text-[13px] text-text-neutral-tertiary">
Die gesetzten Filter schneiden alles weg. Zeitraum erweitern oder einzelne
Einschränkungen in der Filterleiste entfernen.
</span>
</>
) : (
<>
<FeatherAlertTriangle className="text-[24px]" style={{ color: STATUS_FARBEN.negativ }} />
<span className="text-[15px] font-semibold text-text-neutral-primary">
Kennzahlen konnten nicht geladen werden
</span>
<span className="max-w-[380px] text-[13px] text-text-neutral-tertiary">
{vm.error?.messageKey ?? "Unbekannter Fehler"}
</span>
</>
)}
</div>
</PageContainer>
);
}
+54 -71
View File
@@ -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<AuftraegeDaten>;
optionen: FilterOptionen;
}) {
if (vm.status !== "ready") {
return (
<>
<PageHeader
titel="Aufträge"
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
/>
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
<SeitenZustand vm={vm} />
</>
);
}
const d = vm.data;
const spalten: Spalte<Auftrag>[] = [
{
@@ -74,7 +63,7 @@ export function AuftraegeSeite() {
<div className="flex flex-col">
<span className="font-medium">{a.titel}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{a.nummer} · {kundeName.get(a.kundeId) ?? "Unbekannt"}
{a.nummer} · {d.kundeName.get(a.kundeId) ?? "Unbekannt"}
</span>
</div>
),
@@ -92,10 +81,10 @@ export function AuftraegeSeite() {
titel: "Verantwortlich",
zelle: (a) => (
<span className="text-text-neutral-secondary">
{mitarbeiterName.get(a.mitarbeiterId) ?? "—"}
{d.mitarbeiterName.get(a.mitarbeiterId) ?? "—"}
</span>
),
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."
/>
<AnalyticsToolbar seite="auftraege" />
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Neue Vorgänge"
wert={formatZahl(imZeitraum.length)}
wert={formatZahl(d.imZeitraum.length)}
zusatz="Nach Anlagedatum gezählt"
trend={berechneTrend(imZeitraum.length, vorher.length)}
trend={berechneTrend(d.imZeitraum.length, d.vorher.length)}
icon="ClipboardList"
verlauf={eingang.slice(-10).map((p) => p.value)}
verlauf={d.sparkline}
/>
<KPICard
label="Aktive Aufträge"
wert={formatZahl(aktiv.length)}
zusatz={`${formatEuro(bestand)} Auftragsbestand`}
wert={formatZahl(d.aktiv.length)}
zusatz={`${formatEuro(d.bestand)} Auftragsbestand`}
icon="Package"
bezug="stichtag"
/>
<KPICard
label="Über Termin"
wert={formatZahl(ueberfaellig.length)}
wert={formatZahl(d.ueberfaellig.length)}
zusatz={
ueberfaellig.length
? `Ältester seit ${tageLabelDativ(tageSeit(ueberfaellig[0].faelligAm, HEUTE))} fällig`
d.ueberfaellig.length
? `Ältester seit ${tageLabelDativ(tageSeit(d.ueberfaellig[0].faelligAm, HEUTE))} fällig`
: "Alle Termine gehalten"
}
icon="AlertTriangle"
bezug="stichtag"
akzent={ueberfaellig.length ? "negativ" : "positiv"}
akzent={d.ueberfaellig.length ? "negativ" : "positiv"}
/>
<KPICard
label="Abschlussquote"
wert={formatProzent(quote, 0)}
wert={formatProzent(d.quote, 0)}
zusatz="Aus Angebot wird Auftrag"
icon="Target"
bezug="stichtag"
/>
<KPICard
label="Ø Durchlaufzeit"
wert={formatTage(laufzeit)}
wert={formatTage(d.laufzeit)}
zusatz="Anfrage bis Abnahme"
icon="Clock"
bezug="stichtag"
@@ -183,18 +172,14 @@ export function AuftraegeSeite() {
untertitel="Neu angelegte Vorgänge im Zeitverlauf"
className="xl:col-span-2"
>
<BalkenVertikal daten={eingang} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
<BalkenVertikal daten={d.eingang} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
<StatZeile
spalten={2}
eintraege={[
{ label: "Vorperiode", wert: formatZahl(vorher.length) },
{ label: "Vorperiode", wert: formatZahl(d.vorher.length) },
{
label: "Ø Auftragswert",
wert: formatEuro(
imZeitraum.length
? imZeitraum.reduce((s, a) => 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."
>
<Trichter stufen={vertriebsTrichter(daten.auftraege)} formatWert={(w) => formatZahl(w)} />
<Trichter stufen={d.trichter} formatWert={(w) => formatZahl(w)} />
</ChartCard>
</div>
@@ -217,7 +202,7 @@ export function AuftraegeSeite() {
untertitel="Alle Vorgänge seit Beginn — unabhängig vom Zeitraumfilter"
>
<Donut
daten={auftraegeNachStatus(daten.auftraege)}
daten={d.statusVerteilung}
formatWert={(w) => formatZahl(w)}
hoehe={220}
mitteLabel="Vorgänge"
@@ -230,7 +215,7 @@ export function AuftraegeSeite() {
untertitel="Anzahl seit Beginn, zum Stichtag"
>
<BalkenHorizontal
daten={auftraegeNachGewerk(daten.auftraege)}
daten={d.gewerkAnzahl}
formatWert={(w) => formatZahl(w)}
einfarbig={false}
maxBalken={5}
@@ -240,13 +225,13 @@ export function AuftraegeSeite() {
<ChartCard
id="auftraege-foerderung"
titel="Förderquote BAFA / KfW"
untertitel={`${formatProzent(foerderung.quote, 0)} der ${formatZahl(
foerderung.foerderfaehig
untertitel={`${formatProzent(d.foerderung.quote, 0)} der ${formatZahl(
d.foerderung.foerderfaehig
)} förderfähigen Aufträge`}
hinweis="Bezugsgröße sind nur förderfähige Leistungen: Wärmepumpe, PV-Anlage, Batteriespeicher und Lüftungsanlage. Über alle Aufträge gerechnet wäre die Quote wertlos."
>
<Donut
daten={foerderung.verteilung}
daten={d.foerderung.verteilung}
formatWert={(w) => formatZahl(w)}
hoehe={200}
mitteLabel="Förderfähig"
@@ -254,12 +239,10 @@ export function AuftraegeSeite() {
<StatZeile
spalten={2}
eintraege={[
{ label: "Fördersumme", wert: formatEuroKompakt(foerderung.summe) },
{ label: "Fördersumme", wert: formatEuroKompakt(d.foerderung.summe) },
{
label: "Ø je Auftrag",
wert: formatEuroKompakt(
foerderung.bewilligt ? foerderung.summe / foerderung.bewilligt : 0
),
wert: formatEuroKompakt(d.foerderungJeAuftrag),
},
]}
/>
@@ -268,15 +251,15 @@ export function AuftraegeSeite() {
<ChartCard
id="auftraege-liste"
titel={`Aktive Aufträge (${aktiv.length})`}
titel={`Aktive Aufträge (${d.aktiv.length})`}
untertitel="Beauftragt oder in Arbeit"
>
<DataTable
zeilen={aktiv}
zeilen={d.aktiv}
spalten={spalten}
proSeite={8}
suchFelder={(a) =>
`${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"
/>
+51 -50
View File
@@ -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<FinanzenDaten>;
optionen: FilterOptionen;
}) {
if (vm.status !== "ready") {
return (
<>
<PageHeader
titel="Finanzen"
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
/>
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
<SeitenZustand vm={vm} />
</>
);
}
const d = vm.data;
const spalten: Spalte<Rechnung>[] = [
{
@@ -71,7 +70,7 @@ export function FinanzenSeite() {
<div className="flex flex-col">
<span className="font-medium">{r.nummer}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{kundeName.get(r.kundeId) ?? "Unbekannt"}
{d.kundeName.get(r.kundeId) ?? "Unbekannt"}
</span>
</div>
),
@@ -121,44 +120,44 @@ export function FinanzenSeite() {
titel="Finanzen"
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
/>
<AnalyticsToolbar seite="finanzen" />
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Umsatz (bezahlt)"
wert={formatEuro(umsatzAktuell)}
wert={formatEuro(d.umsatzAktuell)}
zusatz="Zahlungseingang im Zeitraum"
trend={berechneTrend(umsatzAktuell, umsatzVorher)}
trend={berechneTrend(d.umsatzAktuell, d.umsatzVorher)}
icon="Euro"
verlauf={verlauf.slice(-10).map((p) => p.value)}
verlauf={d.sparkline}
/>
<KPICard
label="Offene Forderungen"
wert={formatEuro(posten.gesamt)}
zusatz={`${posten.anzahl} Rechnungen`}
wert={formatEuro(d.posten.gesamt)}
zusatz={`${d.posten.anzahl} Rechnungen`}
icon="FileText"
bezug="stichtag"
/>
<KPICard
label="Überfällig"
wert={formatEuro(posten.ueberfaelligBetrag)}
zusatz={`${posten.ueberfaelligAnzahl} Rechnungen im Mahnlauf`}
wert={formatEuro(d.posten.ueberfaelligBetrag)}
zusatz={`${d.posten.ueberfaelligAnzahl} Rechnungen im Mahnlauf`}
icon="AlertTriangle"
bezug="stichtag"
akzent={posten.ueberfaelligBetrag > 0 ? "negativ" : "positiv"}
akzent={d.posten.ueberfaelligBetrag > 0 ? "negativ" : "positiv"}
/>
<KPICard
label="Ø Zahlungsdauer"
wert={formatTage(dauer)}
wert={formatTage(d.zahlungsdauerTage)}
zusatz="Rechnung bis Zahlungseingang"
icon="Clock"
akzent={dauer > 30 ? "warnung" : "positiv"}
akzent={d.zahlungsdauerTage > 30 ? "warnung" : "positiv"}
richtungGut="negativ"
/>
<KPICard
label="Deckungsbeitrag"
wert={formatProzent(margeProzent)}
wert={formatProzent(d.margeProzent)}
zusatz="Gewichtet über Abschlüsse"
icon="Percent"
/>
@@ -181,7 +180,7 @@ export function FinanzenSeite() {
}
>
<AreaVerlauf
daten={verlauf}
daten={d.verlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
@@ -190,10 +189,10 @@ export function FinanzenSeite() {
<StatZeile
spalten={2}
eintraege={[
{ label: "Vorperiode", wert: formatEuro(umsatzVorher) },
{ label: "Vorperiode", wert: formatEuro(d.umsatzVorher) },
{
label: "Veränderung",
wert: `${umsatzAktuell >= 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."
>
<Donut
daten={offeneNachStatus(daten.rechnungen)}
daten={d.offenNachStatus}
formatWert={formatEuroKompakt}
hoehe={200}
mitteLabel="Offen"
@@ -220,11 +219,11 @@ export function FinanzenSeite() {
className="lg:col-span-2"
id="finanzen-alter"
titel="Offene Forderungen nach Alter"
untertitel={`Gesamt ${formatEuro(posten.gesamt)}`}
untertitel={`Gesamt ${formatEuro(d.posten.gesamt)}`}
hinweis="Alter ab Fälligkeitsdatum. Ab 60 Tagen sinkt die Einbringungsquote erfahrungsgemäß deutlich."
>
<BalkenVertikal
daten={posten.nachAlter}
daten={d.posten.nachAlter}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
hoehe={240}
@@ -235,10 +234,12 @@ export function FinanzenSeite() {
eintraege={[
{
label: "Älter als 90 Tage",
wert: formatEuro(posten.nachAlter.find((b) => 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) },
]}
/>
</ChartCard>
@@ -249,7 +250,7 @@ export function FinanzenSeite() {
untertitel="Zahlungseingang im Zeitraum"
>
<BalkenHorizontal
daten={umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum)}
daten={d.gewerkUmsatz}
formatWert={formatEuroKompakt}
einfarbig={false}
maxBalken={5}
@@ -260,14 +261,14 @@ export function FinanzenSeite() {
<ChartCard
id="finanzen-offene-liste"
titel={`Offene Rechnungen (${posten.anzahl})`}
titel={`Offene Rechnungen (${d.posten.anzahl})`}
untertitel="Sortiert nach Fälligkeit — die Arbeitsliste für den Mahnlauf"
>
<DataTable
zeilen={posten.liste}
zeilen={d.posten.liste}
spalten={spalten}
proSeite={8}
suchFelder={(r) => `${r.nummer} ${kundeName.get(r.kundeId) ?? ""}`}
suchFelder={(r) => `${r.nummer} ${d.kundeName.get(r.kundeId) ?? ""}`}
leerText="Keine offenen Rechnungen"
/>
</ChartCard>
+42 -46
View File
@@ -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<KundenDaten>;
optionen: FilterOptionen;
}) {
if (vm.status !== "ready") {
return (
<>
<PageHeader
titel="Kunden"
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
/>
<AnalyticsToolbar seite="kunden" optionen={optionen} />
<SeitenZustand vm={vm} />
</>
);
}
const d = vm.data;
const spalten: Spalte<Kunde>[] = [
{
@@ -117,46 +113,46 @@ export function KundenSeite() {
titel="Kunden"
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
/>
<AnalyticsToolbar seite="kunden" />
<AnalyticsToolbar seite="kunden" optionen={optionen} />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Kunden gesamt"
wert={formatZahl(daten.kunden.length)}
zusatz={`${formatZahl(mitAuftrag.length)} mit mindestens einem Auftrag`}
wert={formatZahl(d.kundenGesamt)}
zusatz={`${formatZahl(d.kundenMitAuftrag)} mit mindestens einem Auftrag`}
icon="Users"
bezug="stichtag"
/>
<KPICard
label="Neue Kunden"
wert={formatZahl(neu)}
wert={formatZahl(d.neueKunden)}
zusatz="Nach Anlagedatum gezählt"
trend={berechneTrend(neu, neuVorher)}
trend={berechneTrend(d.neueKunden, d.neueKundenVorher)}
icon="UserPlus"
verlauf={entwicklung.slice(-10).map((p) => p.value)}
verlauf={d.sparkline}
/>
<KPICard
label="Wiederkehrende Kunden"
wert={formatProzent(wiederkehrend, 0)}
wert={formatProzent(d.wiederkehrendProzent, 0)}
zusatz="Mehr als ein Auftrag"
icon="Repeat"
bezug="stichtag"
/>
<KPICard
label="Ø Kundenwert"
wert={formatEuro(kundenwert)}
wert={formatEuro(d.kundenwert)}
zusatz="Je Kunde mit mindestens einem Auftrag"
icon="CircleDollarSign"
bezug="gesamt"
/>
<KPICard
label="Reaktivierbar"
wert={formatZahl(inaktiv.length)}
wert={formatZahl(d.inaktive.length)}
zusatz="Über 12 Monate ohne Auftrag"
icon="Clock"
bezug="stichtag"
akzent={inaktiv.length > 0 ? "warnung" : "neutral"}
akzent={d.inaktive.length > 0 ? "warnung" : "neutral"}
/>
</Raster>
@@ -167,7 +163,7 @@ export function KundenSeite() {
untertitel="Anlagedatum des Kundendatensatzes"
className="xl:col-span-2"
>
<BalkenVertikal daten={entwicklung} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
<BalkenVertikal daten={d.entwicklung} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
</ChartCard>
<ChartCard
@@ -177,7 +173,7 @@ export function KundenSeite() {
hinweis="Empfehlungen und Bestandskunden zusammen zeigen, wie stark der Betrieb ohne Werbebudget wächst."
>
<Donut
daten={kundenNach(daten.kunden, "quelle")}
daten={d.nachQuelle}
formatWert={(w) => formatZahl(w)}
hoehe={220}
mitteLabel="Kunden"
@@ -188,7 +184,7 @@ export function KundenSeite() {
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard id="kunden-typ" titel="Kundenstruktur" untertitel="Privat, Gewerbe, Verwaltung">
<Donut
daten={kundenNach(daten.kunden, "typ")}
daten={d.nachTyp}
formatWert={(w) => formatZahl(w)}
hoehe={200}
mitteLabel="Kunden"
@@ -201,18 +197,18 @@ export function KundenSeite() {
titel="Top-Kunden nach Umsatz"
untertitel="Bezahlter Gesamtumsatz"
>
<BalkenHorizontal daten={topKunden(daten.kunden, 6)} formatWert={formatEuroKompakt} />
<BalkenHorizontal daten={d.topKunden} formatWert={formatEuroKompakt} />
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<ChartCard
id="kunden-inaktiv"
titel={`Reaktivierungspotenzial (${inaktiv.length})`}
titel={`Reaktivierungspotenzial (${d.inaktive.length})`}
untertitel="Kunden mit Historie, aber über 12 Monate ohne Auftrag"
>
<DataTable
zeilen={inaktiv}
zeilen={d.inaktive}
spalten={spalten.filter((s) => s.key !== "zahlung")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort} ${k.quelle}`}
@@ -222,11 +218,11 @@ export function KundenSeite() {
<ChartCard
id="kunden-zahlung"
titel={`Auffällige Zahlungsmoral (${auffaellig.length})`}
titel={`Auffällige Zahlungsmoral (${d.auffaellige.length})`}
untertitel="Durchschnittlich über 30 Tage bis zum Zahlungseingang"
>
<DataTable
zeilen={auffaellig}
zeilen={d.auffaellige}
spalten={spalten.filter((s) => s.key !== "letzter")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort}`}
+38 -53
View File
@@ -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<TeamDaten>;
optionen: FilterOptionen;
}) {
if (vm.status !== "ready") {
return (
<>
<PageHeader
titel="Team"
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
/>
<AnalyticsToolbar seite="team" optionen={optionen} />
<SeitenZustand vm={vm} />
</>
);
}
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<string, number>();
const label: Record<string, string> = {
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<MitarbeiterKennzahl & { id: string }>[] = [
const spalten: Spalte<TeamZeile>[] = [
{
key: "name",
titel: "Mitarbeiter",
@@ -134,47 +121,45 @@ export function TeamSeite() {
},
];
const tabellenZeilen = kennzahlen.map((k) => ({ ...k, id: k.mitarbeiter.id }));
return (
<>
<PageHeader
titel="Team"
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
/>
<AnalyticsToolbar seite="team" />
<AnalyticsToolbar seite="team" optionen={optionen} />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Im Einsatz"
wert={formatZahl(aktive.length)}
zusatz={`von ${formatZahl(kennzahlen.length)} gewerblich Beschäftigten`}
wert={formatZahl(d.aktive.length)}
zusatz={`von ${formatZahl(d.mitarbeiterGesamt)} gewerblich Beschäftigten`}
icon="HardHat"
bezug="stichtag"
/>
<KPICard
label="Ø Auslastung"
wert={formatProzent(durchschnitt, 0)}
wert={formatProzent(d.durchschnittAuslastung, 0)}
zusatz="Produktive Stunden zu verfügbaren"
icon="Activity"
akzent={durchschnitt > 95 ? "warnung" : "positiv"}
akzent={d.durchschnittAuslastung > 95 ? "warnung" : "positiv"}
/>
<KPICard
label="Produktivquote"
wert={formatProzent(produktivQuote, 0)}
wert={formatProzent(d.produktivQuote, 0)}
zusatz="Anteil auftragsbezogener Stunden"
icon="Target"
/>
<KPICard
label="Erfasste Stunden"
wert={formatStunden(stundenGesamt)}
zusatz={`${formatStunden(produktivGesamt)} auf Aufträge gebucht`}
wert={formatStunden(d.stundenGesamt)}
zusatz={`${formatStunden(d.produktivGesamt)} auf Aufträge gebucht`}
icon="Clock"
/>
<KPICard
label="Abwesenheit"
wert={formatStunden(abwesenheiten.reduce((s, a) => 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."
>
<AuslastungsBalken
zeilen={aktive.map((k) => ({
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."
>
<BalkenHorizontal
daten={aktive.map((k) => ({
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"
>
<Donut
daten={abwesenheitNach}
daten={d.abwesenheitNach}
formatWert={(w) => formatStunden(w)}
hoehe={210}
mitteLabel="Stunden"
@@ -236,7 +221,7 @@ export function TeamSeite() {
untertitel="Alle gewerblich Beschäftigten mit Kennzahlen"
>
<DataTable
zeilen={tabellenZeilen}
zeilen={d.zeilen}
spalten={spalten}
proSeite={10}
suchFelder={(k) => `${k.mitarbeiter.name} ${k.mitarbeiter.rolle} ${k.mitarbeiter.gewerk}`}
+50 -72
View File
@@ -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<UebersichtDaten>;
optionen: FilterOptionen;
}) {
if (vm.status !== "ready") {
return (
<>
<PageHeader
titel="Übersicht"
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
/>
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
<SeitenZustand vm={vm} />
</>
);
}
const d = vm.data;
const auftragSpalten: Spalte<Auftrag>[] = [
{
@@ -118,48 +103,45 @@ export function UebersichtSeite() {
titel="Übersicht"
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
/>
<AnalyticsToolbar seite="uebersicht" />
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Umsatz (Zahlungseingang)"
wert={formatEuro(umsatzAktuell)}
zusatz={`Deckungsbeitrag ${formatProzent(marge(daten.auftraege, filter.zeitraum))}`}
trend={berechneTrend(umsatzAktuell, umsatzVorher)}
wert={formatEuro(d.umsatzAktuell)}
zusatz={`Deckungsbeitrag ${formatProzent(d.margeProzent)}`}
trend={berechneTrend(d.umsatzAktuell, d.umsatzVorher)}
icon="Euro"
verlauf={sparkline}
verlauf={d.sparkline}
/>
<KPICard
label="Offene Forderungen"
wert={formatEuro(posten.gesamt)}
zusatz={`${posten.anzahl} Rechnungen · ${posten.ueberfaelligAnzahl} überfällig`}
wert={formatEuro(d.posten.gesamt)}
zusatz={`${d.posten.anzahl} Rechnungen · ${d.posten.ueberfaelligAnzahl} überfällig`}
icon="FileText"
bezug="stichtag"
akzent={posten.ueberfaelligAnzahl > 0 ? "warnung" : "neutral"}
akzent={d.posten.ueberfaelligAnzahl > 0 ? "warnung" : "neutral"}
/>
<KPICard
label="Aktive Aufträge"
wert={formatZahl(aktiv.length)}
zusatz={`${formatEuro(aktiv.reduce((s, a) => s + a.wert, 0))} Auftragsbestand`}
wert={formatZahl(d.aktiveAuftraege.length)}
zusatz={`${formatEuro(d.auftragsbestand)} Auftragsbestand`}
icon="ClipboardList"
bezug="stichtag"
/>
<KPICard
label="Ø Auslastung Monteure"
wert={formatProzent(durchschnittAuslastung, 0)}
zusatz={`${monteureAktiv.length} Personen im Einsatz`}
wert={formatProzent(d.durchschnittAuslastung, 0)}
zusatz={`${d.monteureAktiv.length} Personen im Einsatz`}
icon="HardHat"
akzent={durchschnittAuslastung > 95 ? "warnung" : durchschnittAuslastung < 70 ? "info" : "positiv"}
akzent={d.durchschnittAuslastung > 95 ? "warnung" : d.durchschnittAuslastung < 70 ? "info" : "positiv"}
/>
<KPICard
label="Neue Kunden"
wert={formatZahl(neueKunden(daten.kunden, filter.zeitraum))}
zusatz={`${formatZahl(daten.kunden.filter((k) => 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"
/>
</Raster>
@@ -169,7 +151,7 @@ export function UebersichtSeite() {
titel="Handlungsbedarf"
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
>
<AlertList eintraege={alerts} spalten={2} />
<AlertList eintraege={d.alerts} spalten={2} />
</ChartCard>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-3">
@@ -189,7 +171,7 @@ export function UebersichtSeite() {
}
>
<AreaVerlauf
daten={verlauf}
daten={d.verlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
@@ -198,16 +180,12 @@ export function UebersichtSeite() {
<StatZeile
spalten={3}
eintraege={[
{ label: "Vorperiode", wert: formatEuro(umsatzVorher) },
{ label: "Vorperiode", wert: formatEuro(d.umsatzVorher) },
{
label: "Ø Auftragswert",
wert: formatEuro(
daten.auftraege.length
? daten.auftraege.reduce((s, a) => 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) },
]}
/>
</ChartCard>
@@ -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."
>
<BalkenHorizontal
daten={gewerkUmsatz}
daten={d.gewerkUmsatz}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
@@ -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."
>
<Trichter stufen={trichter} formatWert={(w) => formatZahl(w)} />
<Trichter stufen={d.trichter} formatWert={(w) => formatZahl(w)} />
</ChartCard>
<ChartCard
@@ -243,7 +221,7 @@ export function UebersichtSeite() {
hinweis="Nur auf Aufträge gebuchte Stunden zählen als produktiv. Über 100 % bedeutet Überstunden."
>
<AuslastungsBalken
zeilen={monteureAktiv.slice(0, 6).map((t) => ({
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() {
<ChartCard
id="uebersicht-ueberfaellig"
titel={`Aufträge über Termin (${ueberfaellig.length})`}
titel={`Aufträge über Termin (${d.ueberfaellige.length})`}
untertitel="Fälligkeitsdatum überschritten, noch nicht abgeschlossen"
>
<DataTable
zeilen={ueberfaellig}
zeilen={d.ueberfaellige}
spalten={auftragSpalten}
proSeite={6}
suchFelder={(a) => `${a.titel} ${a.nummer} ${a.gewerk}`}
@@ -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<LoaderData>();
* return <AuftraegeSeite vm={vm} actions={{ … }} />;
* }
*
* 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 (
<AuftraegeSeite
vm={baueAuftraegeVM(daten, filter)}
optionen={baueFilterOptionen(gesamt)}
/>
);
}
@@ -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<LoaderData>();
* return <FinanzenSeite vm={vm} actions={{ … }} />;
* }
*
* 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 (
<FinanzenSeite
vm={baueFinanzenVM(daten, filter)}
optionen={baueFilterOptionen(gesamt)}
/>
);
}
+30
View File
@@ -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<LoaderData>();
* return <KundenSeite vm={vm} actions={{ … }} />;
* }
*
* 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 (
<KundenSeite
vm={baueKundenVM(daten, filter)}
optionen={baueFilterOptionen(gesamt)}
/>
);
}
+30
View File
@@ -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<LoaderData>();
* return <TeamSeite vm={vm} actions={{ … }} />;
* }
*
* 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 (
<TeamSeite
vm={baueTeamVM(daten, filter)}
optionen={baueFilterOptionen(gesamt)}
/>
);
}
@@ -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<LoaderData>();
* return <UebersichtSeite vm={vm} actions={{ … }} />;
* }
*
* 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 (
<UebersichtSeite
vm={baueUebersichtVM(daten, filter)}
optionen={baueFilterOptionen(gesamt)}
/>
);
}
@@ -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<typeof foerderQuote>;
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<string, string>;
readonly kundeName: ReadonlyMap<string, string>;
}
export function baueAuftraegeVM(daten: Datensatz, filter: FilterState): VMState<AuftraegeDaten> {
// 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])),
});
}
@@ -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<string, string>;
}
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])),
};
}
@@ -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<string, string>;
}
export function baueFinanzenVM(daten: Datensatz, filter: FilterState): VMState<FinanzenDaten> {
// 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])),
});
}
+48
View File
@@ -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 (`<XPage vm={} actions={} />`). 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<T> =
| { 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<T>(data: T): VMState<T> {
return { status: "ready", data };
}
/** Kein Datensatz für die aktuelle Auswahl — nicht dasselbe wie ein Fehler. */
export function leer<T>(): VMState<T> {
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;
}
@@ -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<KundenDaten> {
// 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),
});
}
+84
View File
@@ -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<string, string> = {
urlaub: "Urlaub",
krank: "Krank",
fortbildung: "Fortbildung",
};
export function baueTeamVM(daten: Datensatz, filter: FilterState): VMState<TeamDaten> {
// 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<string, number>();
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 })),
});
}
@@ -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<UebersichtDaten> {
// 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,
});
}