feat(dashboard): Analytics-Dashboard als Gestaltungsvorlage zur Übergabe

UI/UX-Prototyp der Analytics-Dashboards in HiveGrid, fachlich auf einen
SHK-/Handwerksbetrieb zugeschnitten. Ziel ist die Übergabe an die Entwicklung,
nicht der Produktivbetrieb.

Inhalt: sechs Seiten (Übersicht, Finanzen, Aufträge, Kunden, Team, Komponenten),
26 KPIs, neun Chart-Bausteine, Filtersystem mit seitenabhängiger Relevanz und ein
deterministischer Demo-Datensatz (420 Kunden, 1.900 Aufträge, Stichtag 30.07.2026).

Drei Quellen sind zusammengeführt: die fachliche Planung mit dem Branchenpartner
(Januar 2026), die Subframe-Komponentenbibliothek aus dem HiveGrid-Pitch und
IBM Carbon Data Visualization für die Diagrammregeln. Herkunft und bewusste
Abweichungen stehen in docs/HERKUNFT.md.

Dokumentation: README, HANDOVER (was echte Logik braucht), docs/DESIGN-SYSTEM,
KPI-KATALOG, DATENMODELL, NAVIGATION, HERKUNFT, ZIELSYSTEM (Einbauort in
HiveGrid Cloud) und LEITPLANKEN (welche Cloud-Screens als Vorbild taugen).

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:25:20 +02:00
commit b8bf987d0e
250 files changed
+33153

No files matched your search

+568
View File
@@ -0,0 +1,568 @@
/**
* Kennzahlen-Berechnungen.
*
* Alle Funktionen sind pure functions über dem gefilterten Datensatz
* (Anforderung INFRA-06) — die Entwicklung kann sie unverändert übernehmen
* und lediglich die Datenquelle austauschen.
*
* Die Definition jeder Kennzahl steht in docs/KPI-KATALOG.md.
*/
import {
AUFTRAG_STATUS_LABEL,
FUNNEL_STUFEN,
RECHNUNG_STATUS_LABEL,
type Auftrag,
type AuftragStatus,
type ChartPunkt,
type Datensatz,
type Kunde,
type Rechnung,
FOERDERFAEHIGE_KATEGORIEN,
type ZeitraumPreset,
type ZeitreihePunkt,
type Zeiteintrag,
type Mitarbeiter,
} from "./types";
import {
HEUTE,
bucketKey,
bucketsFuer,
formatWoche,
imFenster,
vorperiode,
zeitfenster,
addMonate,
} from "../utils/dateUtils";
import { formatDatumKurz, formatMonatKurz, tageSeit } from "../utils/formatters";
/* ------------------------------------------------------------- Umsatz */
/** Umsatz = Summe bezahlter Ausgangsrechnungen im Zeitraum (Zahlungseingang). */
export function umsatz(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number {
const fenster = zeitfenster(zeitraum);
return rechnungen
.filter((r) => r.status === "bezahlt" && imFenster(r.bezahltAm, fenster))
.reduce((summe, r) => summe + r.betrag, 0);
}
/** Gleiche Berechnung für die Vorperiode — Basis der Trend-Badges. */
export function umsatzVorperiode(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number {
const fenster = vorperiode(zeitraum);
return rechnungen
.filter((r) => r.status === "bezahlt" && imFenster(r.bezahltAm, fenster))
.reduce((summe, r) => summe + r.betrag, 0);
}
/** Beschriftung eines Buckets — eine Stelle fuer alle drei Zeitreihen. */
function bucketLabel(key: string, art: "tag" | "woche" | "monat"): string {
if (art === "monat") return formatMonatKurz(key);
if (art === "woche") return formatWoche(key);
return formatDatumKurz(key);
}
/**
* Umsatzverlauf mit Vorjahresvergleich.
* Buckets richten sich nach dem Zeitraum (Tage bei ≤ 30 Tagen, sonst Monate).
*/
export function umsatzVerlauf(
rechnungen: Rechnung[],
zeitraum: ZeitraumPreset
): ZeitreihePunkt[] {
const { art, schluessel } = bucketsFuer(zeitraum);
const aktuell = new Map<string, number>();
const vorjahr = new Map<string, number>();
for (const r of rechnungen) {
if (r.status !== "bezahlt" || !r.bezahltAm) continue;
aktuell.set(
bucketKey(r.bezahltAm, art),
(aktuell.get(bucketKey(r.bezahltAm, art)) ?? 0) + r.betrag
);
// Vorjahreswert: dasselbe Datum plus 12 Monate landet im aktuellen Bucket.
const verschoben = addMonate(new Date(r.bezahltAm), 12);
const key = bucketKey(verschoben.toISOString().slice(0, 10), art);
vorjahr.set(key, (vorjahr.get(key) ?? 0) + r.betrag);
}
return schluessel.map((key) => ({
datum: key,
label: bucketLabel(key, art),
value: aktuell.get(key) ?? 0,
vergleich: vorjahr.get(key) ?? 0,
}));
}
/** Deckungsbeitrag: gewichtete Marge über abgeschlossene Aufträge im Zeitraum. */
export function marge(auftraege: Auftrag[], zeitraum: ZeitraumPreset): number {
const fenster = zeitfenster(zeitraum);
const relevant = auftraege.filter(
(a) => a.status === "abgeschlossen" && imFenster(a.abgeschlossenAm, fenster)
);
const gesamtwert = relevant.reduce((s, a) => s + a.wert, 0);
if (gesamtwert === 0) return 0;
const db = relevant.reduce((s, a) => s + (a.wert * a.margeProzent) / 100, 0);
return (db / gesamtwert) * 100;
}
/* --------------------------------------------------------- Rechnungen */
export interface OffenePosten {
gesamt: number;
anzahl: number;
ueberfaelligBetrag: number;
ueberfaelligAnzahl: number;
nachAlter: ChartPunkt[];
liste: Rechnung[];
}
/**
* Offene Posten sind stichtagsbezogen (nicht zeitraumgefiltert):
* Was ist heute noch nicht bezahlt?
*/
export function offenePosten(rechnungen: Rechnung[]): OffenePosten {
const offen = rechnungen.filter((r) => r.status === "gestellt" || r.status === "ueberfaellig");
const buckets: Record<string, { betrag: number; anzahl: number }> = {
"0–30 Tage": { betrag: 0, anzahl: 0 },
"31–60 Tage": { betrag: 0, anzahl: 0 },
"61–90 Tage": { betrag: 0, anzahl: 0 },
"> 90 Tage": { betrag: 0, anzahl: 0 },
};
for (const r of offen) {
const tage = tageSeit(r.faelligAm, HEUTE);
const bucket =
tage <= 30 ? "0–30 Tage" : tage <= 60 ? "31–60 Tage" : tage <= 90 ? "61–90 Tage" : "> 90 Tage";
buckets[bucket].betrag += r.betrag;
buckets[bucket].anzahl += 1;
}
const ueberfaellig = offen.filter((r) => r.status === "ueberfaellig");
return {
gesamt: offen.reduce((s, r) => s + r.betrag, 0),
anzahl: offen.length,
ueberfaelligBetrag: ueberfaellig.reduce((s, r) => s + r.betrag, 0),
ueberfaelligAnzahl: ueberfaellig.length,
nachAlter: Object.entries(buckets).map(([name, v]) => ({ name, value: v.betrag })),
liste: offen.sort((a, b) => (a.faelligAm < b.faelligAm ? -1 : 1)),
};
}
/** Durchschnittliche Zahlungsdauer in Tagen (gestellt → bezahlt). */
export function zahlungsdauer(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number {
const fenster = zeitfenster(zeitraum);
const bezahlt = rechnungen.filter(
(r) => r.status === "bezahlt" && r.bezahltAm && imFenster(r.bezahltAm, fenster)
);
if (!bezahlt.length) return 0;
const summe = bezahlt.reduce(
(s, r) =>
s + (new Date(r.bezahltAm!).getTime() - new Date(r.gestelltAm).getTime()) / 86_400_000,
0
);
return summe / bezahlt.length;
}
/**
* Zusammensetzung des OFFENEN Bestands.
*
* Nicht zu verwechseln mit rechnungenNachStatus(): dort steht der kumulierte
* Umsatz aus zwei Jahren gegen den heutigen Forderungsbestand — dabei entfallen
* über 90 % auf "Bezahlt", und der Ring zeigt nichts mehr. Der offene Bestand
* ist ein echtes Ganzes: alles, was heute noch aussteht.
*/
export function offeneNachStatus(rechnungen: Rechnung[]): ChartPunkt[] {
// Entwuerfe sind noch nicht gestellt und damit keine Forderung. Wuerden sie
// mitzaehlen, waeche die Ringsumme von der KPI "Offene Forderungen" ab.
const offen = rechnungen.filter(
(r) => r.richtung === "ausgang" && (r.status === "gestellt" || r.status === "ueberfaellig")
);
const summen = new Map<string, number>();
for (const r of offen) {
const label = RECHNUNG_STATUS_LABEL[r.status];
summen.set(label, (summen.get(label) ?? 0) + r.betrag);
}
return [...summen.entries()].map(([name, value]) => ({ name, value }));
}
export function rechnungenNachStatus(rechnungen: Rechnung[]): ChartPunkt[] {
const zaehler = new Map<string, number>();
for (const r of rechnungen) {
const label = RECHNUNG_STATUS_LABEL[r.status];
zaehler.set(label, (zaehler.get(label) ?? 0) + r.betrag);
}
return [...zaehler.entries()].map(([name, value]) => ({ name, value }));
}
/* ------------------------------------------------------------ Aufträge */
export function auftraegeImZeitraum(auftraege: Auftrag[], zeitraum: ZeitraumPreset): Auftrag[] {
const fenster = zeitfenster(zeitraum);
return auftraege.filter((a) => imFenster(a.angelegtAm, fenster));
}
/** "Aktiv" = beauftragt oder in Arbeit — das, was das Team gerade bindet. */
export function aktiveAuftraege(auftraege: Auftrag[]): Auftrag[] {
return auftraege.filter((a) => a.status === "beauftragt" || a.status === "in_arbeit");
}
export function ueberfaelligeAuftraege(auftraege: Auftrag[]): Auftrag[] {
// Einen Termin gibt es erst ab der Beauftragung — eine offene Anfrage kann
// nicht "über Termin" sein.
return auftraege
.filter(
(a) =>
(a.status === "beauftragt" || a.status === "in_arbeit") &&
new Date(a.faelligAm) < HEUTE
)
.sort((a, b) => (a.faelligAm < b.faelligAm ? -1 : 1));
}
export function auftraegeNachStatus(auftraege: Auftrag[]): ChartPunkt[] {
const zaehler = new Map<AuftragStatus, number>();
for (const a of auftraege) zaehler.set(a.status, (zaehler.get(a.status) ?? 0) + 1);
return (Object.keys(AUFTRAG_STATUS_LABEL) as AuftragStatus[])
.map((status) => ({ name: AUFTRAG_STATUS_LABEL[status], value: zaehler.get(status) ?? 0 }))
.filter((p) => p.value > 0);
}
export function auftraegeNachGewerk(auftraege: Auftrag[]): ChartPunkt[] {
const zaehler = new Map<string, number>();
for (const a of auftraege) zaehler.set(a.gewerk, (zaehler.get(a.gewerk) ?? 0) + 1);
return [...zaehler.entries()]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
export function umsatzNachGewerk(
auftraege: Auftrag[],
rechnungen: Rechnung[],
zeitraum: ZeitraumPreset
): ChartPunkt[] {
const fenster = zeitfenster(zeitraum);
const gewerkProAuftrag = new Map(auftraege.map((a) => [a.id, a.gewerk]));
const summen = new Map<string, number>();
for (const r of rechnungen) {
if (r.status !== "bezahlt" || !imFenster(r.bezahltAm, fenster)) continue;
const gewerk = gewerkProAuftrag.get(r.auftragId);
if (!gewerk) continue;
summen.set(gewerk, (summen.get(gewerk) ?? 0) + r.betrag);
}
return [...summen.entries()]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
/**
* Vertriebstrichter: Wie viele Vorgänge haben eine Stufe mindestens erreicht?
* Verlorene Aufträge zählen nur bis zu ihrer letzten erreichten Stufe.
*/
/**
* Vertriebstrichter — bewusst nur über das Angebotsgeschäft.
*
* Wartung, Notdienst und E-Check laufen ohne Angebot direkt in die Ausführung.
* Zählte man sie mit, würde der Trichter unten breiter als oben und die
* Übergangsquoten wären wertlos.
*/
export function vertriebsTrichter(auftraege: Auftrag[]): ChartPunkt[] {
const rang = (status: AuftragStatus) => FUNNEL_STUFEN.indexOf(status);
const angebotsgeschaeft = auftraege.filter((a) => a.ueberAngebot);
return FUNNEL_STUFEN.map((stufe, index) => {
const anzahl = angebotsgeschaeft.filter((a) => {
// Verlorene Vorgänge zählen bis zu der Stufe, auf der sie abgesprungen sind.
if (a.status === "verloren") {
return a.verlorenAb ? rang(a.verlorenAb) >= index : false;
}
return rang(a.status) >= index;
}).length;
return { name: AUFTRAG_STATUS_LABEL[stufe], value: anzahl };
});
}
export function abschlussquote(auftraege: Auftrag[]): number {
// Bezugsgröße ist ausschließlich das Angebotsgeschäft. Wartungs- und
// Notdienstaufträge laufen ohne Angebot und würden die Quote schönrechnen.
const mitAngebot = auftraege.filter(
(a) =>
a.ueberAngebot &&
(["angebot", "beauftragt", "in_arbeit", "abgeschlossen"].includes(a.status) ||
(a.status === "verloren" && a.verlorenAb === "angebot"))
);
if (!mitAngebot.length) return 0;
const gewonnen = mitAngebot.filter((a) =>
["beauftragt", "in_arbeit", "abgeschlossen"].includes(a.status)
).length;
return (gewonnen / mitAngebot.length) * 100;
}
/** Ø Durchlaufzeit abgeschlossener Aufträge in Tagen. */
export function durchlaufzeit(auftraege: Auftrag[]): number {
const fertig = auftraege.filter((a) => a.status === "abgeschlossen" && a.abgeschlossenAm);
if (!fertig.length) return 0;
const summe = fertig.reduce(
(s, a) =>
s + (new Date(a.abgeschlossenAm!).getTime() - new Date(a.angelegtAm).getTime()) / 86_400_000,
0
);
return summe / fertig.length;
}
export function auftragseingang(auftraege: Auftrag[], zeitraum: ZeitraumPreset): ZeitreihePunkt[] {
const { art, schluessel } = bucketsFuer(zeitraum);
const zaehler = new Map<string, number>();
for (const a of auftraege) {
const key = bucketKey(a.angelegtAm, art);
zaehler.set(key, (zaehler.get(key) ?? 0) + 1);
}
return schluessel.map((key) => ({
datum: key,
label: bucketLabel(key, art),
value: zaehler.get(key) ?? 0,
}));
}
/* -------------------------------------------------------------- Kunden */
export function neueKunden(kunden: Kunde[], zeitraum: ZeitraumPreset): number {
const fenster = zeitfenster(zeitraum);
return kunden.filter((k) => imFenster(k.angelegtAm, fenster)).length;
}
/** Gleiches Fenster davor — Basis fuer den Trend an der Neukunden-Karte. */
export function neueKundenVorperiode(kunden: Kunde[], zeitraum: ZeitraumPreset): number {
const fenster = vorperiode(zeitraum);
return kunden.filter((k) => imFenster(k.angelegtAm, fenster)).length;
}
export function kundenEntwicklung(kunden: Kunde[], zeitraum: ZeitraumPreset): ZeitreihePunkt[] {
const { art, schluessel } = bucketsFuer(zeitraum);
const zaehler = new Map<string, number>();
for (const k of kunden) {
const key = bucketKey(k.angelegtAm, art);
zaehler.set(key, (zaehler.get(key) ?? 0) + 1);
}
return schluessel.map((key) => ({
datum: key,
label: bucketLabel(key, art),
value: zaehler.get(key) ?? 0,
}));
}
export function kundenNach(kunden: Kunde[], feld: "quelle" | "typ" | "region"): ChartPunkt[] {
const zaehler = new Map<string, number>();
for (const k of kunden) zaehler.set(k[feld], (zaehler.get(k[feld]) ?? 0) + 1);
return [...zaehler.entries()]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}
export function topKunden(kunden: Kunde[], anzahl = 5): ChartPunkt[] {
return [...kunden]
.filter((k) => k.umsatzGesamt > 0)
.sort((a, b) => b.umsatzGesamt - a.umsatzGesamt)
.slice(0, anzahl)
.map((k) => ({ name: k.name, value: k.umsatzGesamt }));
}
/** Anteil Kunden mit mehr als einem Auftrag. */
export function wiederkehrquote(kunden: Kunde[]): number {
const mitAuftrag = kunden.filter((k) => k.auftraegeGesamt > 0);
if (!mitAuftrag.length) return 0;
return (mitAuftrag.filter((k) => k.auftraegeGesamt > 1).length / mitAuftrag.length) * 100;
}
/** Kunden, die seit über 12 Monaten keinen Auftrag hatten. */
export function inaktiveKunden(kunden: Kunde[]): Kunde[] {
const grenze = addMonate(HEUTE, -12);
return kunden
.filter((k) => k.auftraegeGesamt > 0 && k.letzterAuftragAm && new Date(k.letzterAuftragAm) < grenze)
.sort((a, b) => b.umsatzGesamt - a.umsatzGesamt);
}
/** Kunden mit auffälliger Zahlungsmoral (> 30 Tage im Schnitt). */
export function zahlungsauffaellig(kunden: Kunde[]): Kunde[] {
return kunden
.filter((k) => k.zahlungsmoralTage > 30 && k.auftraegeGesamt > 0)
.sort((a, b) => b.zahlungsmoralTage - a.zahlungsmoralTage);
}
/* ---------------------------------------------------------------- Team */
export interface MitarbeiterKennzahl {
mitarbeiter: Mitarbeiter;
stunden: number;
produktivStunden: number;
auslastungProzent: number;
abgeschlossen: number;
umsatz: number;
}
/**
* Auslastung = erfasste Arbeitsstunden / verfügbare Stunden im Zeitraum.
* Verfügbare Stunden werden aus den Wochenstunden hochgerechnet.
*/
export function teamKennzahlen(
mitarbeiter: Mitarbeiter[],
zeiten: Zeiteintrag[],
auftraege: Auftrag[],
zeitraum: ZeitraumPreset
): MitarbeiterKennzahl[] {
const fenster = zeitfenster(zeitraum);
const tageImFenster = Math.max(
1,
Math.round((fenster.bis.getTime() - fenster.von.getTime()) / 86_400_000)
);
const wochen = Math.max(1, tageImFenster / 7);
return mitarbeiter
.filter((m) => m.rolle !== "Büro")
.map((m) => {
const eigene = zeiten.filter((z) => z.mitarbeiterId === m.id && imFenster(z.datum, fenster));
const stunden = eigene.reduce((s, z) => s + z.stunden, 0);
const produktiv = eigene
.filter((z) => z.art === "arbeit" && z.auftragId)
.reduce((s, z) => s + z.stunden, 0);
const verfuegbar = m.wochenstunden * wochen;
const eigeneAuftraege = auftraege.filter((a) => a.mitarbeiterId === m.id);
const abgeschlossen = eigeneAuftraege.filter(
(a) => a.status === "abgeschlossen" && imFenster(a.abgeschlossenAm, fenster)
);
return {
mitarbeiter: m,
stunden,
produktivStunden: produktiv,
auslastungProzent: verfuegbar > 0 ? Math.min(135, (produktiv / verfuegbar) * 100) : 0,
abgeschlossen: abgeschlossen.length,
umsatz: abgeschlossen.reduce((s, a) => s + a.wert, 0),
};
})
.sort((a, b) => b.auslastungProzent - a.auslastungProzent);
}
/* -------------------------------------------------------------- Alerts */
export interface AlertEintrag {
id: string;
text: string;
detail: string;
art: "kritisch" | "warnung" | "hinweis";
/** Betrag, um den es geht — macht die Zeilen untereinander vergleichbar. */
betrag?: number;
/**
* Die Handlung, nicht die Beobachtung. Eine Liste ohne Verb ist ein Bericht;
* "Was ist zu tun" braucht ein Verb je Zeile.
*/
aktion: string;
}
/** Handlungsbedarf für die Übersichtsseite. */
export function handlungsbedarf(daten: Datensatz): AlertEintrag[] {
const eintraege: AlertEintrag[] = [];
const posten = offenePosten(daten.rechnungen);
const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege);
const kritischAlt = posten.nachAlter.find((b) => b.name === "> 90 Tage");
if (posten.ueberfaelligAnzahl > 0) {
eintraege.push({
id: "rechnungen-ueberfaellig",
text: `${posten.ueberfaelligAnzahl} überfällige Rechnungen`,
detail: "Zahlungsziel überschritten",
betrag: posten.ueberfaelligBetrag,
aktion: "Mahnlauf starten",
art: "kritisch",
});
}
if (kritischAlt && kritischAlt.value > 0) {
eintraege.push({
id: "rechnungen-90",
text: "Forderungen älter als 90 Tage",
detail: "Einbringungsquote sinkt deutlich",
betrag: kritischAlt.value,
aktion: "Inkasso prüfen",
art: "kritisch",
});
}
if (ueberfaellig.length > 0) {
eintraege.push({
id: "auftraege-ueberfaellig",
text: `${ueberfaellig.length} Aufträge über Termin`,
detail: `Ältester seit ${tageSeit(ueberfaellig[0].faelligAm, HEUTE)} Tagen fällig`,
betrag: ueberfaellig.reduce((s, a) => s + a.wert, 0),
aktion: "Termine neu planen",
art: "warnung",
});
}
const offeneAngebote = daten.auftraege.filter(
(a) => a.status === "angebot" && tageSeit(a.angelegtAm, HEUTE) > 21
);
if (offeneAngebote.length > 0) {
eintraege.push({
id: "angebote-alt",
text: `${offeneAngebote.length} Angebote ohne Rückmeldung`,
detail: "Seit über 21 Tagen offen",
betrag: offeneAngebote.reduce((s, a) => s + a.wert, 0),
aktion: "Nachfassen",
art: "warnung",
});
}
const foerderOffen = daten.auftraege.filter((a) => a.foerderung === "beantragt");
if (foerderOffen.length > 0) {
eintraege.push({
id: "foerderung",
text: `${foerderOffen.length} Förderanträge in Bearbeitung`,
detail: "Bescheid ausstehend",
betrag: foerderOffen.reduce((s, a) => s + a.foerderbetrag, 0),
aktion: "Status prüfen",
art: "hinweis",
});
}
return eintraege;
}
/* --------------------------------------------------------- Förderung */
/**
* Förderquote BAFA/KfW.
*
* Bezugsgröße sind ausschließlich förderfähige Leistungen (Wärmepumpe, PV,
* Speicher, Lüftung). Über alle Aufträge gerechnet läge die Quote im
* einstelligen Bereich und wäre wertlos — eine Badwartung war nie förderfähig.
*/
export function foerderQuote(auftraege: Auftrag[]): {
quote: number;
foerderfaehig: number;
bewilligt: number;
beantragt: number;
summe: number;
verteilung: ChartPunkt[];
} {
const foerderfaehig = auftraege.filter((a) => FOERDERFAEHIGE_KATEGORIEN.has(a.kategorie));
const bewilligt = foerderfaehig.filter((a) => a.foerderung === "bewilligt");
const beantragt = foerderfaehig.filter((a) => a.foerderung === "beantragt");
const mitFoerderung = bewilligt.length + beantragt.length;
return {
quote: foerderfaehig.length ? (mitFoerderung / foerderfaehig.length) * 100 : 0,
foerderfaehig: foerderfaehig.length,
bewilligt: bewilligt.length,
beantragt: beantragt.length,
summe: bewilligt.reduce((s, a) => s + a.foerderbetrag, 0),
verteilung: [
{ name: "Bewilligt", value: bewilligt.length },
{ name: "Beantragt", value: beantragt.length },
{ name: "Nicht beantragt", value: foerderfaehig.length - mitFoerderung },
],
};
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Filterlogik.
*
* Zwei Dinge passieren hier:
* 1. `wendeFilterAn` schneidet den Datensatz auf die aktive Auswahl zu.
* 2. `relevanteFilter` steuert, welche Filter auf welcher Seite überhaupt
* angeboten werden (Page-aware Relevanz aus der Planung, FILT-05).
* Ein Auftragsstatus-Filter auf der Finanzseite verwirrt mehr, als er hilft.
*/
import type { Datensatz, FilterState } from "./types";
import { imFenster, zeitfenster } from "../utils/dateUtils";
export type SeitenId = "uebersicht" | "finanzen" | "auftraege" | "kunden" | "team";
export type FilterFeld = "zeitraum" | "gewerke" | "auftragStatus" | "kundenQuellen" | "regionen" | "mitarbeiterIds";
/** Welche Filter sind auf welcher Seite wirksam? */
export const FILTER_RELEVANZ: Record<SeitenId, FilterFeld[]> = {
uebersicht: ["zeitraum", "gewerke", "regionen"],
finanzen: ["zeitraum", "gewerke", "kundenQuellen", "regionen"],
auftraege: ["zeitraum", "gewerke", "auftragStatus", "mitarbeiterIds", "regionen"],
kunden: ["zeitraum", "gewerke", "kundenQuellen", "regionen"],
team: ["zeitraum", "gewerke", "mitarbeiterIds"],
};
/**
* Setzt alle für die Seite irrelevanten Filter zurück, ohne sie im
* globalen State zu löschen — wechselt man die Seite, sind sie wieder da.
*/
export function wirksameFilter(filter: FilterState, seite: SeitenId): FilterState {
const erlaubt = new Set(FILTER_RELEVANZ[seite]);
return {
zeitraum: erlaubt.has("zeitraum") ? filter.zeitraum : "alles",
gewerke: erlaubt.has("gewerke") ? filter.gewerke : [],
auftragStatus: erlaubt.has("auftragStatus") ? filter.auftragStatus : [],
kundenQuellen: erlaubt.has("kundenQuellen") ? filter.kundenQuellen : [],
regionen: erlaubt.has("regionen") ? filter.regionen : [],
mitarbeiterIds: erlaubt.has("mitarbeiterIds") ? filter.mitarbeiterIds : [],
};
}
/**
* Wendet die Dimensionsfilter an — bewusst OHNE Zeitfilter.
*
* Grund: Kennzahlen brauchen unterschiedliche Zeitbezüge (Umsatz im Zeitraum,
* offene Rechnungen dagegen stichtagsbezogen). Der Zeitraum wird deshalb
* gezielt in den einzelnen Berechnungen angewendet, nicht pauschal vorab.
*/
export function wendeFilterAn(daten: Datensatz, filter: FilterState): Datensatz {
const kunden = daten.kunden.filter((k) => {
if (filter.kundenQuellen.length && !filter.kundenQuellen.includes(k.quelle)) return false;
if (filter.regionen.length && !filter.regionen.includes(k.region)) return false;
return true;
});
const kundenIds = new Set(kunden.map((k) => k.id));
const mitarbeiter = daten.mitarbeiter.filter((m) => {
if (filter.mitarbeiterIds.length && !filter.mitarbeiterIds.includes(m.id)) return false;
if (filter.gewerke.length && !filter.gewerke.includes(m.gewerk)) return false;
return true;
});
const mitarbeiterIds = new Set(mitarbeiter.map((m) => m.id));
const auftraege = daten.auftraege.filter((a) => {
if (!kundenIds.has(a.kundeId)) return false;
if (filter.gewerke.length && !filter.gewerke.includes(a.gewerk)) return false;
if (filter.auftragStatus.length && !filter.auftragStatus.includes(a.status)) return false;
if (filter.mitarbeiterIds.length && !mitarbeiterIds.has(a.mitarbeiterId)) return false;
return true;
});
const auftragIds = new Set(auftraege.map((a) => a.id));
const rechnungen = daten.rechnungen.filter((r) => auftragIds.has(r.auftragId));
const zeiten = daten.zeiten.filter((z) => mitarbeiterIds.has(z.mitarbeiterId));
return { mitarbeiter, kunden, auftraege, rechnungen, zeiten };
}
/** Anzahl aktiver Einschränkungen — für das Badge am Filter-Button. */
export function anzahlAktiverFilter(filter: FilterState, seite: SeitenId): number {
const wirksam = wirksameFilter(filter, seite);
return (
wirksam.gewerke.length +
wirksam.auftragStatus.length +
wirksam.kundenQuellen.length +
wirksam.regionen.length +
wirksam.mitarbeiterIds.length
);
}
/** Hilfsfunktion: liegt ein Datum im aktiven Zeitraum? */
export function imZeitraum(iso: string | null, filter: FilterState): boolean {
return imFenster(iso, zeitfenster(filter.zeitraum));
}
+629
View File
@@ -0,0 +1,629 @@
/**
* Deterministischer Dummy-Datensatz für einen SHK-/Handwerksbetrieb.
*
* Bewusst deterministisch (fester Seed): Die Zahlen bleiben über Reloads,
* Screenshots und Abstimmungsrunden identisch. Für die echte Software wird
* diese Datei durch Entity-Builder-Queries ersetzt — das Interface `Datensatz`
* bleibt gleich.
*
* Betriebsprofil (fiktiv):
* Meister Krause GmbH · Heizung · Sanitär · Klima · PV · Elektro
* 24 Beschäftigte, ca. 3,4 Mio. € Jahresumsatz, Raum Rhein-Main
*/
import {
AUFTRAG_STATUS_LABEL,
GEWERKE,
FOERDERFAEHIGE_KATEGORIEN,
KATEGORIEN,
type Auftrag,
type AuftragStatus,
type Datensatz,
type Gewerk,
type Kunde,
type KundenQuelle,
type KundenTyp,
type Mitarbeiter,
type MitarbeiterRolle,
type Rechnung,
type Zeiteintrag,
} from "./types";
import { HEUTE, addTage, toISO } from "../utils/dateUtils";
/* ------------------------------------------------------ Zufall mit Seed */
/** mulberry32 — kleiner, schneller PRNG mit reproduzierbarer Sequenz. */
function prng(seed: number) {
let a = seed;
return function random(): number {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = prng(20260730);
const zahl = (min: number, max: number) => Math.floor(rnd() * (max - min + 1)) + min;
const kommazahl = (min: number, max: number) => rnd() * (max - min) + min;
const aus = <T,>(liste: readonly T[]): T => liste[Math.floor(rnd() * liste.length)];
function gewichtet<T>(optionen: { wert: T; gewicht: number }[]): T {
const gesamt = optionen.reduce((s, o) => s + o.gewicht, 0);
let r = rnd() * gesamt;
for (const o of optionen) {
r -= o.gewicht;
if (r <= 0) return o.wert;
}
return optionen[optionen.length - 1].wert;
}
/** Zufälliges Datum zwischen zwei Zeitpunkten. */
function datumZwischen(von: Date, bis: Date): Date {
return new Date(von.getTime() + rnd() * (bis.getTime() - von.getTime()));
}
/* ---------------------------------------------------------- Mitarbeiter */
const VORNAMEN = [
"Thomas", "Michael", "Andreas", "Stefan", "Christian", "Markus", "Peter", "Martin",
"Daniel", "Kevin", "Sebastian", "Tobias", "Jens", "Sandra", "Maike", "Nicole",
"Julia", "Patrick", "Dennis", "Marco", "Sven", "Lukas", "Fabian", "Mehmet",
];
const NACHNAMEN = [
"Krause", "Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner",
"Becker", "Schulz", "Hoffmann", "Schäfer", "Koch", "Richter", "Klein", "Wolf",
"Neumann", "Zimmermann", "Braun", "Hartmann", "Lange", "Werner", "Yilmaz", "Peters",
];
function kuerzelAus(name: string): string {
const [v, n] = name.split(" ");
return `${v[0]}${n[0]}`.toUpperCase();
}
function baueMitarbeiter(): Mitarbeiter[] {
const rollen: MitarbeiterRolle[] = [
"Meister", "Meister", "Meister",
"Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur",
"Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur",
"Auszubildender", "Auszubildender", "Auszubildender",
"Büro", "Büro", "Büro", "Büro",
];
const benutzt = new Set<string>();
return rollen.map((rolle, i) => {
let name = `${VORNAMEN[i % VORNAMEN.length]} ${NACHNAMEN[(i * 7) % NACHNAMEN.length]}`;
while (benutzt.has(name)) name = `${aus(VORNAMEN)} ${aus(NACHNAMEN)}`;
benutzt.add(name);
return {
id: `ma-${i + 1}`,
name,
kuerzel: kuerzelAus(name),
rolle,
gewerk: GEWERKE[i % GEWERKE.length],
// Zwei Langzeitabwesende, damit "aktiv" vs. "gesamt" sichtbar wird.
status: i === 12 || i === 19 ? "inaktiv" : "aktiv",
wochenstunden: rolle === "Auszubildender" ? 35 : rolle === "Büro" ? 38 : 40,
eintrittsdatum: toISO(addTage(HEUTE, -zahl(90, 4200))),
} satisfies Mitarbeiter;
});
}
/* ---------------------------------------------------------------- Kunden */
const ORTE: { ort: string; plz: string; region: string }[] = [
{ ort: "Wiesbaden", plz: "65189", region: "Hessen" },
{ ort: "Mainz", plz: "55116", region: "Rheinland-Pfalz" },
{ ort: "Frankfurt am Main", plz: "60313", region: "Hessen" },
{ ort: "Rüsselsheim", plz: "65428", region: "Hessen" },
{ ort: "Hofheim am Taunus", plz: "65719", region: "Hessen" },
{ ort: "Bad Homburg", plz: "61348", region: "Hessen" },
{ ort: "Darmstadt", plz: "64283", region: "Hessen" },
{ ort: "Ingelheim", plz: "55218", region: "Rheinland-Pfalz" },
{ ort: "Bingen", plz: "55411", region: "Rheinland-Pfalz" },
{ ort: "Aschaffenburg", plz: "63739", region: "Bayern" },
];
const GEWERBE_NAMEN = [
"Autohaus Reinhardt", "Bäckerei Lindner", "Hotel Rheinblick", "Praxis Dr. Alber",
"Logistik Weber KG", "Metallbau Süd GmbH", "Kanzlei Hoffmann & Partner",
"Sporthalle Taunusstein", "Kita Sonnenschein", "Getränke Hauser",
"Elektro Nord GmbH", "Weingut Steinberg", "Fahrschule Brandt", "Pflegeheim St. Anna",
];
const VERWALTUNG_NAMEN = [
"Rhein-Main Immobilien GmbH", "Hausverwaltung Kessler", "WEG Parkstraße 12",
"Wohnbau Wiesbaden eG", "Immobilien Service Taunus",
];
const QUELLEN: { wert: KundenQuelle; gewicht: number }[] = [
{ wert: "Empfehlung", gewicht: 30 },
{ wert: "Bestandskunde", gewicht: 24 },
{ wert: "Google", gewicht: 18 },
{ wert: "Website", gewicht: 14 },
{ wert: "Partnerbetrieb", gewicht: 8 },
{ wert: "Werbung", gewicht: 6 },
];
function baueKunden(anzahl: number): Kunde[] {
const kunden: Kunde[] = [];
for (let i = 0; i < anzahl; i++) {
const typ = gewichtet<KundenTyp>([
{ wert: "Privat", gewicht: 68 },
{ wert: "Gewerbe", gewicht: 22 },
{ wert: "Hausverwaltung", gewicht: 10 },
]);
const ort = aus(ORTE);
const name =
typ === "Privat"
? `Familie ${NACHNAMEN[(i * 5) % NACHNAMEN.length]}`
: typ === "Gewerbe"
? GEWERBE_NAMEN[i % GEWERBE_NAMEN.length]
: VERWALTUNG_NAMEN[i % VERWALTUNG_NAMEN.length];
kunden.push({
id: `kd-${i + 1}`,
name: typ === "Privat" ? `${name} · ${ort.ort}` : name,
typ,
quelle: gewichtet(QUELLEN),
region: ort.region,
plz: ort.plz,
ort: ort.ort,
angelegtAm: toISO(datumZwischen(addTage(HEUTE, -900), addTage(HEUTE, -2))),
letzterAuftragAm: null,
umsatzGesamt: 0,
auftraegeGesamt: 0,
// Die meisten zahlen nahe am Zahlungsziel; ein kleiner Teil schleppt.
// Gleichverteilt wäre jeder zweite Kunde auffällig — das wäre kein Betrieb,
// sondern ein Inkassofall.
zahlungsmoralTage: gewichtet([
{ wert: zahl(5, 14), gewicht: 42 },
{ wert: zahl(15, 24), gewicht: 33 },
{ wert: zahl(25, 34), gewicht: 15 },
{ wert: zahl(35, 52), gewicht: 7 },
{ wert: zahl(53, 78), gewicht: 3 },
]),
});
}
return kunden;
}
/* -------------------------------------------------------------- Aufträge */
/** Realistische Auftragswerte je Leistungsart (netto, in €). */
const WERT_SPANNEN: Record<string, [number, number]> = {
"Wärmepumpe": [17_500, 34_000],
"Gastherme": [5_800, 12_400],
"Heizkörper": [1_200, 6_500],
"Wartung Heizung": [150, 420],
"Bad-Sanierung": [9_500, 27_000],
"Rohrleitungen": [900, 7_800],
"Armaturen": [250, 2_400],
"Notdienst": [180, 950],
"Splitgerät": [2_400, 8_900],
"Lüftungsanlage": [6_500, 21_000],
"Kanalreinigung": [420, 1_800],
"Wartung Klima": [180, 480],
"PV-Anlage": [11_500, 29_500],
"Batteriespeicher": [6_800, 14_500],
"Wallbox": [1_150, 3_400],
"Wartung PV": [190, 460],
"Elektroinstallation": [1_800, 12_000],
"Zählerschrank": [1_400, 4_200],
"Smart Home": [2_200, 9_800],
"E-Check": [140, 390],
};
/** Bezugsgröße der Förderquote — zentral in types.ts definiert. */
const FOERDERFAEHIG = FOERDERFAEHIGE_KATEGORIEN;
/**
* Ergebnis eines Vorgangs, abhängig von seinem Alter.
*
* Die Verteilung bildet einen realistischen Trichter ab:
* von 100 Anfragen werden rund 55 zum Angebot und rund 24 zum Auftrag.
* Verlorene Vorgänge merken sich, auf welcher Stufe sie abgesprungen sind —
* sonst zeigt der Trichter oben zu wenig und unten zu viel.
*/
function ergebnisFuerAlter(
tageAlt: number,
/** Große Vorhaben werden angeboten und oft verglichen — kleine kaum. */
grossauftrag: boolean
): {
status: AuftragStatus;
verlorenAb: AuftragStatus | null;
} {
type Ergebnis = { status: AuftragStatus; verlorenAb: AuftragStatus | null };
const verloren = (stufe: AuftragStatus): Ergebnis => ({ status: "verloren", verlorenAb: stufe });
const offen = (stufe: AuftragStatus): Ergebnis => ({ status: stufe, verlorenAb: null });
/**
* Verlustneigung: Wartungs- und Notdienstaufträge werden praktisch immer
* ausgeführt, große Anlagen dagegen ausgeschrieben und verglichen.
* Ein pauschaler Wert über alle Auftragsarten würde die Angebotsquote
* unrealistisch schönrechnen.
*/
const verlustFaktor = grossauftrag ? 2.1 : 0.28;
const v = (gewicht: number) => gewicht * verlustFaktor;
// Frisch: steckt noch vorne im Trichter.
if (tageAlt < 12) {
return gewichtet([
{ wert: offen("anfrage"), gewicht: 40 },
{ wert: offen("aufmass"), gewicht: 24 },
{ wert: offen("angebot"), gewicht: 20 },
{ wert: offen("beauftragt"), gewicht: 9 },
{ wert: verloren("anfrage"), gewicht: v(7) },
]);
}
// In Bearbeitung: Entscheidungen fallen.
if (tageAlt < 45) {
return gewichtet([
{ wert: offen("aufmass"), gewicht: 7 },
{ wert: offen("angebot"), gewicht: 20 },
{ wert: offen("beauftragt"), gewicht: 13 },
{ wert: offen("in_arbeit"), gewicht: 17 },
{ wert: offen("abgeschlossen"), gewicht: 8 },
{ wert: verloren("anfrage"), gewicht: v(9) },
{ wert: verloren("aufmass"), gewicht: v(7) },
{ wert: verloren("angebot"), gewicht: v(34) },
]);
}
// Älter als ein Quartal: fast alles ist entschieden.
if (tageAlt < 120) {
return gewichtet([
{ wert: offen("in_arbeit"), gewicht: 9 },
{ wert: offen("abgeschlossen"), gewicht: 50 },
{ wert: verloren("anfrage"), gewicht: v(8) },
{ wert: verloren("aufmass"), gewicht: v(7) },
{ wert: verloren("angebot"), gewicht: v(46) },
]);
}
// Über vier Monate alt: entweder fertig oder verloren. Ein Vorgang, der
// seit zwei Jahren "in Arbeit" ist, wäre ein Datenfehler, kein Auftrag.
return gewichtet([
{ wert: offen("abgeschlossen"), gewicht: 60 },
{ wert: verloren("anfrage"), gewicht: v(9) },
{ wert: verloren("aufmass"), gewicht: v(8) },
{ wert: verloren("angebot"), gewicht: v(54) },
]);
}
/**
* Leistungsart innerhalb eines Gewerks.
* Wartung, Notdienst und E-Check sind im Alltag die häufigsten Aufträge —
* gleichverteilt zu ziehen würde den Durchschnittswert massiv verzerren.
*/
function kategorieFuer(gewerk: Gewerk): string {
const liste = KATEGORIEN[gewerk];
const kleinteilig = liste.filter((k) => /Wartung|Notdienst|E-Check|Armaturen/.test(k));
const gross = liste.filter((k) => !kleinteilig.includes(k));
if (!kleinteilig.length) return aus(liste);
return rnd() < 0.42 ? aus(kleinteilig) : aus(gross);
}
/**
* Verteilt Auftragsdaten über die letzten 24 Monate.
*
* Zwei Effekte sind absichtlich eingebaut, weil ein gleichverteilter
* Datensatz in jedem Zeitreihen-Chart unnatürlich flach aussieht:
* - moderates Wachstum von rund 12 % gegenüber dem Vorjahr
* - Saisonalität: Heizung, Wärmepumpe und Lüftung ziehen von September
* bis Februar an, der Hochsommer ist schwächer
* Umgesetzt über Verwerfungsmethode gegen eine Dichtefunktion.
*/
const SAISON_INDEX = [
// Jan Feb Mär Apr Mai Jun Jul Aug Sep Okt Nov Dez
1.12, 1.06, 1.0, 0.94, 0.88, 0.84, 0.82, 0.9, 1.1, 1.22, 1.18, 0.98,
];
function ziehAuftragsalter(): number {
const maxDichte = 1.12 * 1.22;
for (let versuch = 0; versuch < 40; versuch++) {
const tage = zahl(0, 719);
const datum = addTage(HEUTE, -tage);
const wachstum = 1 + 0.12 * (1 - tage / 720);
const dichte = wachstum * SAISON_INDEX[datum.getMonth()];
if (rnd() <= dichte / maxDichte) return tage;
}
return zahl(0, 719);
}
function baueAuftraege(kunden: Kunde[], mitarbeiter: Mitarbeiter[], anzahl: number): Auftrag[] {
const ausfuehrende = mitarbeiter.filter((m) => m.rolle === "Monteur" || m.rolle === "Meister");
const auftraege: Auftrag[] = [];
for (let i = 0; i < anzahl; i++) {
const tageAlt = ziehAuftragsalter();
const angelegt = addTage(HEUTE, -tageAlt);
const gewerk = gewichtet<Gewerk>([
{ wert: "Heizung", gewicht: 34 },
{ wert: "Sanitär", gewicht: 24 },
{ wert: "Photovoltaik", gewicht: 20 },
{ wert: "Klima & Lüftung", gewicht: 12 },
{ wert: "Elektro", gewicht: 10 },
]);
const kategorie = kategorieFuer(gewerk);
const [minWert, maxWert] = WERT_SPANNEN[kategorie] ?? [500, 5_000];
const wert = Math.round(kommazahl(minWert, maxWert) / 10) * 10;
// Ab etwa 6.000 € wird im Handwerk regelmäßig schriftlich angeboten und
// verglichen; darunter läuft der Auftrag meist direkt.
const ueberAngebot = wert >= 6_000;
const { status, verlorenAb } = ergebnisFuerAlter(tageAlt, ueberAngebot);
const kunde = aus(kunden);
const mitarbeiterId = aus(ausfuehrende).id;
const laufzeitTage = wert > 12_000 ? zahl(21, 75) : zahl(3, 28);
const startAm =
status === "in_arbeit" || status === "abgeschlossen"
? toISO(addTage(angelegt, zahl(5, 30)))
: null;
const abgeschlossenAm =
status === "abgeschlossen" ? toISO(addTage(angelegt, laufzeitTage + zahl(0, 20))) : null;
const foerderfaehig = FOERDERFAEHIG.has(kategorie);
// Ein Förderbescheid dauert Wochen, nicht Jahre: Nur bei jungen Vorgängen
// ist "beantragt" plausibel, ältere sind längst entschieden.
const foerderung: Auftrag["foerderung"] = !foerderfaehig
? "keine"
: tageAlt <= 100
? gewichtet([
{ wert: "beantragt" as const, gewicht: 46 },
{ wert: "bewilligt" as const, gewicht: 39 },
{ wert: "keine" as const, gewicht: 15 },
])
: gewichtet([
{ wert: "bewilligt" as const, gewicht: 84 },
{ wert: "keine" as const, gewicht: 16 },
]);
auftraege.push({
id: `au-${i + 1}`,
nummer: `A-2026-${String(1000 + i).slice(-4)}`,
kundeId: kunde.id,
mitarbeiterId,
gewerk,
kategorie,
titel: `${kategorie} · ${kunde.ort}`,
status,
prioritaet: gewichtet([
{ wert: "normal" as const, gewicht: 62 },
{ wert: "hoch" as const, gewicht: 22 },
{ wert: "niedrig" as const, gewicht: 16 },
]),
wert,
margeProzent: Math.round(kommazahl(16, 38) * 10) / 10,
angelegtAm: toISO(angelegt),
startAm,
abgeschlossenAm,
faelligAm: toISO(addTage(angelegt, laufzeitTage)),
foerderung,
foerderbetrag:
foerderung === "keine" ? 0 : Math.round((wert * kommazahl(0.2, 0.4)) / 50) * 50,
verlorenAb,
ueberAngebot,
});
}
return auftraege.sort((a, b) => (a.angelegtAm < b.angelegtAm ? 1 : -1));
}
/* ------------------------------------------------------------ Rechnungen */
function baueRechnungen(auftraege: Auftrag[]): Rechnung[] {
const rechnungen: Rechnung[] = [];
let nr = 0;
for (const auftrag of auftraege) {
// Rechnung entsteht erst ab Beauftragung (Anzahlung) bzw. Abschluss.
if (!["beauftragt", "in_arbeit", "abgeschlossen"].includes(auftrag.status)) continue;
const positionen: { betrag: number; basis: string }[] = [];
if (auftrag.wert > 8_000 && auftrag.startAm) {
// Große Vorhaben: 30 % Anzahlung bei Auftragserteilung.
positionen.push({ betrag: Math.round(auftrag.wert * 0.3), basis: auftrag.startAm });
}
if (auftrag.status === "abgeschlossen" && auftrag.abgeschlossenAm) {
const bereits = positionen.reduce((s, p) => s + p.betrag, 0);
positionen.push({ betrag: auftrag.wert - bereits, basis: auftrag.abgeschlossenAm });
}
if (positionen.length === 0 && auftrag.status !== "beauftragt" && auftrag.startAm) {
positionen.push({ betrag: Math.round(auftrag.wert * 0.5), basis: auftrag.startAm });
}
for (const position of positionen) {
nr += 1;
const gestellt = new Date(position.basis);
const zahlungsziel = 21;
const faellig = addTage(gestellt, zahlungsziel);
const ueberfaelligTage = Math.floor((HEUTE.getTime() - faellig.getTime()) / 86_400_000);
let status: Rechnung["status"];
let bezahltAm: string | null = null;
if (gestellt > HEUTE) {
// Rechnung existiert noch nicht — z. B. Schlussrechnung eines laufenden Auftrags.
status = "entwurf";
} else if (ueberfaelligTage < 0) {
// Noch innerhalb des Zahlungsziels; ein Teil zahlt sofort.
status = rnd() < 0.08 ? "bezahlt" : "gestellt";
if (status === "bezahlt") bezahltAm = toISO(addTage(gestellt, zahl(2, zahlungsziel)));
} else if (ueberfaelligTage <= 18) {
// Kurz über dem Ziel: das ist Alltag, noch kein Mahnfall.
if (rnd() < 0.52) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-6, ueberfaelligTage)));
} else {
status = "gestellt";
}
} else if (ueberfaelligTage <= 60) {
if (rnd() < 0.84) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, Math.min(ueberfaelligTage, 34))));
} else {
status = "ueberfaellig";
}
} else if (ueberfaelligTage <= 120) {
// Mahnstufe erreicht — der Großteil zahlt spätestens jetzt.
if (rnd() < 0.94) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 70)));
} else {
status = "ueberfaellig";
}
} else if (ueberfaelligTage <= 210) {
// Letzte Mahnstufe: hier entscheidet sich Zahlung oder Ausfall.
if (rnd() < 0.992) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 120)));
} else {
status = "ueberfaellig";
}
} else {
// Älter als sieben Monate: eingebracht oder ausgebucht — in beiden
// Fällen steht die Forderung nicht mehr in der offenen Liste.
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 150)));
}
rechnungen.push({
id: `re-${nr}`,
nummer: `R-2026-${String(2000 + nr).slice(-4)}`,
auftragId: auftrag.id,
kundeId: auftrag.kundeId,
richtung: "ausgang",
betrag: position.betrag,
status,
gestelltAm: toISO(gestellt),
faelligAm: toISO(faellig),
bezahltAm,
});
}
}
return rechnungen;
}
/* ---------------------------------------------------------- Zeiterfassung */
function baueZeiten(mitarbeiter: Mitarbeiter[], auftraege: Auftrag[]): Zeiteintrag[] {
const zeiten: Zeiteintrag[] = [];
const laufend = auftraege.filter((a) => a.status === "in_arbeit" || a.status === "abgeschlossen");
const gewerblich = mitarbeiter.filter(
(m) => m.status === "aktiv" && m.rolle !== "Büro"
);
let nr = 0;
// Letzte 12 Wochen, Montag bis Freitag.
for (let tagOffset = 84; tagOffset >= 0; tagOffset--) {
const tag = addTage(HEUTE, -tagOffset);
const wochentag = tag.getDay();
if (wochentag === 0 || wochentag === 6) continue;
for (const person of gewerblich) {
const abwesend = rnd() < 0.09;
if (abwesend) {
nr += 1;
zeiten.push({
id: `ze-${nr}`,
mitarbeiterId: person.id,
auftragId: null,
datum: toISO(tag),
stunden: 8,
art: "abwesenheit",
abwesenheitsgrund: gewichtet([
{ wert: "urlaub" as const, gewicht: 55 },
{ wert: "krank" as const, gewicht: 33 },
{ wert: "fortbildung" as const, gewicht: 12 },
]),
});
continue;
}
nr += 1;
const auftrag = laufend.length ? aus(laufend) : null;
zeiten.push({
id: `ze-${nr}`,
mitarbeiterId: person.id,
auftragId: auftrag?.id ?? null,
datum: toISO(tag),
// Produktive Stunden schwanken; nicht jede Stunde ist verrechenbar.
stunden: Math.round(kommazahl(4.5, 9.5) * 2) / 2,
art: "arbeit",
});
}
}
return zeiten;
}
/* -------------------------------------------------------- Aggregation */
function ergaenzeKundenkennzahlen(
kunden: Kunde[],
auftraege: Auftrag[],
rechnungen: Rechnung[]
): Kunde[] {
const umsatzProKunde = new Map<string, number>();
for (const r of rechnungen) {
if (r.status !== "bezahlt") continue;
umsatzProKunde.set(r.kundeId, (umsatzProKunde.get(r.kundeId) ?? 0) + r.betrag);
}
const auftraegeProKunde = new Map<string, Auftrag[]>();
for (const a of auftraege) {
const liste = auftraegeProKunde.get(a.kundeId) ?? [];
liste.push(a);
auftraegeProKunde.set(a.kundeId, liste);
}
return kunden.map((kunde) => {
const eigene = auftraegeProKunde.get(kunde.id) ?? [];
const letzter = eigene
.map((a) => a.angelegtAm)
.sort()
.at(-1);
return {
...kunde,
umsatzGesamt: umsatzProKunde.get(kunde.id) ?? 0,
auftraegeGesamt: eigene.filter((a) => a.status !== "verloren").length,
letzterAuftragAm: letzter ?? null,
};
});
}
/* --------------------------------------------------------------- Export */
function baueDatensatz(): Datensatz {
const mitarbeiter = baueMitarbeiter();
const rohkunden = baueKunden(420);
const auftraege = baueAuftraege(rohkunden, mitarbeiter, 1900);
const rechnungen = baueRechnungen(auftraege);
const kunden = ergaenzeKundenkennzahlen(rohkunden, auftraege, rechnungen);
const zeiten = baueZeiten(mitarbeiter, auftraege);
return { mitarbeiter, kunden, auftraege, rechnungen, zeiten };
}
/** Einmal erzeugter, unveränderlicher Datensatz für die gesamte App. */
export const DATENSATZ: Datensatz = baueDatensatz();
/** Nur für Debug-Ausgaben / die Komponenten-Galerie. */
export const DATENSATZ_INFO = {
mitarbeiter: DATENSATZ.mitarbeiter.length,
kunden: DATENSATZ.kunden.length,
auftraege: DATENSATZ.auftraege.length,
rechnungen: DATENSATZ.rechnungen.length,
zeiteintraege: DATENSATZ.zeiten.length,
statusLabels: Object.values(AUFTRAG_STATUS_LABEL),
};
+249
View File
@@ -0,0 +1,249 @@
/**
* Datenmodell des Analytics-Prototyps.
*
* Die Felder sind bewusst nah an den HiveGrid-Entities (invoice, order,
* company, contact, timeEntry) gehalten, damit die Entwicklung die
* Dummy-Daten 1:1 gegen echte Entity-Builder-Daten tauschen kann.
* Siehe docs/DATENMODELL.md.
*/
/* ---------------------------------------------------------------- Gewerke */
export const GEWERKE = [
"Heizung",
"Sanitär",
"Klima & Lüftung",
"Photovoltaik",
"Elektro",
] as const;
export type Gewerk = (typeof GEWERKE)[number];
/** Leistungsarten je Gewerk — Grundlage für "Umsatz nach Kategorie". */
export const KATEGORIEN: Record<Gewerk, string[]> = {
Heizung: ["Wärmepumpe", "Gastherme", "Heizkörper", "Wartung Heizung"],
Sanitär: ["Bad-Sanierung", "Rohrleitungen", "Armaturen", "Notdienst"],
"Klima & Lüftung": ["Splitgerät", "Lüftungsanlage", "Kanalreinigung", "Wartung Klima"],
Photovoltaik: ["PV-Anlage", "Batteriespeicher", "Wallbox", "Wartung PV"],
Elektro: ["Elektroinstallation", "Zählerschrank", "Smart Home", "E-Check"],
};
/* -------------------------------------------------------------- Mitarbeiter */
export type MitarbeiterRolle = "Meister" | "Monteur" | "Auszubildender" | "Büro";
export interface Mitarbeiter {
id: string;
name: string;
kuerzel: string;
rolle: MitarbeiterRolle;
gewerk: Gewerk;
status: "aktiv" | "inaktiv";
/** Vertraglich verfügbare Stunden pro Woche. */
wochenstunden: number;
eintrittsdatum: string;
}
/* ------------------------------------------------------------------ Kunden */
export type KundenQuelle =
| "Empfehlung"
| "Google"
| "Website"
| "Werbung"
| "Bestandskunde"
| "Partnerbetrieb";
export type KundenTyp = "Privat" | "Gewerbe" | "Hausverwaltung";
export interface Kunde {
id: string;
name: string;
typ: KundenTyp;
quelle: KundenQuelle;
region: string;
plz: string;
ort: string;
angelegtAm: string;
letzterAuftragAm: string | null;
/** Aggregiert aus Rechnungen — wird in seed.ts nachberechnet. */
umsatzGesamt: number;
auftraegeGesamt: number;
zahlungsmoralTage: number;
}
/* ---------------------------------------------------------------- Aufträge */
export type AuftragStatus =
| "anfrage"
| "aufmass"
| "angebot"
| "beauftragt"
| "in_arbeit"
| "abgeschlossen"
| "verloren";
export const AUFTRAG_STATUS_LABEL: Record<AuftragStatus, string> = {
anfrage: "Anfrage",
aufmass: "Vor-Aufmaß",
angebot: "Angebot",
beauftragt: "Beauftragt",
in_arbeit: "In Arbeit",
abgeschlossen: "Abgeschlossen",
verloren: "Verloren",
};
/**
* Leistungsarten, für die BAFA-/KfW-Förderung in Frage kommt.
* Bezugsgröße der Förderquote — sonst rechnet man Wartungsaufträge
* in eine Quote hinein, die für sie gar nicht gilt.
*/
export const FOERDERFAEHIGE_KATEGORIEN = new Set([
"Wärmepumpe",
"PV-Anlage",
"Batteriespeicher",
"Lüftungsanlage",
]);
/** Reihenfolge im Vertriebstrichter (ohne Endzustände). */
export const FUNNEL_STUFEN: AuftragStatus[] = [
"anfrage",
"aufmass",
"angebot",
"beauftragt",
"in_arbeit",
"abgeschlossen",
];
export interface Auftrag {
id: string;
nummer: string;
kundeId: string;
mitarbeiterId: string;
gewerk: Gewerk;
kategorie: string;
titel: string;
status: AuftragStatus;
prioritaet: "niedrig" | "normal" | "hoch";
wert: number;
/** Geschätzter Deckungsbeitrag in Prozent. */
margeProzent: number;
angelegtAm: string;
startAm: string | null;
abgeschlossenAm: string | null;
faelligAm: string;
/** BAFA/KfW-Förderung beantragt? */
foerderung: "keine" | "beantragt" | "bewilligt";
foerderbetrag: number;
/**
* Bei verlorenen Vorgängen: die zuletzt erreichte Trichterstufe.
* Ohne dieses Feld müsste der Trichter raten, wo ein Vorgang abgesprungen
* ist — und würde die oberen Stufen systematisch zu klein zeigen.
*/
verlorenAb: AuftragStatus | null;
/**
* Lief der Vorgang über ein schriftliches Angebot?
* Wartung, Notdienst und E-Check werden direkt beauftragt. Zählt man sie
* in die Angebotsquote hinein, sieht der Vertrieb doppelt so gut aus,
* wie er ist.
*/
ueberAngebot: boolean;
}
/* --------------------------------------------------------------- Rechnungen */
export type RechnungStatus = "entwurf" | "gestellt" | "bezahlt" | "ueberfaellig";
export const RECHNUNG_STATUS_LABEL: Record<RechnungStatus, string> = {
entwurf: "Entwurf",
gestellt: "Gestellt",
bezahlt: "Bezahlt",
ueberfaellig: "Überfällig",
};
export interface Rechnung {
id: string;
nummer: string;
auftragId: string;
kundeId: string;
richtung: "ausgang" | "eingang";
betrag: number;
status: RechnungStatus;
gestelltAm: string;
faelligAm: string;
bezahltAm: string | null;
}
/* ------------------------------------------------------------ Zeiterfassung */
export interface Zeiteintrag {
id: string;
mitarbeiterId: string;
auftragId: string | null;
datum: string;
stunden: number;
art: "arbeit" | "abwesenheit";
abwesenheitsgrund?: "urlaub" | "krank" | "fortbildung";
}
/* ---------------------------------------------------------------- Datensatz */
export interface Datensatz {
mitarbeiter: Mitarbeiter[];
kunden: Kunde[];
auftraege: Auftrag[];
rechnungen: Rechnung[];
zeiten: Zeiteintrag[];
}
/* ------------------------------------------------------------------ Filter */
export type ZeitraumPreset =
| "heute"
| "7_tage"
| "30_tage"
| "quartal"
| "jahr"
| "alles";
export const ZEITRAUM_LABEL: Record<ZeitraumPreset, string> = {
heute: "Heute",
"7_tage": "7 Tage",
"30_tage": "30 Tage",
quartal: "Quartal",
jahr: "Jahr",
alles: "Gesamt",
};
export interface FilterState {
zeitraum: ZeitraumPreset;
gewerke: Gewerk[];
auftragStatus: AuftragStatus[];
kundenQuellen: KundenQuelle[];
regionen: string[];
mitarbeiterIds: string[];
}
export const LEERER_FILTER: FilterState = {
zeitraum: "30_tage",
gewerke: [],
auftragStatus: [],
kundenQuellen: [],
regionen: [],
mitarbeiterIds: [],
};
/* ------------------------------------------------------------- Chart-Typen */
export interface ChartPunkt {
name: string;
value: number;
/** Optionale Zweitreihe, z. B. Vorjahr. */
vergleich?: number;
}
export interface ZeitreihePunkt {
datum: string;
label: string;
value: number;
vergleich?: number;
}