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:
1 parent
b8bf987d0e
commit
99be627dad
28 files changed
+1030
-340
No files matched your search
+13
-4
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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])),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user