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:
commit
b8bf987d0e
250 files changed
+33153
No files matched your search
@@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user