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

+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
out
build
.DS_Store
*.pem
npm-debug.log*
.env*
next-env.d.ts
tsconfig.tsbuildinfo
+119
View File
@@ -0,0 +1,119 @@
# Übergabe an die Entwicklung
Dieser Prototyp liefert **Gestaltung und Interaktion**, nicht die Anbindung.
Was echt ist, was Attrappe ist und was beim Einbau zu tun bleibt:
---
## 1. Was fertig ist und übernommen werden kann
| Bereich | Datei(en) | Zustand |
|---|---|---|
| Kennzahlen-Berechnung | `features/dashboard/data/calculations.ts` | **Reine Funktionen** über typisierten Eingangsdaten — direkt übernehmbar |
| Chart-Theme | `features/dashboard/utils/chartTheme.ts` | Carbon-Tokens, Palette, Achsen-Props, Margins |
| Formatierung | `features/dashboard/utils/formatters.ts` | Deutsche Zahlen-, Währungs- und Datumsformate |
| Chart-Komponenten | `features/dashboard/components/charts/` | Vollständig, mit Leerzuständen und Tooltips |
| Karten & Tabellen | `features/dashboard/components/cards/`, `tables/` | Vollständig |
| Filtersystem | `features/dashboard/context/FilterContext.tsx`, `data/filtering.ts` | State und Relevanzlogik fertig |
| Seitenaufbau | `features/dashboard/pages/` | Raster und Reihenfolge stehen |
---
## 2. Was echte Logik braucht
### 2.1 Datenquelle austauschen
Alles hängt an **einer** Stelle: `features/dashboard/data/seed.ts` exportiert
`DATENSATZ` vom Typ `Datensatz`. Diesen Export durch echte Daten ersetzen —
Loader, Server-Query oder Service, je nach Architektur. Alle Berechnungen und
Komponenten arbeiten unverändert weiter.
Feldzuordnung zu den HiveGrid-Entities steht in [`docs/DATENMODELL.md`](docs/DATENMODELL.md).
**Achtung Zeitbezug:** `features/dashboard/utils/dateUtils.ts` definiert
`HEUTE` als festen Stichtag (30.07.2026), damit der Prototyp reproduzierbar
bleibt. Beim Einbau durch `new Date()` ersetzen — die Konstante wird an
elf Stellen verwendet, alle über den Import.
### 2.2 Filter serverseitig anwenden
Aktuell wird komplett clientseitig gefiltert (`data/filtering.ts`). Bei echten
Datenmengen gehört das in die Query. Der State im `FilterContext` lässt sich
dafür in URL-Parameter serialisieren; die Relevanzlogik (`FILTER_RELEVANZ`)
bleibt clientseitig, weil sie nur die Anzeige steuert.
### 2.3 Aktionen ohne Wirkung
Diese Elemente lösen bewusst nur einen Hinweis-Toast aus:
| Element | Wo | Erwartetes Verhalten |
|---|---|---|
| Export (Seitenkopf) | `components/layout/PageHeader.tsx` | PDF-/CSV-Export der Ansicht |
| Teilen | `components/layout/PageHeader.tsx` | Link mit eingefrorenen Filtern |
| Aktualisieren | `components/layout/PageHeader.tsx` | Daten neu laden |
| „Als Bild exportieren" | `components/cards/ChartCard.tsx` | PNG-Export des Charts |
| Einträge in „Handlungsbedarf" | `components/cards/AlertList.tsx` | Absprung in die jeweilige Arbeitsliste — die Handlung je Zeile (`aktion`) beschreibt, was dort passieren soll |
| Tabellenzeilen | `components/tables/DataTable.tsx` | Absprung auf den Datensatz |
| Suche und Glocke (Kopfzeile) | `components/layout/AppShell.tsx` | Attrappe, gehört zur bestehenden Shell |
### 2.4 Was funktioniert und erhalten bleiben sollte
- **Zeitraum-Umschaltung** rechnet alle Kennzahlen und Charts neu, inklusive Trendvergleich zur Vorperiode
- **Mehrfachfilter** (Gewerk, Status, Quelle, Region, Mitarbeiter) mit entfernbaren Chips
- **Seitenabhängige Filterrelevanz** — irrelevante Filter werden gar nicht erst angeboten
- **Widgets ausblenden** über das ⋯-Menü jeder Karte, Wiedereinblenden über die Filterleiste, persistiert
- **Tabellen** sortieren, durchsuchen, blättern
- **Hover** auf Balken, Ringsegmenten, Trichterstufen und Verlaufspunkten
- **Legenden im Ring** heben beim Klick das jeweilige Segment hervor
- **Tastaturbedienung**: sichtbarer Fokus auf allen interaktiven Elementen, Escape schließt Dropdown und Karten-Menü
Persistiert wird in `localStorage` unter `hivegrid.analytics.*`. In der echten
Software gehört das an das Benutzerprofil.
---
## 3. Bewusste Entscheidungen
**Recharts statt der Subframe-Chart-Komponenten.** Die Subframe-Wrapper
(`ui/components/AreaChart` etc.) geben zu wenig Kontrolle über Achsen, Tooltips
und Ringproportionen, um die Carbon-Regeln umzusetzen. Die übrige Oberfläche
nutzt weiterhin die Subframe-Bibliothek und deren Design-Tokens.
**Deterministische Dummy-Daten.** Fester Seed statt `Math.random()`: Die Zahlen
bleiben über Reloads und Screenshots identisch, sonst ist keine Abstimmung über
konkrete Werte möglich.
**Team-Seite enthalten,** obwohl die Planung sie für v2 vorgesehen hatte.
Auslastung ist im Handwerk die Kennzahl, nach der disponiert wird — ohne sie
fehlt dem Dashboard der Bezug zum Tagesgeschäft.
**Kein Dark Mode.** Die Carbon-Palette ist hier im Light Theme hinterlegt.
Ein Dark Theme braucht die zweite Palettenvariante (in `@carbon/charts`
enthalten) und eigene Kontrastprüfung — bewusst nicht angefangen, statt es
halb zu machen.
---
## 4. Empfohlene Reihenfolge beim Einbau
1. `data/types.ts` gegen die echten Entity-Typen abgleichen
2. `data/seed.ts` durch die echte Datenquelle ersetzen
3. `HEUTE` in `utils/dateUtils.ts` auf `new Date()` umstellen
4. Eine Seite vollständig durchziehen (Übersicht) — dabei zeigt sich, welche Felder fehlen
5. Filter serverseitig nachziehen, sobald die Datenmengen es erfordern
6. Export, Absprünge und Persistenz am Benutzerprofil ergänzen
---
## 5. Offene Punkte für die Fachabstimmung
- **Auslastung** rechnet produktive Stunden gegen Vertragsstunden. Ob Rüst- und
Fahrzeiten als produktiv zählen, ist eine betriebliche Festlegung — die
Schwellen (70 % / 95 %) stehen in `components/charts/AuslastungsBalken.tsx`.
- **Umsatz** ist hier Zahlungseingang, nicht Rechnungsstellung. Für die
betriebswirtschaftliche Auswertung kann die andere Definition gewünscht sein;
beide Varianten sind aus denselben Daten berechenbar.
- **Deckungsbeitrag** basiert auf einer geschätzten Marge je Auftrag. Ob echte
Kostendaten vorliegen, ist offen.
- **Förderquote BAFA/KfW** ist als eigener Block angelegt, weil sie im
Wärmepumpengeschäft entscheidend ist. Sie braucht ein Feld am Auftrag.
+113
View File
@@ -0,0 +1,113 @@
# HiveGrid Analytics Dashboard — UI/UX-Prototyp
Gestaltungsvorlage für die Analytics-Dashboards in HiveGrid, fachlich auf einen
SHK-/Handwerksbetrieb zugeschnitten. **Ziel dieses Repos ist die Übergabe an die
Entwicklung** — nicht der Produktivbetrieb.
```bash
npm install
npm run dev # http://localhost:4300
```
---
## Was hier drin ist
| Bereich | Inhalt |
|---|---|
| **6 Seiten** | Übersicht, Finanzen, Aufträge, Kunden, Team, Komponenten |
| **26 KPIs** | vollständig definiert in [`docs/KPI-KATALOG.md`](docs/KPI-KATALOG.md) |
| **9 Chart-Bausteine** | Fläche, Säulen, liegende Balken, Ring, Trichter, Auslastung, Sparkline, Legende, Leerzustand |
| **Filtersystem** | Zeitraum + 5 Dimensionen, seitenabhängig relevant, in localStorage persistiert |
| **Demo-Datensatz** | deterministisch erzeugt: 420 Kunden, 1.900 Aufträge, ~1.260 Rechnungen, 2,4 Mio. € Jahresumsatz |
Alles ist deutschsprachig, weil die Oberfläche deutschsprachig ist — inklusive
Bezeichner im Code. Das erspart beim Einbau die ständige Übersetzung zwischen
`revenue` und „Umsatz".
---
## Wo was liegt
```
app/ Next.js App Router — je Seite nur ein Wrapper
features/dashboard/
components/
layout/ AppShell, PageHeader, PageContainer, Navigation
cards/ KPICard, ChartCard, AlertList, StatZeile
charts/ alle Visualisierungen + gemeinsamer Tooltip
filters/ Filterleiste und Mehrfachauswahl
tables/ DataTable (sortieren, suchen, blättern) + Badges
context/ Filter-, Widget- und Toast-State
data/ types · seed (Dummy-Daten) · calculations · filtering
hooks/ useAnalyseDaten
pages/ die sechs Seitenkompositionen
utils/ chartTheme (Carbon-Tokens), formatters, dateUtils
ui/ Subframe-Komponentenbibliothek (unverändert übernommen)
docs/ Design-System, KPI-Katalog, Navigation, Datenmodell, Herkunft, Zielsystem, Leitplanken
```
**Wichtigste Datei für den Einbau:** `features/dashboard/data/calculations.ts`.
Dort steht jede Kennzahl als reine Funktion — Datenquelle austauschen, Rest bleibt.
---
## Grundlagen
Drei Quellen sind hier zusammengeführt:
1. **Fachliche Planung** (Januar 2026, mit dem Branchenpartner erarbeitet) —
Seitenschnitt, KPI-Auswahl, Filtersystem, Zahlen- und Spacing-Regeln.
2. **Bestehender Pitch-Prototyp** — die Subframe-Komponentenbibliothek unter `ui/`
und die Gestaltungssprache der Oberfläche.
3. **IBM Carbon Data Visualization** — Chart-Regeln, Farbpalette, Achsen, Tooltips.
Siehe [`docs/DESIGN-SYSTEM.md`](docs/DESIGN-SYSTEM.md).
Welche Quelle was beigesteuert hat — und an welchen Stellen bewusst von der
Januar-Planung abgewichen wurde — steht in [`docs/HERKUNFT.md`](docs/HERKUNFT.md).
---
## Für die Entwicklung
[`HANDOVER.md`](HANDOVER.md) listet auf, was echte Logik braucht, was bewusst
Attrappe ist und in welcher Reihenfolge der Einbau sinnvoll ist.
Die Seite **/komponenten** zeigt jeden Baustein isoliert mit Dateipfad — der
schnellste Weg, sich zu orientieren.
---
## Zielsystem
Eingebaut wird das hier in **HiveGrid Cloud** (`HiveGrid-Cloud`, Gitea
`toolctrl-labs`) — die eigentliche Software: pnpm-Monorepo, React Router 7,
Prisma/Postgres, mandantenfähig. Zielort ist der Tenant-Dashboard-Screen unter
`…/<tenant>/dashboard`.
Dort steht bereits eine Implementierung — bewusst als **Interim-UI**: gebaut aus
derselben Subframe-Bibliothek gegen ein stabiles View-Model, damit die
Backend-Tickets nicht auf den finalen Screen warten müssen. Die finale Optik ist
dort als Nachzug vorgemerkt. Genau die Lücke füllt dieser Prototyp.
Die Ausgangslage ist günstiger als üblich: **dasselbe Subframe-Projekt**
(82 der 86 Komponenten identisch benannt) und **dieselbe Chart-Bibliothek**
(Recharts). Der Einbau ist ein Port, keine Neuimplementierung.
Anzupassen sind vor allem vier Dinge — Daten kommen als View-Model-Prop mit fünf
Zuständen statt als Import, Texte laufen über i18n, Diagramme brauchen eine
Textalternative für Screenreader, und das App-Chrome dieses Prototyps entfällt,
weil die Zielanwendung Shell und Sidebar mitbringt.
**Nicht alles in der Zielanwendung sieht heute final aus.** HiveGrid Cloud
arbeitet designer-first: Für jede Phase gibt es einen Brief mit den benötigten
UI-Elementen, den finalen Screen entwirft der Designer in Subframe — Layout und
Anordnung sind ausdrücklich dessen Entscheidung. Ein großer Teil dieser Screens
ist noch offen und läuft bis dahin auf Interim-UI. Für diesen Prototyp heißt
das: Verbindlich zu übergeben sind **Kennzahl-Definitionen, Chart-Regeln und
Verhalten** — die Pixelanordnung ist ein Vorschlag.
Details, Dateipfade und die konkrete Einbau-Liste stehen in
[`docs/ZIELSYSTEM.md`](docs/ZIELSYSTEM.md). Welche Bereiche der Zielanwendung
bereits finale Subframe-Screens tragen — und damit als Leitplanke taugen — steht
in [`docs/LEITPLANKEN.md`](docs/LEITPLANKEN.md).
+5
View File
@@ -0,0 +1,5 @@
import { AuftraegeSeite } from "@/features/dashboard/pages/AuftraegeSeite";
export default function Page() {
return <AuftraegeSeite />;
}
+5
View File
@@ -0,0 +1,5 @@
import { FinanzenSeite } from "@/features/dashboard/pages/FinanzenSeite";
export default function Page() {
return <FinanzenSeite />;
}
+79
View File
@@ -0,0 +1,79 @@
@import "tailwindcss";
@import "../ui/theme.css";
/*
* HiveGrid Analytics Dashboard
* ---------------------------------------------------------------
* Chrome (Sidebar, Karten, Buttons, Tabellen) nutzt die Subframe-
* Design-Tokens aus ui/theme.css.
* Die Datenvisualisierung folgt IBM Carbon (siehe docs/DESIGN-SYSTEM.md)
* und definiert eigene Tokens, damit Charts unabhaengig vom Brand-Theme
* lesbar und barrierearm bleiben.
*/
:root {
/* --- Carbon Data-Viz Tokens (Light Theme) --- */
--dv-grid: #e0e0e0; /* cds-layer-accent-01 - Gridlines */
--dv-axis: #8d8d8d; /* cds-border-strong-01 - Achsenlinie */
--dv-text: #161616; /* cds-text-primary - Chart-Titel */
--dv-text-secondary: #525252;/* cds-text-secondary - Achsenlabels */
--dv-tooltip-bg: #393939; /* cds-background-inverse */
--dv-tooltip-fg: #ffffff; /* cds-text-inverse */
/* Vertikaler Rhythmus fuer Dashboard-Raster (8px-System) */
--dv-card-padding: 16px;
--dv-card-gap: 16px;
}
body {
background: var(--color-neutral-50, #fafafa);
color: var(--color-text-neutral-primary);
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-variant-numeric: tabular-nums;
}
/*
* Fokus-Sichtbarkeit.
* Eine Regel fuer alles Interaktive statt focus:-Klassen an jedem Element.
* :focus-visible zeigt den Ring nur bei Tastaturbedienung, nicht beim Klick.
*/
:where(a, button, [role="button"], input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--shadow-focus, 0 0 0 3px rgb(7 145 240 / 0.4));
border-radius: var(--radius-sm, 4px);
}
/* Radix-Portale (Dropdowns, Tooltips) ueber den Sticky-Header heben. */
[data-radix-popper-content-wrapper] {
z-index: 1000 !important;
}
/* Recharts: Fokus-Outline der SVG-Wrapper unterdruecken, Cursor vereinheitlichen. */
.recharts-wrapper:focus,
.recharts-surface:focus {
outline: none;
}
.recharts-sector,
.recharts-rectangle,
.recharts-dot {
transition: opacity 140ms ease, filter 140ms ease;
}
/* Scrollbars in Karten dezent halten. */
.dv-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dv-scroll::-webkit-scrollbar-thumb {
background: #d4d4d4;
border-radius: 999px;
}
.dv-scroll::-webkit-scrollbar-track {
background: transparent;
}
@media print {
.dv-no-print {
display: none !important;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { KomponentenSeite } from "@/features/dashboard/pages/KomponentenSeite";
export default function Page() {
return <KomponentenSeite />;
}
+5
View File
@@ -0,0 +1,5 @@
import { KundenSeite } from "@/features/dashboard/pages/KundenSeite";
export default function Page() {
return <KundenSeite />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { AppShell } from "@/features/dashboard/components/layout/AppShell";
import { DashboardProviders } from "@/features/dashboard/context/DashboardProviders";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "HiveGrid · Analytics Dashboard",
description:
"UI/UX-Prototyp der Analytics-Dashboards für Handwerksbetriebe (SHK) — Übergabe an die Entwicklung",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de" className={`${inter.variable} h-full antialiased`}>
<body className="min-h-full">
<DashboardProviders>
<AppShell>{children}</AppShell>
</DashboardProviders>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { UebersichtSeite } from "@/features/dashboard/pages/UebersichtSeite";
export default function Page() {
return <UebersichtSeite />;
}
+5
View File
@@ -0,0 +1,5 @@
import { TeamSeite } from "@/features/dashboard/pages/TeamSeite";
export default function Page() {
return <TeamSeite />;
}
+99
View File
@@ -0,0 +1,99 @@
# Datenmodell
Definiert in `features/dashboard/data/types.ts`, erzeugt in `data/seed.ts`.
Die Struktur ist bewusst nah an den HiveGrid-Entities gehalten, damit beim
Einbau nur die Datenquelle getauscht werden muss.
---
## Zuordnung zu HiveGrid
| Prototyp | HiveGrid-Entity | Verwendete Felder |
|---|---|---|
| `Rechnung` | `invoice` | `amount`, `status`, `issuedAt`, `dueDate`, bezahlt-am |
| `Auftrag` | `order` | `totalValue`, `status`, `startDate`, `endDate`, Gewerk, Marge, Förderung |
| `Kunde` | `company` | `name`, `source`, `createdAt`, Typ, Region |
| `Mitarbeiter` | `TenantUser` + `User` | Name, Rolle, Gewerk, Status, Wochenstunden |
| `Zeiteintrag` | `timeEntry` | `hours`, `date`, Auftragsbezug, Abwesenheitsgrund |
**Felder, die es so noch nicht gibt** und beim Einbau ergänzt werden müssen:
| Feld | Wo | Wofür |
|---|---|---|
| `gewerk` | Auftrag, Mitarbeiter | Alle Gewerke-Auswertungen und der Gewerk-Filter |
| `margeProzent` | Auftrag | Deckungsbeitrag |
| `foerderung` / `foerderbetrag` | Auftrag | Förderquote BAFA/KfW |
| `verlorenAb` | Auftrag | Ehrlicher Vertriebstrichter (siehe unten) |
| `ueberAngebot` | Auftrag | Trennt Angebotsgeschäft von Direktaufträgen |
| `wochenstunden` | Mitarbeiter | Auslastungsberechnung |
| `zahlungsmoralTage` | Kunde | Auffällige Zahlungsmoral (auch aus Rechnungen ableitbar) |
---
## Statuswerte
**Auftrag:** `anfrage` → `aufmass` → `angebot` → `beauftragt` → `in_arbeit` →
`abgeschlossen`, dazu `verloren` als Endzustand.
`verlorenAb` hält fest, welche Stufe ein verlorener Vorgang zuletzt erreicht
hat. Ohne dieses Feld müsste der Trichter raten und würde die oberen Stufen
systematisch zu klein zeigen.
`ueberAngebot` trennt das Angebotsgeschäft (ab etwa 6.000 €) von Aufträgen, die
direkt beauftragt werden — Wartung, Notdienst, E-Check. Trichter und
Abschlussquote beziehen sich ausschließlich auf das Angebotsgeschäft.
**Rechnung:** `entwurf` · `gestellt` · `bezahlt` · `ueberfaellig`.
---
## Gewerke und Leistungsarten
| Gewerk | Leistungsarten |
|---|---|
| Heizung | Wärmepumpe, Gastherme, Heizkörper, Wartung |
| Sanitär | Bad-Sanierung, Rohrleitungen, Armaturen, Notdienst |
| Klima & Lüftung | Splitgerät, Lüftungsanlage, Kanalreinigung, Wartung |
| Photovoltaik | PV-Anlage, Batteriespeicher, Wallbox, Wartung |
| Elektro | Elektroinstallation, Zählerschrank, Smart Home, E-Check |
Förderfähig (BAFA/KfW) sind Wärmepumpe, PV-Anlage, Batteriespeicher und
Lüftungsanlage.
---
## Der Demo-Datensatz
Erzeugt mit festem Seed (`mulberry32`, Seed `20260730`) — die Zahlen bleiben
über Reloads und Screenshots identisch. Bezugsdatum ist der **30.07.2026**
(`HEUTE` in `utils/dateUtils.ts`).
Fiktiver Betrieb: **Meister Krause GmbH**, Raum Rhein-Main, 24 Beschäftigte
(3 Meister, 14 Monteure, 3 Auszubildende, 4 Büro).
| Größe | Wert |
|---|---|
| Kunden | 420 |
| Aufträge (24 Monate) | 1.900 |
| Rechnungen | ~1.270 |
| Zeiteinträge (12 Wochen) | ~1.500 |
| Jahresumsatz | ~2,4 Mio. € |
| Offene Forderungen | ~230 T€ |
| Abschlussquote (Angebotsgeschäft) | ~38 % |
| Ø Auslastung | ~73 % |
**Eingebaute Effekte**, damit die Charts nicht künstlich flach aussehen:
- **Saisonalität** — Heizung und Lüftung ziehen von September bis Februar an,
der Hochsommer ist schwächer (`SAISON_INDEX`)
- **Wachstum** — rund 12 % gegenüber dem Vorjahr
- **Größenabhängige Abschlussquote** — Wartung und Notdienst werden fast immer
ausgeführt, große Anlagen dagegen verglichen und häufiger verloren
- **Zahlungsverhalten** — die Mehrheit zahlt nahe am Zahlungsziel (21 Tage),
ein kleiner Rest wird zum Mahnfall; Forderungen älter als sieben Monate sind
eingebracht oder ausgebucht und stehen nicht mehr in der offenen Liste
- **Förderanträge** — nur bei Vorgängen der letzten rund 100 Tage offen; ein
Bescheid dauert Wochen, nicht Jahre
Diese Effekte sind fachlich begründet, nicht kosmetisch: Ein gleichverteilter
Zufallsdatensatz erzeugt Charts, an denen sich keine Gestaltung prüfen lässt.
+248
View File
@@ -0,0 +1,248 @@
# Design-System: Datenvisualisierung
Grundlage ist **IBM Carbon Data Visualization**, ergänzt um die Spacing- und
Zahlenregeln aus der eigenen Planung (`UI_UX_GUIDELINES.md`, Januar 2026).
Die konkreten Farb- und Linienwerte sind aus dem ausgelieferten Stylesheet von
`@carbon/charts` (`dist/styles.css`, Light Theme) übernommen — nicht aus der
Dokumentation abgeschrieben. Alles Folgende ist in
`features/dashboard/utils/chartTheme.ts` als Code hinterlegt.
---
## 1. Farben
### Kategoriale Palette (14 Farben, Reihenfolge verbindlich)
```
1 #6929c4 2 #1192e8 3 #005d5d 4 #9f1853 5 #fa4d56
6 #520408 7 #198038 8 #002d9c 9 #ee5396 10 #b28600
11 #009d9a 12 #012749 13 #8a3800 14 #a56eff
```
Die Reihenfolge ist auf Unterscheidbarkeit optimiert — auch bei
Farbfehlsichtigkeit und in Graustufen. Farben deshalb **immer von vorne
vergeben**, nie nach Geschmack auswählen und nie umsortieren.
**Maximal fünf Kategorien je Chart.** Alles darüber fasst
`gruppiereRest()` zu „Sonstige" zusammen. Ein Ring mit zwölf Segmenten ist
keine Information mehr, sondern Dekoration.
### Statusfarben (nur für Aussagen, nie für Kategorien)
| Bedeutung | Wert |
|---|---|
| positiv | `#198038` |
| Warnung | `#b28600` |
| negativ | `#da1e28` |
| neutral | `#8d8d8d` |
| Information | `#1192e8` |
Kategorie- und Statusfarben nie im selben Chart mischen — eine grüne Kategorie
neben einem grünen Positiv-Status ist nicht mehr eindeutig lesbar.
**Rangfolgen einfarbig.** Top-Kunden oder Aufträge je Mitarbeiter sind keine
Kategorien: Dort trägt die Farbe keine Information, und eine bunte Palette
erzeugt nur Unruhe. Palette nur, wenn die Farbe etwas bedeutet.
### Feste Farbzuordnung
Wiederkehrende Kategorien behalten ihre Farbe über alle Charts und Seiten
hinweg (`GEWERK_FARBEN`, `STATUS_KATEGORIE_FARBEN` in `chartTheme.ts`). Sonst
ist Heizung im Ring lila und im Balkendiagramm blau, und jede Legende muss neu
gelesen werden.
| Gewerk | Farbe | | Status | Farbe |
|---|---|---|---|---|
| Heizung | `#6929c4` | | Verloren / Überfällig | `#fa4d56` / `#da1e28` |
| Sanitär | `#1192e8` | | In Arbeit / Bezahlt | `#198038` |
| Klima & Lüftung | `#005d5d` | | Beauftragt | `#009d9a` |
| Photovoltaik | `#9f1853` | | Angebot | `#6929c4` |
| Elektro | `#198038` | | Entwurf / Sonstige | `#8d8d8d` |
Bei Statuswerten trägt die Farbe Bedeutung: „Verloren" und „Überfällig" sind
rot, „Bezahlt" grün. Eine zufällige Palettenfarbe für „Verloren" würde die
Aussage der Grafik verwässern.
### Geordnete Dimensionen
Altersklassen, Risikostufen und Ähnliches sind **keine Kategorien**, sondern eine
Reihenfolge mit wachsendem Risiko. Dafür gilt die vierstufige Risikorampe
(`RISIKO_RAMPE` in `chartTheme.ts`) — durchgehend Statusfarben, keine
Palettenfarbe dazwischen:
| Stufe | Wert | Bedeutung |
|---|---|---|
| 1 | `#198038` | im Zahlungsziel |
| 2 | `#b28600` | beobachten |
| 3 | `#ba4e00` | mahnen |
| 4 | `#da1e28` | Abschreibungsrisiko |
### Einzelserien
| Rolle | Wert |
|---|---|
| Hauptreihe (Umsatzverlauf o. Ä.) | `#1192e8` |
| Vergleichsreihe (Vorjahr) | `#8d8d8d`, gestrichelt |
Die Vergleichsreihe ist bewusst neutral: Sie soll einordnen, nicht konkurrieren.
---
## 2. Achsen und Gitter
| Element | Wert | Begründung |
|---|---|---|
| Gitterlinien | `#e0e0e0`, **nur horizontal** | Vertikale Linien zerschneiden den Verlauf |
| Achsenlinie | `#8d8d8d`, 1 px | Nur an der X-Achse; die Y-Achse braucht keine |
| Tick-Marks | keine | Die Beschriftung reicht als Orientierung |
| Beschriftung | 12 px, `#525252` | Carbon-Standard |
| Y-Achsen-Breite | 58 px | Platz für „245 T€" ohne Umbruch |
Bei mehr als 12 Datenpunkten wird nur jede n-te Beschriftung gezeigt, sonst
überlappen sie (`MAX_ACHSENLABELS` in `chartTheme.ts` — eine Konstante für
alle Diagrammtypen).
**Achsenwerte sind rund.** Recharts leitet Ticks aus `max/(n-1)` ab und rundet
den Rohschritt auf; bei 219 T€ ergibt das 0/55/110/165/220. Krumme Achsenwerte
kosten den Leser bei jedem Blick eine Rechenoperation. `nettesRaster()` erzwingt
deshalb Schrittweiten aus der 1-2-2,5-5-Reihe.
**Die Einheit gehört zur Achse, nicht zum Wert.** `achsenFormat(max)` wählt sie
einmal aus dem Achsenmaximum. Sonst steht „0 €" neben „10 T€" und die Skala
wechselt mitten im Lesen die Einheit; die Nulllinie trägt gar keine Einheit.
---
## 3. Tooltip
Einheitlich für alle Charts (`components/charts/ChartTooltip.tsx`):
- Fläche `#393939`, Text `#ffffff`
- Label 12 px mit 80 % Deckkraft, Wert 13 px fett in Tabellenziffern
- kein Rahmen, kein Doppelpunkt zwischen Name und Wert
- Farbmarker je Reihe, 10 × 10 px
Ein gemeinsamer Tooltip verhindert, dass jedes Chart eigene Regeln entwickelt.
---
## 4. Balken, Flächen, Ringe
| Element | Regel |
|---|---|
| Balkenbreite | maximal 48 px |
| Balkenabstand | mindestens halbe Balkenbreite (`barCategoryGap: 28 %`) |
| Balkenecken | 2 px, nur an der Werteseite |
| Linienstärke Fläche | 2 px, Verlaufsfüllung 24 % → 2 % |
| Interpolation | **linear** — eine Monotone-Kurve erfindet Zwischenwerte zwischen zwei Messpunkten |
| Punkte | nur beim Hovern, Radius 4, weißer Rand |
| Ring innen/außen | 62 % / 88 %, 2° Segmentabstand |
| Ringmitte | Gesamtwert — sonst muss man die Segmente addieren |
| Ringlegende | rechts, einzeilig, mit Wert und Prozentanteil |
Ringsegmente starten oben (12 Uhr) mit dem größten Wert und laufen im
Uhrzeigersinn absteigend. Auch „Sonstige" wird nach Größe einsortiert, nicht
ans Ende gehängt.
**Ein Ring braucht ein echtes Ganzes.** Beträgt ein Segment über 80 %, trägt die
Grafik nichts mehr bei — dann ist die Bezugsgröße falsch gewählt. Beispiel: Der
Rechnungsbestand aus zwei Jahren gegen den heutigen Forderungsstand ergibt
92 % „Bezahlt". Richtig ist der offene Bestand allein; dann zeigt der Ring, wie
viel davon bereits überfällig ist.
### Werte in einem engen Band: Punkt statt Balken
Auslastung liegt betrieblich zwischen etwa 70 und 100 %. Ein Balken **muss** bei
Null beginnen, sonst verzerrt seine Länge — und dann sind 74 % und 96 % optisch
fast gleich lang. Ein Punkt kodiert über die **Position**, nicht über die Länge,
und darf deshalb einen ausgeschnittenen Bereich zeigen.
Der Zielkorridor liegt als graues Band hinter den Punkten, die 100-%-Marke als
beschriftete Linie, darunter eine Achse. Damit erklärt sich die Grafik ohne
Ampel-Legende — und tote Legendeneinträge („Luft frei", wenn niemand darunter
liegt) entfallen.
---
## 5. Abstände (8-px-System)
| Bereich | Wert |
|---|---|
| Seitenrand | 24 px |
| Abstand zwischen Karten | 16 px |
| Innenabstand Karte | 16 px |
| Kopf zu Inhalt in der Karte | 12 px |
| Label zu Wert in der KPI-Karte | 4 px |
| Icon zu Text | 8 px |
**Grundregel: innen ≤ außen.** Ist der Innenabstand größer als der Abstand
zwischen den Karten, zerfällt das Raster optisch.
---
## 6. Typografie
| Rolle | Größe | Gewicht |
|---|---|---|
| Seitentitel | 22 px | 600 |
| KPI-Wert | 28 px | 600 |
| Kartentitel | 14 px | 600 |
| Fließtext / Tabelle | 13 px | 400 |
| Label, Achsen, Legende | 12 px | 400–500 |
| Kleinstschrift (Meta) | 11 px | 400 |
Eine Schriftfamilie (Inter). Zahlen immer in Tabellenziffern
(`font-variant-numeric: tabular-nums`, global gesetzt), damit Spalten
untereinander stehen.
---
## 7. Zahlenformate (deutsch)
| Fall | Ausgabe |
|---|---|
| Währung, KPI | `238.412 €` |
| Währung, kompakt (Achsen, Balken) | `8,4 T€` · `245 T€` · `1,25 Mio. €` |
| Prozent | `58,2 %` |
| Tage / Stunden | `34,2 Tage` · `7,5 h` |
| Datum | `30.07.2026`, kurz `30.07.`, Monat `Jul 26` |
Punkt als Tausendertrenner, Komma als Dezimaltrenner. Innerhalb einer Ansicht
immer dasselbe Format — nicht in einer Zeile `245.000 €` und in der nächsten
`245 T€`.
---
## 8. Zustände
Jedes Chart braucht einen **Leerzustand** — Filter können jede Ansicht leer
laufen lassen. `ChartLeer` zeigt dann eine gestrichelte Fläche mit Hinweistext
statt eines leeren Rechtecks.
Hover-Zustände: Karten erhalten einen stärkeren Schatten, Balken und
Ringsegmente werden hervorgehoben (die übrigen auf 35–45 % Deckkraft).
Tabellenzeilen bekommen **keinen** Hover, solange sie nicht anklickbar sind —
ein Hover ohne Ziel verspricht eine Interaktion, die es nicht gibt.
## 9. Fokus und Kontrast
**Fokus** ist eine einzige Regel in `app/globals.css` statt `focus:`-Klassen an
jedem Element:
```css
:where(a, button, [role="button"], input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
}
```
`:focus-visible` zeigt den Ring nur bei Tastaturbedienung. Overlays (Filter-
Dropdown, Karten-Menü) schließen mit **Escape** und geben den Fokus an den
auslösenden Knopf zurück.
**Kontrast:** `--color-text-neutral-tertiary` liegt auf `neutral-700`
(5,15:1 auf Weiß), nicht auf `neutral-600` (3,68:1 — unter WCAG AA). Das
entspricht dem a11y-Override im Zielsystem. `text-neutral-subtle` ist ein
Placeholder-Token und für lesbaren Text nicht geeignet.
+173
View File
@@ -0,0 +1,173 @@
# Herkunft
Woher die Gestaltung dieses Prototyps stammt, was aus welcher Quelle übernommen
wurde und wo bewusst abgewichen wurde.
Der Zweck: Wer beim Einbau eine Entscheidung anzweifelt, soll nachlesen können,
ob sie begründet ist oder nur mitgeschleppt wurde. Vieles hier sieht auf den
ersten Blick wie eine Abweichung von der Januar-Planung aus und ist eine
absichtliche Korrektur.
---
## Die drei Quellen
| Quelle | Was daraus stammt | Stand |
|---|---|---|
| **Fachliche Planung** mit dem Branchenpartner | Seitenschnitt, KPI-Auswahl, Filtersystem, Spacing- und Zahlenregeln | Januar 2026 |
| **HiveGrid-Pitch-Prototyp** | Komponentenbibliothek `ui/`, Design-Tokens, Icon-Set, Gestaltungssprache der Oberfläche | Juli/August 2026 |
| **IBM Carbon Data Visualization** | Chart-Regeln, Farbpalette, Achsen, Tooltips, Kategorien-Obergrenze | laufend |
Die ersten beiden liegen als lokale Ordner außerhalb dieses Repos und sind
**nicht Teil der Übergabe** — alles fachlich Relevante daraus ist in `docs/`
überführt. Die Zuordnung unten ist die Brücke zurück, falls jemand die
Originale doch braucht.
---
## 1. Fachliche Planung (Januar 2026)
Ordner `dashboard-planung/`, entstanden mit dem Branchenpartner. Drei Teile
sind hier eingeflossen:
| Teil der Planung | Wirkung in diesem Repo |
|---|---|
| `01_CRM-Analytics-Clickdummy/` — React/Vite-Click-Dummy | Seitenschnitt, Komponentenzuschnitt, Filterarchitektur |
| `UI_UX_GUIDELINES.md` | 8-px-Raster, Regel „innen ≤ außen", Zahlenformate, KPI-Karten-Aufbau |
| `03_Planung-und-Konzept/Analytics/REQUIREMENTS-analytics.md` | Anforderungs-IDs (INFRA, DASH, FIN, ORD, CUST, COMP, FILT) |
Die Anforderungs-IDs stehen an mehreren Stellen als Kommentar im Code
(`FilterContext.tsx` → INFRA-01/02, `filtering.ts` → FILT-05,
`AnalyticsToolbar.tsx` → FILT-06). Sie sind der Faden zurück in die Planung.
### Übernommen
- **Fünf Analyse-Seiten** in derselben Reihenfolge: Übersicht, Finanzen,
Aufträge, Kunden, Team
- **Komponentenzuschnitt**: KPICard, ChartCard, AlertList, DataTable,
Filterleiste, Mehrfachauswahl mit Suche — dieselbe Aufteilung, neu geschrieben
- **Filterarchitektur**: globaler Filter-State über alle Seiten, in
`localStorage` persistiert, aktive Einschränkungen als entfernbare Chips
- **Seitenabhängige Filterrelevanz** (FILT-05) als Konzept
- **Recharts** als Chart-Bibliothek — dieselbe Wahl wie im Click-Dummy
- **8-px-Raster und deutsche Zahlenformate** aus `UI_UX_GUIDELINES.md`,
siehe [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) Abschnitt 5 und 7
### Bewusst anders
| Thema | Click-Dummy (Januar) | Hier | Grund |
|---|---|---|---|
| **Chart-Farben** | 12 kräftige Farben, Regel „keine Grautöne, wirken traurig" | Carbon-Palette, Grau ausdrücklich für Vergleichsreihen und Neutrales | Die Januar-Palette ist auf Freundlichkeit optimiert, nicht auf Unterscheidbarkeit. Carbon ist auf Farbfehlsichtigkeit und Graustufen geprüft. Grau ist kein Makel, sondern das Mittel, mit dem eine Vergleichsreihe zurücktritt |
| **Irrelevante Filter** | rechts ausgegraut angezeigt | werden gar nicht erst angeboten | Ein sichtbarer Filter, der nichts bewirkt, kostet Vertrauen in die Zahlen |
| **Schriftgrößen** | zur Laufzeit verstellbar (`TypographyContext` + Dev-Panel), KPI-Wert 37 px | feste Werte, KPI-Wert 28 px | Das Panel war ein Abstimmungswerkzeug. Die Abstimmung ist erfolgt; die Werte gehören jetzt fest ins Design-System |
| **Chart-Elemente** | Drop-Shadow statt Rahmen | flach, Trennung über Weißraum und Segmentabstand | Schatten auf Datenflächen erzeugen eine Tiefe, die keine Bedeutung trägt |
| **Fachlichkeit** | generisches CRM: Abteilungen, Bewertungen, Kundenkontakte | SHK-Handwerk: Gewerke, Förderung, Auslastung, Angebotsgeschäft | Auftrag dieses Prototyps |
| **Bezeichner** | englisch (`revenue`, `orders`) | deutsch (`umsatz`, `auftraege`) | Die Oberfläche ist deutsch. Ein Bruch in der Mitte erzwingt beim Einbau ständiges Übersetzen |
| **Datensatz** | 25 Mitarbeiter, 200 Kunden, 800 Aufträge, gleichverteilt | 24, 420, 1.900 — mit Saisonalität, Wachstum, Zahlungsverhalten | An einem gleichverteilten Datensatz lässt sich keine Gestaltung prüfen, siehe [DATENMODELL.md](DATENMODELL.md) |
### Anforderungen, die hier nicht umgesetzt sind
Die Planung führt 47 v1-Anforderungen als vollständig. In diesem Prototyp
fehlen vier davon bewusst:
| ID | Anforderung | Warum nicht |
|---|---|---|
| INFRA-03 | Filter-Presets speichern und laden | Bedienkomfort ohne Gestaltungsfrage. Gehört in die echte Software ans Benutzerprofil, nicht in die Vorlage |
| INFRA-04 (teilweise) | Zeitraum mit benutzerdefiniertem Bereich | Die sechs Presets decken die Abstimmung ab; ein Datumsbereichs-Picker ist eine Umsetzungs-, keine Gestaltungsfrage |
| FILT-02 / FILT-04 | Filter für Rechnungsstatus und Betragsbereich | Rechnungsstatus ist auf der Finanzseite bereits als Chart-Dimension sichtbar; ein Betragsfilter verzerrt Summen-KPIs, ohne dass es an der Karte steht |
| CUST-03 | KPI „Kontakte gesamt" | Der Prototyp führt keine Kontakt-Entität. In HiveGrid existiert sie — die Karte lässt sich beim Einbau ergänzen |
Umgekehrt ist **Team** enthalten, obwohl die Planung es unter v2 führt
(TEAM-01 bis TEAM-03): Auslastung ist im Handwerk die Kennzahl, nach der
disponiert wird.
---
## 2. HiveGrid-Pitch-Prototyp
Der Next.js-Prototyp aus dem Kundentermin (Juli 2026). Von dort stammt die
gesamte Oberfläche außerhalb der Diagramme.
| Übernommen | Wo |
|---|---|
| Komponentenbibliothek — 86 Komponenten, 4 Layouts | `ui/components/`, `ui/layouts/` |
| Design-Tokens (Farben, Radien, Schatten, Typo-Skala) | `ui/theme.css`, eingebunden in `app/globals.css` |
| Icon-Set (Feather über `@subframe/core`) | überall, aufgelöst in `components/layout/FeatherIcon.tsx` |
| Stack | Next.js 16 · React 19 · Tailwind v4 — gleiche Versionen |
`ui/` ist **byteidentisch** mit dem Stand im Pitch-Repo und wurde nicht
angefasst. Die Bibliothek wird dort über die Subframe-CLI aus dem Projekt
*Basedesign* synchronisiert:
```bash
npx @subframe/cli@latest sync --all
```
Wer die Bibliothek aktualisiert, sollte das im Pitch-Repo tun und den Ordner
hierher kopieren — sonst laufen die beiden Stände auseinander.
### Was aus der Bibliothek nicht verwendet wird
`ui/components/` enthält eigene Chart-Wrapper (`AreaChart`, `BarChart`,
`LineChart`, `PieChart`). Sie sind hier **nicht** im Einsatz. Sie geben zu
wenig Kontrolle über Achsen, Tooltips und Ringproportionen, um die
Carbon-Regeln umzusetzen. Die Diagramme unter
`features/dashboard/components/charts/` setzen deshalb direkt auf Recharts auf.
Das ist die einzige Stelle, an der dieser Prototyp von der Pitch-Bibliothek
abweicht — überall sonst gilt sie.
---
## 3. IBM Carbon Data Visualization
Grundlage aller Diagramm-Entscheidungen. Ausformuliert in
[DESIGN-SYSTEM.md](DESIGN-SYSTEM.md), als Code hinterlegt in
`features/dashboard/utils/chartTheme.ts`.
Die Farb- und Linienwerte sind aus dem ausgelieferten Stylesheet von
`@carbon/charts` (`dist/styles.css`, Light Theme) übernommen — nicht aus der
Dokumentation abgeschrieben. Das Paket selbst ist **keine Abhängigkeit** dieses
Repos; gezogen wurden nur die Werte.
| Von Carbon | Konkret |
|---|---|
| Kategoriale Palette | 14 Farben in verbindlicher Reihenfolge, immer von vorne vergeben |
| Obergrenze | maximal 5 Kategorien je Chart, Rest über `gruppiereRest()` zu „Sonstige" |
| Achsen | nur horizontale Gitterlinien, keine Tick-Marks, Achsenlinie nur an X |
| Tooltip | dunkle Fläche `#393939`, weißer Text, kein Rahmen |
| Neutrale Werte | Grid `#e0e0e0`, Achse `#8d8d8d`, Text `#161616` / `#525252` |
**Ergänzt, nicht aus Carbon:** die festen Farbzuordnungen je Gewerk und je
Status (`GEWERK_FARBEN`, `STATUS_KATEGORIE_FARBEN`). Carbon regelt die Palette,
nicht welche Kategorie welche Farbe bekommt. Eine Kategorie behält ihre Farbe
über alle Seiten hinweg, damit man Legenden nicht ständig neu liest.
---
## 4. Was aus keiner der drei Quellen stammt
Die SHK-Fachlogik ist für diesen Prototyp entstanden und beim Einbau der Teil,
der mit dem Betrieb abzustimmen ist:
| Thema | Wo | Offene Fachfrage |
|---|---|---|
| Gewerke und Leistungsarten | `data/types.ts` | Schnitt deckungsgleich mit dem Betrieb? |
| Förderquote BAFA/KfW | `calculations.ts` → `foerderQuote()` | Braucht ein Feld am Auftrag |
| Vertriebstrichter nur über Angebotsgeschäft | `vertriebsTrichter()`, Feld `ueberAngebot` | Ab welchem Wert wird schriftlich angeboten? |
| Absprungstufe verlorener Vorgänge | Feld `verlorenAb` | Wird das heute erfasst? |
| Auslastung und ihre Schwellen | `AuslastungsBalken.tsx` | Zählen Rüst- und Fahrzeiten als produktiv? |
Diese Punkte stehen mit Begründung in [HANDOVER.md](../HANDOVER.md) Abschnitt 5
und in [KPI-KATALOG.md](KPI-KATALOG.md).
---
## Zeitleiste
| Wann | Was |
|---|---|
| 18.–22.01.2026 | CRM-Analytics-Click-Dummy mit dem Branchenpartner, UI/UX-Regeln, Anforderungskatalog |
| 22.01.2026 | Dashboard- und Analytics-Bausteine ins HiveGrid-Projekt übernommen |
| Juli 2026 | Pitch-Prototyp mit der Subframe-Bibliothek *Basedesign* |
| 27.08.2026 | Dieses Repo: Planung, Pitch-Bibliothek und Carbon zusammengeführt, fachlich auf SHK zugeschnitten |
+147
View File
@@ -0,0 +1,147 @@
# KPI-Katalog
Jede Kennzahl mit Definition, Berechnung und Fundstelle im Code.
Alle Funktionen liegen in `features/dashboard/data/calculations.ts`.
**Zwei Zeitbezüge sind zu unterscheiden:**
- **zeitraumbezogen** — reagiert auf die Zeitraum-Umschaltung (Umsatz, neue Kunden)
- **stichtagsbezogen** — gilt zum heutigen Tag, unabhängig vom Zeitraum
(offene Forderungen, aktive Aufträge, Auslastung des Bestands)
Das ist Absicht: „Offene Forderungen der letzten 7 Tage" wäre keine sinnvolle
Zahl.
In der Oberfläche steht der Zeitbezug in der **Fußzeile jeder KPI-Karte**
(„im gewählten Zeitraum" / „zum Stichtag" / „über alle Zeiträume"). Karten mit
Vorperiodenvergleich zeigen dort stattdessen den Trend — die Anatomie bleibt
gleich, damit die Reihe eine gemeinsame Grundlinie hat. Diagrammkarten, die
den Zeitraumfilter ignorieren, sagen es im Untertitel.
---
## Übersicht
| KPI | Definition | Bezug | Funktion |
|---|---|---|---|
| Umsatz (Zahlungseingang) | Summe bezahlter Ausgangsrechnungen, gezählt am Zahlungsdatum | Zeitraum | `umsatz()` |
| Trend Umsatz | Vergleich mit dem gleich langen Zeitraum davor | Zeitraum | `umsatzVorperiode()` + `berechneTrend()` |
| Offene Forderungen | Summe aller nicht bezahlten Ausgangsrechnungen | Stichtag | `offenePosten().gesamt` |
| Aktive Aufträge | Vorgänge im Status „beauftragt" oder „in Arbeit" | Stichtag | `aktiveAuftraege()` |
| Ø Auslastung Monteure | Mittel der produktiven Stunden je verfügbarer Stunde | Zeitraum | `teamKennzahlen()` |
| Neue Kunden | Kundendatensätze mit Anlagedatum im Zeitraum | Zeitraum | `neueKunden()` |
| Abschlussquote | Gewonnene Aufträge im Angebotsgeschäft, bezogen auf alle Angebote | Stichtag | `abschlussquote()` |
| Deckungsbeitrag | Nach Auftragswert gewichtete Marge abgeschlossener Aufträge | Zeitraum | `marge()` |
**Umsatzentwicklung** (`umsatzVerlauf()`) bildet zusätzlich den
Vorjahreszeitraum ab: Jede Zahlung wird um zwölf Monate verschoben in denselben
Zeitraster einsortiert. Die Auflösung wechselt automatisch — bis 30 Tage
tageweise, darüber monatsweise.
---
## Finanzen
| KPI | Definition | Bezug | Funktion |
|---|---|---|---|
| Umsatz (bezahlt) | wie oben | Zeitraum | `umsatz()` |
| Offene Forderungen | Status „gestellt" oder „überfällig" | Stichtag | `offenePosten()` |
| Überfällig | Nur Status „überfällig" (Zahlungsziel deutlich überschritten) | Stichtag | `offenePosten().ueberfaellig*` |
| Ø Zahlungsdauer | Mittel aus Rechnungsdatum bis Zahlungseingang | Zeitraum | `zahlungsdauer()` |
| Deckungsbeitrag | siehe oben | Zeitraum | `marge()` |
| Forderungen nach Alter | Buckets ab Fälligkeit: 0–30 / 31–60 / 61–90 / > 90 Tage | Stichtag | `offenePosten().nachAlter` |
| Rechnungsvolumen nach Status | Betragssumme je Rechnungsstatus | Stichtag | `rechnungenNachStatus()` |
| Umsatz nach Gewerk | Bezahlte Rechnungen, zugeordnet über den Auftrag | Zeitraum | `umsatzNachGewerk()` |
| Top-Kunden | Kunden nach bezahltem Gesamtumsatz | gesamt | `topKunden()` |
Die Altersstruktur ist die wichtigste Karte der Seite: Ab etwa 60 Tagen sinkt
die Einbringungsquote erfahrungsgemäß deutlich. Die Balken sind deshalb von
grün nach rot eingefärbt.
---
## Aufträge
| KPI | Definition | Bezug | Funktion |
|---|---|---|---|
| Neue Aufträge | Vorgänge mit Anlagedatum im Zeitraum | Zeitraum | `auftraegeImZeitraum()` |
| Aktive Aufträge | „beauftragt" oder „in Arbeit" | Stichtag | `aktiveAuftraege()` |
| Auftragsbestand | Summe der Werte aktiver Aufträge | Stichtag | abgeleitet |
| Über Termin | Beauftragt oder in Arbeit und Fälligkeit überschritten | Stichtag | `ueberfaelligeAuftraege()` |
| Abschlussquote | siehe Übersicht | Stichtag | `abschlussquote()` |
| Ø Durchlaufzeit | Anlagedatum bis Abschluss, nur abgeschlossene Vorgänge | Stichtag | `durchlaufzeit()` |
| Vertriebstrichter | Vorgänge je erreichter Stufe | Stichtag | `vertriebsTrichter()` |
| Förderquote BAFA/KfW | Anteil **förderfähiger** Aufträge mit beantragter oder bewilligter Förderung | Stichtag | `foerderQuote()` |
**Zum Trichter:** Er zeigt ausschließlich das **Angebotsgeschäft** — Vorhaben
ab etwa 6.000 €, für die schriftlich angeboten wird (Feld `ueberAngebot`).
Wartung, Notdienst und E-Check gehen ohne Angebot direkt in die Ausführung;
zählte man sie mit, würde der Trichter unten breiter als oben.
Verlorene Vorgänge zählen bis zu der Stufe, auf der sie abgesprungen sind
(Feld `verlorenAb`). Ohne diese Unterscheidung wären die oberen Stufen
systematisch zu klein und die Übergangsquoten wertlos.
**Zur Abschlussquote:** gleiche Bezugsgröße wie der Trichter. Sie entspricht
deshalb exakt der Übergangsquote von „Angebot" auf „Beauftragt" — im
Demo-Datensatz rund 38 %.
**Zur Förderquote:** Bezugsgröße sind nur förderfähige Leistungen (Wärmepumpe,
PV-Anlage, Batteriespeicher, Lüftungsanlage). Über alle Aufträge gerechnet läge
sie im einstelligen Bereich und wäre wertlos — eine Badsanierung war nie
förderfähig.
---
## Kunden
| KPI | Definition | Bezug | Funktion |
|---|---|---|---|
| Kunden gesamt | Alle Kundendatensätze | Stichtag | — |
| Neue Kunden | Anlagedatum im Zeitraum | Zeitraum | `neueKunden()` |
| Wiederkehrende Kunden | Anteil mit mehr als einem Auftrag | Stichtag | `wiederkehrquote()` |
| Ø Kundenwert | Bezahlter Umsatz je Kunde mit mindestens einem Auftrag | gesamt | abgeleitet |
| Reaktivierbar | Kunden mit Historie, über 12 Monate ohne Auftrag | Stichtag | `inaktiveKunden()` |
| Kunden nach Quelle / Typ / Region | Verteilungen | Stichtag | `kundenNach()` |
| Auffällige Zahlungsmoral | Ø Zahlungsdauer über 30 Tage | Stichtag | `zahlungsauffaellig()` |
Empfehlung und Bestandskunden zusammen zeigen, wie stark der Betrieb ohne
Werbebudget wächst — im Handwerk die aussagekräftigste Quellenkennzahl.
---
## Team
| KPI | Definition | Bezug | Funktion |
|---|---|---|---|
| Im Einsatz | Gewerblich Beschäftigte mit Status „aktiv" | Stichtag | — |
| Ø Auslastung | Produktive Stunden / verfügbare Stunden (aus Wochenstunden hochgerechnet) | Zeitraum | `teamKennzahlen()` |
| Produktivquote | Auftragsbezogene Stunden / erfasste Stunden | Zeitraum | abgeleitet |
| Erfasste Stunden | Summe aller Zeiteinträge | Zeitraum | abgeleitet |
| Abwesenheit | Stunden für Urlaub, Krankheit, Fortbildung | Zeitraum | abgeleitet |
| Abgeschlossene Aufträge je Mitarbeiter | Anzahl im Zeitraum abgeschlossener Aufträge | Zeitraum | `teamKennzahlen()` |
| Auftragswert je Mitarbeiter | Summe der Werte dieser Aufträge | Zeitraum | `teamKennzahlen()` |
**Auslastungsschwellen** (in `AuslastungsBalken.tsx`, betrieblich festzulegen):
| Bereich | Bedeutung |
|---|---|
| < 70 % | Luft frei — Kapazität ungenutzt |
| 70–95 % | Zielkorridor |
| > 95 % | Ausgebucht — Terminrisiko |
Der Auftragswert je Mitarbeiter ist ausdrücklich **kein Leistungsvergleich**:
Ein Wärmepumpen-Auftrag ist um ein Vielfaches größer als eine Wartung. Der
Hinweis steht als Tooltip an der Karte.
---
## Bewusst nicht enthalten
| Kennzahl | Grund |
|---|---|
| Live-Aktualisierung | Für die Gestaltungsabstimmung ohne Nutzen |
| CSV-/Excel-Export | Reine Umsetzungsfrage, kein Gestaltungsthema |
| Drilldown auf Einzeldatensätze | Der Absprung in die bestehenden Listen genügt |
| E-Mail-Marketing-Kennzahlen | Eigenes Modul (siehe Planungsordner 04) |
| Workflow-Kennzahlen | Eigenes Modul |
+211
View File
@@ -0,0 +1,211 @@
# Leitplanken aus HiveGrid Cloud
Welche Bereiche der Zielanwendung bereits Davids finale Subframe-Screens tragen
und welche noch auf Interim-UI laufen — und was daraus für dieses Dashboard
verbindlich ist.
Warum die Unterscheidung zählt: Ein nachgezogener Screen ist eine
**Design-Entscheidung**, an der man sich ausrichten kann. Eine Interim-UI ist
eine **Notlösung des Devs** — sie kopieren hieße, einen Platzhalter zum Standard
zu erklären.
Stand der Auswertung: 2026-08-29 gegen den lokalen Repo-Stand.
---
## 1. Woran man es im Code erkennt
Der Repo pflegt dafür bewusste Marker. In absteigender Verlässlichkeit:
| Muster | Bedeutung |
|---|---|
| `Interim-UI (AD-28)` im Kopfkommentar | Optik ist ausdrücklich vorläufig |
| `design-nachzug` | offener Punkt — steht auch in sonst fertigen Screens für einzelne Reste |
| Kopfkommentar mit Subframe-Screen-ID (`WF-P8 S03`, `S02/S02L/S03/S05`), Page-UUID oder „gegen Davids Export vom …" | nachgezogen, Optik ist abgestimmt |
| `@subframe/sync-disable` in `apps/web/app/ui/**` | lokal geänderte Komponente, die der Sync nicht überschreiben darf |
Ein typischer Interim-Kopf (`projects/PipelineKanbanPage.tsx`):
> `// R-P8b (P8 configurable Kanban) Interim-UI (AD-28) — pipeline board screen`
> `// (D-KANBAN), composed from ~/ui …`
Ein typischer nachgezogener Kopf (`projects/ProjektListPage.tsx`):
> `// WF-P8 S03 "Projekt-Liste" — rebuilt against Davids Subframe export of`
> `// 2026-07-28 (page 5c5bdb0a-995e-4c22-aa8b-e48333c60297) …`
**Autoritativ ist aber die Liste, nicht der Kommentar:**
`.planning/SUBFRAME-FLOW-INVENTAR.md` trackt jeden Subframe-Flow mit Seitenzahl,
Owner und Stand. Der dortige Hinweis ist wichtig: Der Subframe-Sync pflegt nur
Components, Layouts und Theme — **Pages werden nie automatisch integriert**,
jeder Screen wird einzeln hinter der Loader/Action-Grenze verdrahtet.
**Grenze der Methode:** Ein fehlender Marker sagt nichts. Von 214
`*Page.tsx`-Dateien tragen 92 einen Interim-Marker, 36 einen Subframe-Bezug und
**86 gar keinen** — überwiegend Plattform-Admin (31) und Integrationen (13),
also interne Flächen mit niedrigem Politur-Druck.
---
## 2. Die Abgrenzung
### Nachgezogen — als Leitplanke brauchbar
| Bereich | Stand laut Inventar | Im Code |
|---|---|---|
| **Team-Chat** | 🟢 24 von 24 Screens integriert (Handoff 2026-08-08) | `screens/chat/` — kein einziger Interim-Marker |
| **Form-Builder** | ✅ integriert (PR #71) | `screens/forms/` — Kopf nennt „WF-P16, screens S02/S02L/S03/S05" |
| **Automation / Workflow-Editor** | 🟡 voller Delta-Nachzug (Davids „V1 Vollbild minimal") | `screens/automation/` — 6 Screens mit Subframe-Bezug, keiner interim |
| **Projekt-Pipelines** | 🟡 Kernstrecke S01–S07 integriert | `screens/projects/` — 7 nachgezogen, 4 noch interim (darunter das Kanban selbst) |
| **Aufgaben** | 🟡 Stufen 0–6 fertig | `screens/tasks/` — 2 nachgezogen, 1 interim |
| **Lead-Liste** | 🟡 Kernstrecke (`/leads` = S01, `/leads/inbox` = S02) | `screens/leads/` — 2 nachgezogen, 6 noch interim |
### Vollständig Interim — nicht als Vorbild nehmen
Kein einziger nachgezogener Screen: **HR** (21 Seiten), **Mobile-PWA** (19),
**CRM** (6), **Booking** (5), **DSGVO/Privacy** (5), **E-Mail-Postfach** (4),
**Files** (3), **Kalender** (2), **Audit** (2), Signatur, Plugins, Auth —
**und das Dashboard** (1).
### Noch gar nicht entworfen
Die zehn Auth-Flows (Login/2FA, Passwort-Reset, Set-Password, Admin-RBAC,
Signup, Konto löschen, 2FA verwalten, Passwort ändern, Workspace-Auswahl,
Profil & Sicherheit) stehen im Inventar auf ❌ offen — zusammen 44 Seiten.
Ergänzend: Die 30 Designer-Briefs unter `.planning/designer-briefs/` haben ihren
Block „Davids Delta" durchgehend **leer**. Die tatsächlichen Handoffs werden
über das Flow-Inventar und die Ticket-Docs geführt, nicht über die Briefs —
102 Ticket-Docs führen einen offenen `design-nachzug`.
---
## 3. Was daraus für dieses Dashboard folgt
### 3.1 KPI-Kachel: `StatsCard` ist gesetzt
Die nachgezogenen Screens benutzen durchgehend `~/ui/components/StatsCard`
(Billing-Übersicht, Zahlungsseite, Admin-Health, Admin-Jobs, Admin-Billing).
Die API ist knapp:
```tsx
<StatsCard
trend="neutral" | "positive" | "negative"
icon={<FeatherUsers />}
label={t("…")}
value={formatters.number(totalSeats)}
percentage={t("…")} // "" um den Slot abzuschalten
/>
```
Unsere `KPICard` kann mehr: Sparkline, Akzentfarbe, `richtungGut` (steigender
Wert ist je nach Kennzahl gut oder schlecht), Trendlabel, ⋯-Menü zum Ausblenden.
Das ist fachlich begründet — **aber es ist eine Abweichung vom gesetzten
Baustein.** Beim Einbau gibt es genau zwei saubere Wege:
1. `StatsCard` erweitern und die Datei mit `@subframe/sync-disable` schützen
(so wurde es dort schon einmal gemacht, siehe Kopf der Komponente), oder
2. die eigene Karte bewusst als lokale Komposition begründen.
Was nicht geht: eine zweite KPI-Kachel kommentarlos danebenstellen.
### 3.2 Raster: 16 px, mobil einspaltig
Das durchgängige Muster der nachgezogenen Screens:
```
mobile:grid-cols-1 grid w-full grid-cols-4 gap-4
```
`gap-4` = 16 px = unser Kartenabstand ✓. Das `mobile:`-Präfix ist der
hauseigene Breakpoint und in 44 Stellen belegt — unser Prototyp hat kein
Mobil-Verhalten und muss es beim Einbau ergänzen.
### 3.3 Filterleiste: `FilterBar` statt Eigenbau
`ProjektListPage`, `AutomationListPage`, `AutomationExecutionsPage`,
`FormsAdminPage` und die Betriebsmittel-Übersicht nutzen alle
`~/ui/components/FilterBar`. Unsere `AnalyticsToolbar` ist handgebautes
Tailwind — funktional gleichwertig, aber am gesetzten Baustein vorbei. Das ist
die auffälligste Stelle, an der der Prototyp beim Einbau umgestellt gehört.
Bemerkenswert: Die Betriebsmittel-Übersicht wurde 2026-08-19 genau in diese
Richtung überarbeitet — „auf kompakte Kennzahlen plus echte `FilterBar`
umgestellt". Kennzahlenreihe über Filterleiste über Liste ist dort also bereits
das etablierte Muster. Unser Seitenaufbau folgt derselben Logik.
### 3.4 Weitere Konventionen der fertigen Screens
- Seitenkopf über `PageShell` / `PageHeader` / `PageBody`, nie eigenes Chrome
- i18n mit Namespace: `t("billing:subscriptionOverview.summary.plan")`
- Screen-Komponente ist rein: `({ vm, actions })`, kein `useLoaderData`,
kein `useFetcher` — Navigation und Schreibpfade sind Callbacks
- Tabellen über `~/ui/components/Table`, Dichte über die Komponente wählen
---
## 4. Wo es keine Leitplanke gibt: die Diagramme
Recharts kommt im gesamten Repo an **drei** Stellen vor: die beiden
Dashboard-Diagramme und `leads/LeadAnalyticsPage.tsx`. Es gibt keine geteilte
Palette, kein Chart-Theme, keine Kategorienregel — die vorhandenen Diagramme
setzen ihre Farben direkt im JSX.
Auch Davids Standards decken das nicht ab:
`.planning/INTERIM-UI-DESIGN-STANDARDS.md` regelt Spacing, Control-Höhen,
Dialoggrößen und Zustände sehr genau, **Datenvisualisierung gar nicht**.
Für dieses Dashboard heißt das beides:
- Für Kacheln, Filter, Tabellen und Seitenaufbau **gibt es** einen Standard —
daran halten wir uns, statt ihn neu zu erfinden.
- Für Diagramme gibt es keinen. Was wir in
[DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) und `chartTheme.ts` festlegen, füllt eine
echte Lücke und wird im Zweifel zur Referenz für alle späteren Charts. Das ist
der wertvollste, aber auch der begründungspflichtigste Teil der Übergabe —
deshalb steht zu jeder Regel dort, warum sie so ist.
---
## 5. Selbst nachprüfen
```sh
cd <hivegrid-cloud>
# Interim-Screens
grep -rl "Interim-UI\|AD-28" apps/web/app/components/screens --include='*Page.tsx'
# nachgezogene Screens
grep -rl "Davids\|WF-P[0-9]" apps/web/app/components/screens --include='*Page.tsx'
# autoritative Flow-Liste
cat .planning/SUBFRAME-FLOW-INVENTAR.md
```
Die Zahlen in diesem Dokument sind eine Momentaufnahme. Das Flow-Inventar wird
fortgeschrieben (Stand dort: 2026-08-25, 12. Update) — vor einer Entscheidung
lieber neu zählen.
---
## 6. Wo dieser Prototyp bewusst abweicht
Ein Abgleich mit den nachgezogenen Cloud-Screens fördert drei Stellen zutage, an
denen dieser Prototyp anders arbeitet. Sie stehen hier, damit sie beim Einbau
als **Entscheidung** erkennbar sind und nicht als Versehen wegkorrigiert werden.
| Thema | Cloud | Hier | Begründung |
|---|---|---|---|
| **Zahlen in Tabellen** | linksbündig; `align-right` nur für die Aktionsspalte | rechtsbündig mit `tabular-nums` | IBM Carbon und die eigene Planung (`UI_UX_GUIDELINES.md` §2) verlangen es: Nur rechtsbündig stehen Tausenderstellen untereinander, und genau darum geht es in einem Analytics-Dashboard. Die Cloud-Tabellen zeigen überwiegend Text, nicht Beträge |
| **Filterleiste** | `~/ui` `FilterBar` mit Facetten-Slots | eigene `AnalyticsToolbar` | Der Prototyp braucht ein Zeitraum-Segment-Control, das die FilterBar nicht kennt. Beim Einbau ist das der erste Umbau — die Logik passt fast 1:1 auf `FacetFilterBar` |
| **Formatierung** | `locale/format.ts`, Prozent erwartet 0–1 | eigene `formatters.ts`, Prozent erwartet 0–100 | Der Prototyp hat keine i18n-Schicht. Beim Einbau ersetzen, aber die **Achsenformatierung** (`achsenFormat`, `nettesRaster`) hat dort kein Gegenstück und sollte mitkommen |
Umgekehrt übernommen wurden: keine Versalien in Spaltenköpfen, Raster mit 16 px
Abstand, Kennzahlenreihe über Filterleiste über Liste, Leerzustände an jeder
Visualisierung.
**Offen für den Einbau** (aus dem Cloud-Abgleich, hier bewusst nicht gebaut):
Screens als reine `({ vm, actions })`-Komponenten, Texte über i18n-Keys,
`mobile:`-Verhalten, `data-ui-id` auf allen Flächen, Textalternative je Diagramm.
Das ist Portierungsarbeit, keine Gestaltung — Details in
[ZIELSYSTEM.md](ZIELSYSTEM.md) Abschnitt 3.
+96
View File
@@ -0,0 +1,96 @@
# Navigation und Seitenaufbau
## Struktur
```
Seitenleiste (fest, einklappbar auf Icon-Breite)
├── Analyse
│ ├── Übersicht / Kernkennzahlen, Handlungsbedarf
│ ├── Finanzen /finanzen Umsatz, Forderungen, Zahlungsverhalten
│ ├── Aufträge /auftraege Pipeline, Termine, Förderung
│ ├── Kunden /kunden Akquise, Quellen, Reaktivierung
│ └── Team /team Auslastung, Stunden, Abschlüsse
└── Entwicklung
└── Komponenten /komponenten Bausteine und Regeln (nur für die Umsetzung)
```
**Warum Seitenleiste statt Tab-Leiste:** Die bestehende HiveGrid-Oberfläche
navigiert links. Der Prototyp lässt sich so ohne Umbau der
Informationsarchitektur einsetzen. Einklappen schafft Platz für breite Tabellen
und das fünfspaltige KPI-Raster.
---
## Aufbau jeder Analyse-Seite
```
┌──────────────────────────────────────────────────────────────┐
│ Kopfzeile: Breadcrumb · Suche · Benachrichtigungen · Profil │ fest
├──────────────────────────────────────────────────────────────┤
│ Seitenkopf: Titel · Einordnung Aktualisieren Export ⇗ │
├──────────────────────────────────────────────────────────────┤
│ Filterleiste: Zeitraum │ Dimensionen │ zurücksetzen │ klebt oben
│ Aktive Filter als entfernbare Chips │
│ Ausgeblendete Widgets (nur wenn welche versteckt sind) │
├──────────────────────────────────────────────────────────────┤
│ KPI-Reihe (5 Karten, gleiche Höhe) │
│ [nur Übersicht] Handlungsbedarf über volle Breite │
│ Hauptdiagramm (2/3) │ Nebendiagramm (1/3) │
│ Detaildiagramme (2–3, oben bündig) │
│ Arbeitsliste (Tabelle) │
└──────────────────────────────────────────────────────────────┘
```
Die Reihenfolge folgt der Lesefrage: **Wie steht es? → Was ist zu tun? → Wie hat
es sich entwickelt? → Woran liegt es?**
Auf der **Übersicht** steht der Handlungsbedarf direkt unter der KPI-Reihe und
über die volle Breite. Sie ist die Seite der Geschäftsführung; dort ist „was ist
zu tun" die zweite Frage, nicht die letzte. Jede Zeile trägt Betrag und Handlung
(„Mahnlauf starten", „Nachfassen") — eine Liste ohne Verb ist ein Bericht.
Auf den vier Detailseiten bleibt die Arbeitsliste unten: Dort ist sie die
Konsequenz aus dem, was darüber steht.
**Gleiche Kartenhöhe nur bei gleicher Struktur.** Die KPI-Reihe wird gestreckt,
damit die fünf Karten als ein Objekt lesbar sind. Gemischte Reihen (Diagramm
mit fester Höhe neben Tabelle) stehen auf `items-start` — Strecken erzeugt dort
nur Weißraum.
---
## Interaktionen
| Element | Verhalten |
|---|---|
| **Zeitraum** | Heute · 7 Tage · 30 Tage · Quartal · Jahr · Gesamt. Rechnet alle zeitraumbezogenen Kennzahlen und Charts neu, inklusive Vorperiodenvergleich |
| **Dimensionsfilter** | Mehrfachauswahl mit Suche; wirkt sofort. Nur Filter, die auf der Seite etwas bewirken, werden angeboten |
| **Filter-Chips** | Jede Einschränkung erscheint als Chip und lässt sich einzeln entfernen |
| **Widget ausblenden** | ⋯-Menü an jeder Karte. Ausgeblendete Karten erscheinen in der Filterleiste und lassen sich einzeln oder gesammelt zurückholen |
| **Hover** | Balken, Ringsegmente und Trichterstufen heben sich hervor, die übrigen treten zurück; Verläufe zeigen einen Tooltip mit allen Reihen des Datenpunkts |
| **Handlungsbedarf** | Jede Zeile ist ein Absprung in die zugehörige Arbeitsliste (im Prototyp Attrappe mit Hinweis) |
| **Tastatur** | Fokus ist auf allen interaktiven Elementen sichtbar; Filter-Dropdown und Karten-Menü schließen mit Escape und geben den Fokus zurück |
| **Ringlegende** | Klick hebt ein Segment dauerhaft hervor, erneuter Klick löst es wieder |
| **Tabellen** | Spaltenkopf sortiert, Suchfeld filtert, Blättern über Pfeile |
| **Seitenleiste** | Einklappbar; die Auswahl bleibt beim Seitenwechsel erhalten |
Filterauswahl und ausgeblendete Widgets überleben einen Reload
(`localStorage`, Schlüssel `hivegrid.analytics.*`).
---
## Seitenabhängige Filterrelevanz
Definiert in `features/dashboard/data/filtering.ts`:
| Seite | Zeitraum | Gewerk | Auftragsstatus | Kundenquelle | Region | Mitarbeiter |
|---|:--:|:--:|:--:|:--:|:--:|:--:|
| Übersicht | ✓ | ✓ | | | ✓ | |
| Finanzen | ✓ | ✓ | | ✓ | ✓ | |
| Aufträge | ✓ | ✓ | ✓ | | ✓ | ✓ |
| Kunden | ✓ | ✓ | | ✓ | ✓ | |
| Team | ✓ | ✓ | | | | ✓ |
Ein Filter, der sichtbar ist aber nichts bewirkt, kostet Vertrauen in die
Zahlen. Deshalb werden irrelevante Filter gar nicht erst angeboten — die
Auswahl bleibt aber im Hintergrund erhalten und wirkt beim Seitenwechsel wieder.
+215
View File
@@ -0,0 +1,215 @@
# Zielsystem: HiveGrid Cloud
Wohin dieser Prototyp eingebaut wird, was dort heute schon steht und was beim
Einbau konkret anzupassen ist.
Repo: `HiveGrid-Cloud` (Gitea `git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud`).
pnpm-Monorepo, React Router 7, Prisma/Postgres, mandantenfähig mit RLS.
`apps/web` (Tenant- und Admin-App), dazu `api`, `worker`, `desktop`,
`hocuspocus`, `portal-domain-controller` und rund 40 Pakete unter `packages/`.
Größenordnung: 678 Route-Dateien, davon 575 im Core-Manifest.
---
## 1. Der Platz dieses Prototyps
Das Ziel ist der Tenant-Dashboard-Screen unter `…/<tenant>/dashboard`.
Dort liegt bereits eine Implementierung:
| Datei | Was drin steht |
|---|---|
| `apps/web/app/components/screens/dashboard/DashboardPage.tsx` | Widget-Grid, 495 Zeilen |
| `…/DashboardEditPanel.tsx` | Edit-Modus (Widget-Filter/Zeitraum) |
| `…/DashboardDrilldownDialog.tsx` | Drilldown je Kachel |
| `…/DashboardMonthlySeriesChart.tsx`, `…/DashboardStageDistributionChart.tsx` | die zwei vorhandenen Diagramme |
| `apps/web/app/routes/app.$tenant/dashboard.tsx` | Loader, SSE-Stream, Export |
Fachlich ist das die Phase **P11** (Modul M20): drei feste Rollen-Layouts
(**GF · Büro · Monteur**) aus einer Widget-Registry, live über SSE, plus das
Komfort-Delta aus **P30** (Edit-Modus, Drilldown, CSV-/Report-Export,
Realtime-Opt-out).
**Wichtig:** Dieser Stand ist ausdrücklich **Interim-UI nach AD-28** — der Dev
baut aus `~/ui` gegen ein stabiles View-Model, damit das Ticket nicht auf den
finalen Screen wartet. Der Kommentarkopf von `DashboardPage.tsx` sagt es
wörtlich: *„Final Subframe optics = tracked design-nachzug (D-DASH / VD-P30)."*
Genau diese Lücke füllt dieser Prototyp. Der Einbau ist der geplante Nachzug,
kein Umbau gegen den Strom.
---
## 2. Gemeinsame Basis — und wo sie auseinanderläuft
| | Prototyp | HiveGrid Cloud |
|---|---|---|
| Subframe-Projekt | `f023064a5ca0` | `f023064a5ca0` — **dasselbe** |
| Bibliothek | `ui/`, 86 Komponenten | `apps/web/app/ui/`, Alias `~/ui/*`, 92 Komponenten |
| Überschneidung | 82 Komponenten gleich benannt | dieselben 82 |
| Diagramme | Recharts ^2.15.4 | Recharts ^2.15.3 — **dieselbe Bibliothek** |
| Framework | Next.js 16 | React Router 7 |
Die vier Namensunterschiede: `DialogFullscreen` → `FullscreenDialog`,
`DrawerRight` → `Drawer`, `AddressDialog` und `BannerOffline` existieren dort
nicht. Cloud hat zusätzlich `BulkActionBar`, `ConfirmDialog`, `DropdownMenu`,
`ModalBlank`, `ResultDialog`, `BannerPermanent`, `ChatSprechblase`.
Dass Komponentenbibliothek **und** Chart-Bibliothek übereinstimmen, ist der
Grund, warum der Einbau realistisch ein Port ist und keine Neuimplementierung.
---
## 3. Was beim Einbau konkret anders wird
### 3.1 Daten kommen als View-Model-Prop
Der Prototyp importiert `DATENSATZ` direkt. In der Cloud gilt das Muster
`<XPage vm={} actions={} />`: Die Route besitzt Loader, SSE und Actions, die
Screen-Komponente ist rein und bekommt alles als Prop. Kein `useLoaderData`
in der Komponente.
```ts
export type DashboardPageVM = VMState<DashboardPageData>;
type VMState<T> =
| { status: "idle" }
| { status: "loading"; data?: T }
| { status: "ready"; data: T }
| { status: "empty" }
| { status: "error"; data?: T; error: { messageKey: string; code?: string } };
```
Fünf Zustände statt einem. Der Prototyp kennt nur „Daten da"; `loading`,
`empty` und `error` sind je Seite zu ergänzen. Die Chart-Leerzustände
(`ChartLeer`) decken davon nur einen Teil ab.
`DashboardPageData` führt außerdem `activeRole`, `layout`, `generatedAt`,
`streamUrl`, `canCustomize`, `liveUpdatesEnabled`, `layoutSource` und
`catalog` — das Dashboard ist dort rollen- und berechtigungsabhängig, was der
Prototyp nicht abbildet.
### 3.2 Kein eigenes App-Chrome
`AppShell.tsx` aus diesem Prototyp fällt beim Einbau **weg**. Die Cloud-Routen
laufen bereits in der echten App-Shell mit Sidebar, Tenant-Layout und
Breadcrumb (`SubframeAppShell.tsx`, `AppSidebar.tsx`). Screen-Komponenten
dürfen dort ausdrücklich **kein** `~/ui/layouts/DefaultPageLayout` rendern —
das bringt eine eigene Beispiel-Sidebar mit und erzeugt eine doppelte
Navigation. Stattdessen gilt `PageShell` / `PageHeader` / `PageBody` aus
`components/screens/shared/PageShell.tsx`.
Unsere Seitenköpfe und die Filterleiste bleiben, die Rahmen-Navigation nicht.
### 3.3 Texte über i18n
Alle statischen Texte laufen über `react-i18next` (`t("app.dashboard.…")`).
Dieser Prototyp hat deutsche Strings fest im JSX. Das ist beim Einbau
mechanische Arbeit, aber flächendeckend — es betrifft jede Karte, jede
Achsenbeschriftung und jeden Leerzustand.
Die deutschen **Bezeichner im Code** (`umsatz`, `auftraege`) bleiben davon
unberührt und passen zur Cloud, deren UI ebenfalls deutsch ist (EN-Locale erst
in P30).
### 3.4 Barrierefreiheit der Diagramme
In der Cloud sind Charts konsequent `aria-hidden="true"`, und die umgebende
Karte liefert eine Textzusammenfassung plus Datentabelle für Screenreader.
Dieser Prototyp hat das **nicht**. Beim Einbau ist es Pflicht — das Repo fährt
ein a11y-Lint-Baseline-Gate und axe-Tests je Primitive.
### 3.5 Stabile Oberflächen-IDs
Jede relevante Fläche trägt ein `data-ui-id` (`ui.dashboard.widget.…`), erfasst
in `docs/ui-inventory/surfaces.json`. Tests und Agenten adressieren darüber.
Unsere Widget-IDs aus dem `WidgetContext` sind der natürliche Anknüpfungspunkt,
müssen aber auf das dortige Namensschema umgestellt werden.
### 3.6 Spacing passt bereits
`.planning/INTERIM-UI-DESIGN-STANDARDS.md` schreibt fest: Page-Padding **24**,
Card **16**, Grid-Gap **16**, Section-Gap **24**, Field-Gap **16**,
Control-Höhe Medium **36**, Input-Radius **4**. Das deckt sich mit dem
8-px-System in [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) Abschnitt 5 — hier ist
nichts anzupassen.
---
## 4. Was dieser Prototyp dem Zielsystem voraushat
Nicht alles fließt nur in eine Richtung. Drei Dinge fehlen dort heute:
**Ein gemeinsames Chart-Theme.** Die beiden vorhandenen Diagramme setzen
Farben direkt im JSX (`fill="#334155"`, `fill="#cbd5e1"`), jedes mit eigenem
Gitter (`strokeDasharray="3 3"`), eigener Achsenbreite und eigenem
Balkenradius. Es gibt keine geteilte Palette, keine Kategorien-Obergrenze und
keine feste Farbzuordnung. `features/dashboard/utils/chartTheme.ts` ist genau
das, was dort fehlt — und der Teil, der sich am ehesten unabhängig vom Rest
übernehmen lässt.
**Ausformulierte Diagramm-Regeln.** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) hat in
der Cloud keine Entsprechung. Die Standards dort regeln Layout, Controls und
Zustände sehr genau, Datenvisualisierung dagegen gar nicht.
**Fachliche Kennzahl-Definitionen.** [KPI-KATALOG.md](KPI-KATALOG.md) mit
Zeitbezug und Berechnung je KPI ist im Zielsystem so nicht vorhanden; die
Widget-Registry beschreibt Kacheln, nicht deren betriebswirtschaftliche
Definition.
---
## 5. Wer entwirft was — der Rest ist noch nicht fertig designt
HiveGrid Cloud ist nach dem **Designer-First-Prinzip** organisiert
(`.planning/DESIGNER-FIRST-PLAYBOOK.md`). Der Ablauf ist verbindlich:
1. Dev/Planung stellt je Phase einen **Designer-Brief** bereit —
*was* das Modul tut und *welche UI-Elemente* vorkommen müssen, **kein Layout**.
2. Der Designer entwirft die Screens in Subframe und darf Spec und UI/UX
**überschreiben** — Anordnung und Layout sind eine Design-Entscheidung.
3. Das **Delta** (geänderte Spec-Punkte, neue UX-Entscheidungen, Subframe-Link)
wird in den Brief eingetragen.
4. Der Dev setzt um und zieht Spec und Doku nach.
Es gibt 31 solcher Briefs (`.planning/designer-briefs/P01…P34.md`) — Auth, CRM,
Lead-Inbox, Projekt-Pipelines, Automation-Flow-Editor, Form-Builder, Plantafel,
Kalender, Telefonie, HR, Billing, Customer-Portal, Help-Center. Für einen
großen Teil davon steht der Delta-Block **noch leer**: Der finale Screen ist
noch nicht entworfen, die Route läuft bis dahin auf Interim-UI.
**Für die Einordnung dieses Prototyps heißt das dreierlei:**
- Das Dashboard ist **ein Baustein unter vielen**, nicht die Leitgestaltung der
Anwendung. Es fügt sich in eine App-Shell und ein Design-System ein, die
bereits stehen.
- Was hier an Anordnung und Raster festgelegt ist, ist ein **Vorschlag**. Die
Layout-Hoheit liegt beim Designer — der Brief zu P11 sagt ausdrücklich
„Anordnung = Davids freie Hand". Verbindlich zu übergeben sind die
**Kennzahl-Definitionen, die Chart-Regeln und das Verhalten**, nicht die
Pixelanordnung.
- Andere Bereiche der Anwendung sehen heute noch nicht final aus, weil sie
entweder auf Interim-UI laufen oder noch beim Designer liegen. Ein Vergleich
„unser Dashboard gegen den aktuellen Screen X" misst deshalb nicht
zwangsläufig gegen den Zielzustand.
---
## 6. Wenn du dir das Zielsystem selbst ansehen willst
```sh
corepack enable && pnpm install
pnpm dev:services # Postgres + Redis
pnpm dev:services:reset # Wegwerf-DB inkl. Migrationen und Demo-Daten
pnpm dev:local
```
Tenant-App auf `http://app.localhost:3000`. Die lokalen Demo-Zugänge (Tenant
„HiveGrid Demo SHK") stehen im README von HiveGrid Cloud unter „App starten" —
sie werden hier bewusst nicht wiederholt. Die Demo-Daten sind zunächst
ausgeblendet und erscheinen über **„Demo-Daten anzeigen"** oder `?demo=show`.
Der Weg zum Dashboard ist nach dem Login die erste Seite der Tenant-App.
Für einen schnellen Überblick ohne Setup:
`docs/ui-inventory/surface-map.md` listet alle stabilen Oberflächen mit ihren
Quelldateien, `docs/route-inventory.md` die Größenordnung des Routenbestands.
+13
View File
@@ -0,0 +1,13 @@
import next from "eslint-config-next";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...next,
...nextTypescript,
{
// Die Subframe-Bibliothek wird unverändert übernommen und nicht geprüft.
ignores: ["ui/**", ".next/**", "node_modules/**"],
},
];
export default eslintConfig;
@@ -0,0 +1,107 @@
"use client";
import React from "react";
import {
FeatherAlertTriangle,
FeatherArrowRight,
FeatherCheckCircle,
FeatherInfo,
} from "@subframe/core";
import { STATUS_FARBEN } from "../../utils/chartTheme";
import { formatEuro } from "../../utils/formatters";
import { useToast } from "../../context/ToastContext";
import type { AlertEintrag } from "../../data/calculations";
/**
* Handlungsbedarf-Liste (COMP-08).
*
* Jede Zeile nennt drei Dinge: was los ist, um wie viel Geld es geht und was
* zu tun ist. Ohne den Betrag lassen sich die Zeilen nicht gegeneinander
* abwägen, ohne die Handlung ist die Liste ein Bericht statt einer Arbeitsliste.
* Der Absprung in die jeweilige Arbeitsliste ist im Prototyp eine Attrappe.
*/
export function AlertList({
eintraege,
leerText = "Keine offenen Punkte",
/** Zwei Spalten, wenn die Karte über die volle Seitenbreite läuft. */
spalten = 1,
}: {
eintraege: AlertEintrag[];
leerText?: string;
spalten?: 1 | 2;
}) {
const { zeigeToast } = useToast();
const rang = { kritisch: 0, warnung: 1, hinweis: 2 } as const;
const sortiert = [...eintraege].sort((a, b) => rang[a.art] - rang[b.art]);
if (!sortiert.length) {
return (
<div className="flex h-full min-h-[180px] flex-col items-center justify-center gap-2 text-center">
<FeatherCheckCircle className="text-[22px]" style={{ color: STATUS_FARBEN.positiv }} />
<span className="text-[13px] text-text-neutral-tertiary">{leerText}</span>
</div>
);
}
return (
// Container-Query statt Viewport: dieselbe Liste steht mal über die volle
// Seitenbreite und mal in einer Drittelkarte. Der Viewport weiß davon nichts.
<ul
className={`@container ${
spalten === 2 ? "grid grid-cols-1 gap-x-8 xl:grid-cols-2" : "flex flex-col"
}`}
>
{sortiert.map((eintrag) => {
const farbe =
eintrag.art === "kritisch"
? STATUS_FARBEN.negativ
: eintrag.art === "warnung"
? STATUS_FARBEN.warnung
: STATUS_FARBEN.info;
return (
<li key={eintrag.id}>
<button
onClick={() =>
zeigeToast(eintrag.aktion, `${eintrag.text} — Absprung folgt in der Umsetzung`)
}
className="group flex w-full items-center gap-3 border-b border-solid border-border-default py-3 text-left last:border-b-0 hover:bg-neutral-50"
>
<span className="flex-none" style={{ color: farbe }}>
{eintrag.art === "hinweis" ? (
<FeatherInfo className="text-[16px]" />
) : (
<FeatherAlertTriangle className="text-[16px]" />
)}
</span>
<span className="flex min-w-0 grow flex-col">
<span className="text-[13px] font-medium leading-snug text-text-neutral-primary">
{eintrag.text}
</span>
<span className="text-[12px] leading-snug text-text-neutral-tertiary">
{eintrag.detail}
</span>
</span>
{eintrag.betrag !== undefined ? (
<span className="flex-none whitespace-nowrap text-[14px] font-semibold tabular-nums text-text-neutral-primary">
{formatEuro(eintrag.betrag)}
</span>
) : null}
{/* Schmale Karte: nur der Pfeil, die Handlung steht im Tooltip. */}
<span
title={eintrag.aktion}
className="flex flex-none items-center justify-end gap-1 whitespace-nowrap text-[12px] font-medium text-text-neutral-tertiary group-hover:text-text-neutral-primary @md:w-[150px]"
>
<span className="hidden @md:inline">{eintrag.aktion}</span>
<FeatherArrowRight className="text-[13px]" />
</span>
</button>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,144 @@
"use client";
import React from "react";
import {
FeatherDownload,
FeatherEyeOff,
FeatherInfo,
FeatherMoreHorizontal,
} from "@subframe/core";
import { useWidgets } from "../../context/WidgetContext";
import { useToast } from "../../context/ToastContext";
/**
* Rahmen für jede Visualisierung (COMP-09).
*
* Enthält die einzige "echte" Interaktionslogik des Prototyps:
* Über das ⋯-Menü lässt sich die Karte ausblenden; sie taucht dann in der
* Filterleiste zum Wiedereinblenden auf. Der Zustand ist persistiert.
*/
export function ChartCard({
id,
titel,
untertitel,
hinweis,
aktion,
legende,
children,
className = "",
}: {
/** Stabile ID — Schlüssel für Ausblenden/Einblenden. */
id: string;
titel: string;
untertitel?: string;
/** Erklärt die Kennzahl; erscheint als Tooltip am (i). */
hinweis?: string;
aktion?: React.ReactNode;
legende?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
const { istSichtbar, verstecke } = useWidgets();
const { zeigeToast } = useToast();
const [menuOffen, setMenuOffen] = React.useState(false);
const menu = React.useRef<HTMLDivElement>(null);
const ausloeser = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (!menuOffen) return;
const schliesse = (event: MouseEvent) => {
if (!menu.current?.contains(event.target as Node)) setMenuOffen(false);
};
// Escape schliesst und gibt den Fokus an den Ausloeser zurueck — sonst
// landet die Tastaturbedienung nach dem Schliessen am Seitenanfang.
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOffen(false);
ausloeser.current?.focus();
}
};
document.addEventListener("mousedown", schliesse);
document.addEventListener("keydown", taste);
return () => {
document.removeEventListener("mousedown", schliesse);
document.removeEventListener("keydown", taste);
};
}, [menuOffen]);
if (!istSichtbar(id)) return null;
return (
<section
className={`flex flex-col overflow-hidden rounded-lg border border-solid border-border-default bg-white shadow-sm ${className}`}
>
<header className="flex flex-none items-start justify-between gap-3 px-4 pb-3 pt-4">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<h2 className="truncate text-[14px] font-semibold leading-tight text-text-neutral-primary">
{titel}
</h2>
{hinweis ? (
<span
title={hinweis}
className="flex h-4 w-4 flex-none cursor-help items-center justify-center text-text-neutral-subtle hover:text-text-neutral-secondary"
>
<FeatherInfo className="text-[13px]" />
</span>
) : null}
</div>
{untertitel ? (
<p className="line-clamp-2 text-[12px] leading-snug text-text-neutral-tertiary">
{untertitel}
</p>
) : null}
</div>
<div className="dv-no-print relative flex flex-none items-center gap-1" ref={menu}>
{aktion}
<button
ref={ausloeser}
onClick={() => setMenuOffen((wert) => !wert)}
aria-label={`Optionen für ${titel}`}
aria-haspopup="menu"
aria-expanded={menuOffen}
className="flex h-8 w-8 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100 hover:text-text-neutral-primary"
>
<FeatherMoreHorizontal className="text-[16px]" />
</button>
{menuOffen ? (
<div
role="menu"
className="absolute right-0 top-9 z-40 flex w-52 flex-col rounded-lg border border-solid border-border-default bg-white py-1 shadow-lg"
>
<button
onClick={() => {
verstecke(id, titel);
setMenuOffen(false);
}}
className="flex items-center gap-2 px-3 py-2 text-left text-[13px] text-text-neutral-primary hover:bg-neutral-50"
>
<FeatherEyeOff className="text-[14px] text-text-neutral-tertiary" />
Widget ausblenden
</button>
<button
onClick={() => {
zeigeToast("Als Bild vorgemerkt", `${titel} — Export folgt in der Umsetzung`);
setMenuOffen(false);
}}
className="flex items-center gap-2 px-3 py-2 text-left text-[13px] text-text-neutral-primary hover:bg-neutral-50"
>
<FeatherDownload className="text-[14px] text-text-neutral-tertiary" />
Als Bild exportieren
</button>
</div>
) : null}
</div>
</header>
{legende ? <div className="flex-none px-4 pb-2">{legende}</div> : null}
<div className="min-h-0 grow px-4 pb-4">{children}</div>
</section>
);
}
@@ -0,0 +1,139 @@
"use client";
import React from "react";
import { FeatherArrowDownRight, FeatherArrowUpRight, FeatherMinus } from "@subframe/core";
import { FeatherIcon } from "../layout/FeatherIcon";
import { Sparkline } from "../charts/Sparkline";
import { CHART, STATUS_FARBEN } from "../../utils/chartTheme";
/** Zeitbezug einer Kennzahl — siehe docs/KPI-KATALOG.md. */
export type Bezug = "zeitraum" | "stichtag" | "gesamt";
const BEZUG_LABEL: Record<Bezug, string> = {
zeitraum: "im gewählten Zeitraum",
stichtag: "zum Stichtag",
gesamt: "über alle Zeiträume",
};
/**
* KPI-Karte nach UI_UX_GUIDELINES.md, Abschnitt 4:
* Label oben links · Icon oben rechts · Wert · Zusatz · Fußzeile.
*
* Alle Karten einer Reihe haben dieselbe Anatomie — auch die ohne Trend.
* Wo kein Vorperiodenvergleich möglich ist (stichtagsbezogene Kennzahlen),
* trägt die Fußzeile den Zeitbezug. Ohne das steht in einer Reihe eine
* Karte mit Trendzeile neben vier Karten mit leerem Fuß, und das Auge
* sucht eine Regel, die es nicht gibt.
*
* Der Trend vergleicht immer mit der gleich langen Vorperiode
* (30 Tage gegen die 30 Tage davor) — nicht mit dem Vorjahr.
*/
export function KPICard({
label,
wert,
zusatz,
trend,
trendLabel = "Vorperiode",
bezug = "zeitraum",
icon,
verlauf,
akzent = "neutral",
/** Bei "positiv" ist ein steigender Wert gut, bei "negativ" schlecht. */
richtungGut = "positiv",
}: {
label: string;
wert: string;
zusatz?: string;
trend?: { wert: number; richtung: "up" | "down" | "flat" };
trendLabel?: string;
bezug?: Bezug;
icon: string;
verlauf?: number[];
/**
* Färbt das Icon. Nur setzen, wenn die Farbe aus einem Schwellenvergleich
* kommt — ein dekorativ grünes Icon behauptet "gut", ohne es zu belegen
* (docs/DESIGN-SYSTEM.md §1: Statusfarben nur für Aussagen).
*/
akzent?: "neutral" | "positiv" | "warnung" | "negativ" | "info";
richtungGut?: "positiv" | "negativ";
}) {
const gut =
trend?.richtung === "flat"
? null
: richtungGut === "positiv"
? trend?.richtung === "up"
: trend?.richtung === "down";
const trendFarbe =
gut === null ? STATUS_FARBEN.neutral : gut ? STATUS_FARBEN.positiv : STATUS_FARBEN.negativ;
const akzentFarbe = {
neutral: CHART.textSekundaer,
positiv: STATUS_FARBEN.positiv,
warnung: STATUS_FARBEN.warnung,
negativ: STATUS_FARBEN.negativ,
info: STATUS_FARBEN.info,
}[akzent];
return (
<article className="flex min-h-[136px] flex-col rounded-lg border border-solid border-border-default bg-white p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<span className="text-[12px] font-medium leading-tight text-text-neutral-tertiary">
{label}
</span>
<span
className="flex h-8 w-8 flex-none items-center justify-center rounded-md bg-neutral-50"
style={{ color: akzentFarbe }}
>
<FeatherIcon name={icon} className="text-[15px]" />
</span>
</div>
<div className="mt-3 flex flex-col gap-1">
<span className="text-[28px] font-semibold leading-none tracking-[-0.02em] text-text-neutral-primary">
{wert}
</span>
{zusatz ? (
<span className="text-[12px] leading-snug text-text-neutral-tertiary">{zusatz}</span>
) : null}
</div>
{/* Fußzeile: immer vorhanden, damit die Reihe eine gemeinsame Grundlinie hat. */}
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
{trend ? (
<span
className="flex min-w-0 items-center gap-1 whitespace-nowrap text-[12px] font-medium"
style={{ color: trendFarbe }}
>
{trend.richtung === "up" ? (
<FeatherArrowUpRight className="text-[13px] flex-none" />
) : trend.richtung === "down" ? (
<FeatherArrowDownRight className="text-[13px] flex-none" />
) : (
<FeatherMinus className="text-[13px] flex-none" />
)}
{trend.richtung === "flat"
? "unverändert"
: `${trend.wert.toFixed(1).replace(".", ",")} %`}
<span className="hidden truncate font-normal text-text-neutral-tertiary xl:inline">
{trendLabel}
</span>
</span>
) : (
<span className="text-[11px] leading-tight text-text-neutral-tertiary">
{BEZUG_LABEL[bezug]}
</span>
)}
{verlauf && verlauf.length > 1 ? (
<span className="h-8 w-16 flex-none">
{/* Die Kurve trägt dieselbe Aussage wie der Pfeil daneben und
deshalb auch dieselbe Farbe — sonst zeigt die Karte zwei
widersprüchliche Signale. */}
<Sparkline werte={verlauf} farbe={trend ? trendFarbe : CHART.achse} />
</span>
) : null}
</div>
</article>
);
}
@@ -0,0 +1,38 @@
import React from "react";
/**
* Kompakte Zusatzkennzahlen unter einem Chart —
* beantwortet "und was heißt das jetzt?" ohne eigene Karte.
*/
export function StatZeile({
eintraege,
/**
* Spalten auf breiten Schirmen. In einer Drittelkarte brechen vier Spalten
* mitten im Betrag um ("1,22 Mio." / "€") — dort gehoeren zwei hin.
*/
spalten = 4,
}: {
eintraege: { label: string; wert: string; hinweis?: string }[];
spalten?: 2 | 3 | 4;
}) {
const raster = { 2: "md:grid-cols-2", 3: "md:grid-cols-3", 4: "md:grid-cols-4" }[spalten];
return (
<div
className={`grid grid-cols-2 gap-x-6 gap-y-3 border-t border-solid border-border-default pt-3 ${raster}`}
>
{eintraege.map((eintrag) => (
<div key={eintrag.label} className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-[0.06em] text-text-neutral-tertiary">
{eintrag.label}
</span>
<span className="text-[14px] font-semibold tabular-nums text-text-neutral-primary">
{eintrag.wert}
</span>
{eintrag.hinweis ? (
<span className="text-[11px] text-text-neutral-tertiary">{eintrag.hinweis}</span>
) : null}
</div>
))}
</div>
);
}
@@ -0,0 +1,131 @@
"use client";
import React from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { ChartLeer } from "./ChartLeer";
import {
CHART,
MARGIN,
MAX_ACHSENLABELS,
PRIMAER_FARBE,
VERGLEICH_FARBE,
achseX,
achseY,
nettesRaster,
} from "../../utils/chartTheme";
import type { ZeitreihePunkt } from "../../data/types";
/**
* Zeitverlauf als Fläche mit optionaler Vergleichslinie (Vorjahr).
*
* Bewusst `ComposedChart`, nicht `AreaChart`: Recharts sammelt in einem
* AreaChart ausschließlich `Area`-Kinder ein (generateCategoricalChart mit
* GraphicalChild = Area). Ein `<Line>` darin wird stillschweigend verworfen —
* die Vergleichsreihe stand monatelang in der Legende, ohne je gezeichnet zu
* werden. ComposedChart nimmt beide Typen.
*
* Carbon-Regeln, die hier umgesetzt sind:
* - nur horizontale Gridlines (#e0e0e0), vertikale lenken ab
* - keine Tick-Marks, Achsenbeschriftung 12 px in #525252
* - lineare Interpolation: eine Monotone-Kurve erfindet Zwischenwerte
* zwischen zwei Messpunkten, die es nicht gibt
* - Vergleichsreihe gestrichelt und neutral, damit sie die Hauptreihe
* nicht um Aufmerksamkeit bringt
*/
export function AreaVerlauf({
daten,
formatWert,
/** Kürzere Schreibweise für die Y-Achse; ohne Angabe wie formatWert. */
formatAchse,
hoehe = 260,
zeigeVergleich = false,
farbe = PRIMAER_FARBE,
labelPrefix,
}: {
daten: ZeitreihePunkt[];
formatWert: (wert: number) => string;
formatAchse?: (wert: number) => string;
hoehe?: number;
zeigeVergleich?: boolean;
farbe?: string;
labelPrefix?: string;
}) {
// Zwei Verläufe auf einer Seite dürfen sich keinen Gradienten teilen.
const gradientId = `verlauf-${React.useId().replace(/:/g, "")}`;
const hatWerte = daten.some((d) => d.value > 0 || (d.vergleich ?? 0) > 0);
if (!daten.length || !hatWerte) return <ChartLeer hoehe={hoehe} />;
// Bei vielen Punkten nur jede n-te Beschriftung zeigen, sonst überlappen sie.
const intervall =
daten.length > MAX_ACHSENLABELS ? Math.ceil(daten.length / MAX_ACHSENLABELS) - 1 : 0;
const maximum = Math.max(
...daten.map((d) => Math.max(d.value, zeigeVergleich ? (d.vergleich ?? 0) : 0))
);
const raster = nettesRaster(maximum);
return (
<div style={{ height: hoehe }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={daten} margin={MARGIN.standard}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={farbe} stopOpacity={0.24} />
<stop offset="100%" stopColor={farbe} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={CHART.grid} vertical={false} />
<XAxis dataKey="label" interval={intervall} {...achseX} />
<YAxis
domain={raster.domain}
ticks={raster.ticks}
tickFormatter={formatAchse ?? formatWert}
{...achseY}
/>
<Tooltip
cursor={{ stroke: CHART.achse, strokeWidth: 1, strokeDasharray: "3 3" }}
content={(props) => (
<ChartTooltip {...props} formatWert={formatWert} labelPrefix={labelPrefix} />
)}
/>
<Area
type="linear"
dataKey="value"
name="Aktueller Zeitraum"
stroke={farbe}
strokeWidth={2}
fill={`url(#${gradientId})`}
dot={false}
activeDot={{ r: 4, fill: farbe, stroke: "#fff", strokeWidth: 2 }}
/>
{zeigeVergleich ? (
<Line
type="linear"
dataKey="vergleich"
name="Vorjahr"
stroke={VERGLEICH_FARBE}
strokeWidth={1.5}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4, fill: VERGLEICH_FARBE, stroke: "#fff", strokeWidth: 2 }}
/>
) : null}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import React from "react";
import { CHART, STATUS_FARBEN } from "../../utils/chartTheme";
import { ChartLeer } from "./ChartLeer";
/**
* Auslastung je Person als Punktdiagramm mit Zielkorridor.
*
* Warum kein Balken: Auslastungswerte liegen im Betrieb zwischen etwa 70 und
* 100 %. Ein Balken muss bei 0 beginnen (sonst verzerrt die Länge), und dann
* sind 74 % und 96 % optisch fast gleich lang — das Diagramm zeigt nichts.
* Ein Punkt kodiert über die Position, nicht über die Länge, und darf deshalb
* einen ausgeschnittenen Bereich zeigen. Carbon nennt das Dot-Plot.
*
* Die Schwellen sind betrieblich gedacht, nicht mathematisch:
* < 70 % Luft frei — Kapazität ungenutzt
* 70–95 % Zielkorridor
* > 95 % ausgebucht — Überlastrisiko, Termine gefährdet
* Der Korridor liegt als graues Band hinter den Punkten, die 100-%-Marke als
* beschriftete Linie. Damit erklärt sich die Grafik ohne Ampel-Legende.
*/
const SCHWELLEN = { frei: 70, ausgebucht: 95 };
export function AuslastungsBalken({
zeilen,
maxZeilen = 8,
}: {
zeilen: { name: string; wert: number; zusatz?: string }[];
maxZeilen?: number;
}) {
const [aktiv, setAktiv] = React.useState<string | null>(null);
const sichtbar = zeilen.slice(0, maxZeilen);
if (!sichtbar.length) return <ChartLeer hoehe={200} />;
// Skala umschließt die Daten, bleibt aber mindestens 60–110 %, damit der
// Zielkorridor immer als Fläche erkennbar ist.
const min = Math.min(60, Math.floor(Math.min(...sichtbar.map((z) => z.wert)) / 10) * 10);
const max = Math.max(110, Math.ceil(Math.max(...sichtbar.map((z) => z.wert)) / 10) * 10);
const pos = (wert: number) => ((Math.min(Math.max(wert, min), max) - min) / (max - min)) * 100;
const ticks = [];
for (let t = min; t <= max; t += 10) ticks.push(t);
return (
<div className="flex flex-col gap-2">
{sichtbar.map((zeile) => {
const farbe =
zeile.wert < SCHWELLEN.frei
? STATUS_FARBEN.info
: zeile.wert <= SCHWELLEN.ausgebucht
? STATUS_FARBEN.positiv
: STATUS_FARBEN.warnung;
const hervorgehoben = aktiv === zeile.name;
return (
<div
key={zeile.name}
onMouseEnter={() => setAktiv(zeile.name)}
onMouseLeave={() => setAktiv(null)}
className="grid grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-[12px] text-text-neutral-secondary">{zeile.name}</span>
{zeile.zusatz ? (
<span className="truncate text-[11px] text-text-neutral-tertiary">
{zeile.zusatz}
</span>
) : null}
</span>
<span className="relative block h-5 w-full">
{/* Zielkorridor als ruhiges Band hinter allen Zeilen */}
<span
className="absolute top-1/2 h-4 -translate-y-1/2 rounded-sm bg-neutral-100"
style={{
left: `${pos(SCHWELLEN.frei)}%`,
width: `${pos(SCHWELLEN.ausgebucht) - pos(SCHWELLEN.frei)}%`,
}}
aria-hidden="true"
/>
{/* Grundlinie */}
<span
className="absolute top-1/2 h-px w-full -translate-y-1/2"
style={{ background: CHART.grid }}
aria-hidden="true"
/>
{/* 100-%-Marke */}
<span
className="absolute top-0 h-full w-px"
style={{ left: `${pos(100)}%`, background: CHART.achse }}
aria-hidden="true"
/>
<span
className="absolute top-1/2 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-white transition-opacity duration-200"
style={{
left: `${pos(zeile.wert)}%`,
background: farbe,
opacity: aktiv && !hervorgehoben ? 0.45 : 1,
}}
title={`${zeile.name}: ${zeile.wert.toFixed(0)} %`}
/>
</span>
<span className="text-right text-[12px] font-semibold tabular-nums text-text-neutral-primary">
{zeile.wert.toFixed(0)} %
</span>
</div>
);
})}
{/* Achse statt Farblegende — die Zonen erklären sich über das Band. */}
<div className="grid grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3 pt-1">
<span />
<span className="relative block h-4">
{ticks.map((t) => (
<span
key={t}
className="absolute top-0 -translate-x-1/2 text-[11px] tabular-nums text-text-neutral-tertiary"
style={{ left: `${pos(t)}%` }}
>
{t}
</span>
))}
</span>
<span className="text-right text-[11px] text-text-neutral-tertiary">%</span>
</div>
<p className="border-t border-solid border-border-default pt-2 text-[11px] text-text-neutral-tertiary">
Graues Band = Zielkorridor {SCHWELLEN.frei}–{SCHWELLEN.ausgebucht} %, Linie = 100 % der
vertraglichen Stunden.
</p>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import React from "react";
import { farbeFuer, farbe as paletteFarbe } from "../../utils/chartTheme";
import { ChartLeer } from "./ChartLeer";
/**
* Horizontale Balken für Rangfolgen (Top-Kunden, Aufträge je Gewerk).
*
* Bewusst als HTML/CSS statt Recharts: Kategorienamen sind hier lang und
* unterschiedlich breit; im SVG müsste man sie hart abschneiden. In HTML
* bleiben sie umbruchfähig, selektierbar und barrierefrei lesbar.
* Hover hebt den Balken hervor und zeigt den vollen Namen als Titel.
*/
export function BalkenHorizontal({
daten,
formatWert,
maxBalken = 6,
/**
* Einfarbig für Rangfolgen (Top-Kunden, Aufträge je Mitarbeiter):
* Dort trägt die Farbe keine Information und würde nur Unruhe erzeugen.
* Palette nur, wenn die Balken echte Kategorien sind.
*/
einfarbig = true,
farbeStart = 1,
/**
* Zweitwert rechts neben dem Hauptwert — z. B. Auftragswert neben der
* Anzahl Abschlüsse. Erspart eine zweite Karte mit derselben Kodierung
* auf derselben Personenachse.
*/
formatZweitwert,
}: {
daten: { name: string; value: number; zweitwert?: number }[];
formatWert: (wert: number) => string;
maxBalken?: number;
einfarbig?: boolean;
farbeStart?: number;
formatZweitwert?: (wert: number) => string;
}) {
const [aktiv, setAktiv] = React.useState<string | null>(null);
const sichtbar = [...daten]
.filter((d) => d.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, maxBalken);
if (!sichtbar.length) return <ChartLeer hoehe={180} />;
const max = Math.max(...sichtbar.map((d) => d.value));
return (
<ul className="flex flex-col gap-2.5">
{sichtbar.map((eintrag, index) => {
const anteil = max > 0 ? (eintrag.value / max) * 100 : 0;
const balkenFarbe = einfarbig
? paletteFarbe(1)
: farbeFuer(eintrag.name, farbeStart + index);
const hervorgehoben = aktiv === eintrag.name;
return (
<li
key={eintrag.name}
title={eintrag.name}
onMouseEnter={() => setAktiv(eintrag.name)}
onMouseLeave={() => setAktiv(null)}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1"
>
<span className="min-w-0 truncate text-[12px] text-text-neutral-secondary">
{eintrag.name}
</span>
<span className="flex items-baseline gap-2 whitespace-nowrap tabular-nums">
<span className="text-[12px] font-semibold text-text-neutral-primary">
{formatWert(eintrag.value)}
</span>
{formatZweitwert && eintrag.zweitwert !== undefined ? (
<span className="text-[12px] text-text-neutral-tertiary">
{formatZweitwert(eintrag.zweitwert)}
</span>
) : null}
</span>
<span className="col-span-2 block h-2.5 w-full overflow-hidden rounded-sm bg-neutral-100">
<span
className="block h-full rounded-r-sm transition-[width,opacity] duration-300"
style={{
width: `${Math.max(2, anteil)}%`,
background: balkenFarbe,
opacity: aktiv && !hervorgehoben ? 0.45 : 1,
}}
/>
</span>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,106 @@
"use client";
import React from "react";
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { ChartLeer } from "./ChartLeer";
import {
BALKEN,
CHART,
MARGIN,
MAX_ACHSENLABELS,
PRIMAER_FARBE,
achseX,
achseY,
nettesRaster,
} from "../../utils/chartTheme";
/**
* Vertikale Balken für Mengen über Zeit oder Kategorien mit Nulllinie.
* Balkenbreite ist gedeckelt (Carbon: Abstand ≥ halbe Balkenbreite),
* damit vier Balken nicht wie Flächen wirken.
*/
export function BalkenVertikal({
daten,
formatWert,
/** Kürzere Schreibweise für die Y-Achse; ohne Angabe wie formatWert. */
formatAchse,
hoehe = 240,
farbe = PRIMAER_FARBE,
/** Einzelne Balken einfärben, z. B. Alters-Buckets von grün nach rot. */
farbenProBalken,
/** Bei Stückzahlen: keine Zwischenwerte auf der Achse ("1, 1, 2, 2"). */
nurGanzzahlen = false,
labelPrefix,
}: {
daten: { name?: string; label?: string; value: number }[];
formatWert: (wert: number) => string;
formatAchse?: (wert: number) => string;
hoehe?: number;
farbe?: string;
farbenProBalken?: string[];
nurGanzzahlen?: boolean;
labelPrefix?: string;
}) {
const [aktiv, setAktiv] = React.useState<number | null>(null);
const punkte = daten.map((d) => ({ label: d.label ?? d.name ?? "", value: d.value }));
if (!punkte.length || !punkte.some((p) => p.value > 0)) return <ChartLeer hoehe={hoehe} />;
const intervall =
punkte.length > MAX_ACHSENLABELS ? Math.ceil(punkte.length / MAX_ACHSENLABELS) - 1 : 0;
// Ganzzahlreihen brauchen ganzzahlige Ticks — sonst steht "1,5 Vorgänge".
const maximum = Math.max(...punkte.map((p) => p.value));
const raster = nurGanzzahlen
? undefined
: nettesRaster(maximum);
return (
<div style={{ height: hoehe }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={punkte}
margin={MARGIN.standard}
barCategoryGap={BALKEN.kategorieAbstand}
onMouseMove={(zustand) =>
setAktiv(typeof zustand?.activeTooltipIndex === "number" ? zustand.activeTooltipIndex : null)
}
onMouseLeave={() => setAktiv(null)}
>
<CartesianGrid stroke={CHART.grid} vertical={false} />
<XAxis dataKey="label" interval={intervall} {...achseX} />
<YAxis
tickFormatter={formatAchse ?? formatWert}
allowDecimals={!nurGanzzahlen}
{...(raster ? { domain: raster.domain, ticks: raster.ticks } : {})}
{...achseY}
/>
<Tooltip
cursor={{ fill: "rgba(22,22,22,0.04)" }}
content={(props) => (
<ChartTooltip {...props} formatWert={formatWert} labelPrefix={labelPrefix} />
)}
/>
<Bar
dataKey="value"
name="Wert"
fill={farbe}
maxBarSize={BALKEN.maxBreite}
radius={[BALKEN.radius, BALKEN.radius, 0, 0]}
>
{punkte.map((_, index) => (
<Cell
key={index}
fill={
farbenProBalken ? farbenProBalken[index % farbenProBalken.length] : farbe
}
opacity={aktiv !== null && aktiv !== index ? 0.45 : 1}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,21 @@
import React from "react";
import { FeatherBarChart2 } from "@subframe/core";
/** Leerzustand — wichtiger als er aussieht: Filter können jedes Chart leeren. */
export function ChartLeer({
text = "Keine Daten im gewählten Zeitraum",
hoehe = 220,
}: {
text?: string;
hoehe?: number;
}) {
return (
<div
className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-default"
style={{ height: hoehe }}
>
<FeatherBarChart2 className="text-[20px] text-text-neutral-subtle" />
<span className="text-[13px] text-text-neutral-tertiary">{text}</span>
</div>
);
}
@@ -0,0 +1,64 @@
"use client";
import React from "react";
/**
* Legende als eigene Komponente statt Recharts-Legend:
* Recharts platziert die Legende im SVG und kostet damit Chartfläche.
* Carbon setzt sie über den Chart, linksbündig, 12 px, 12 px Marker.
*/
export function ChartLegende({
eintraege,
/** Ausgeblendete Reihen erscheinen ausgegraut — Klick schaltet um. */
inaktiv = [],
onToggle,
}: {
eintraege: { name: string; farbe: string; wert?: string; gestrichelt?: boolean }[];
inaktiv?: string[];
onToggle?: (name: string) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
{eintraege.map((eintrag) => {
const aus = inaktiv.includes(eintrag.name);
const Inhalt = (
<>
<span
className="h-2.5 w-2.5 flex-none rounded-sm"
style={{
background: eintrag.gestrichelt ? "transparent" : eintrag.farbe,
border: eintrag.gestrichelt ? `2px dashed ${eintrag.farbe}` : undefined,
opacity: aus ? 0.35 : 1,
}}
/>
<span
className="text-[12px]"
style={{ color: aus ? "#a8a8a8" : "#525252" }}
>
{eintrag.name}
</span>
{eintrag.wert ? (
<span className="text-[12px] font-semibold tabular-nums text-text-neutral-primary">
{eintrag.wert}
</span>
) : null}
</>
);
return onToggle ? (
<button
key={eintrag.name}
onClick={() => onToggle(eintrag.name)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 hover:bg-neutral-50"
>
{Inhalt}
</button>
) : (
<span key={eintrag.name} className="flex items-center gap-1.5">
{Inhalt}
</span>
);
})}
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { CHART } from "../../utils/chartTheme";
/**
* Einheitlicher Tooltip für alle Charts.
*
* Carbon-Vorgabe: dunkle Fläche (#393939), heller Text, 12 px Label,
* kein Rahmen, kein Doppelpunkt zwischen Name und Wert.
* Ein gemeinsamer Tooltip verhindert, dass jedes Chart eigene Regeln erfindet.
*
* Die Props sind bewusst weit typisiert: Recharts reicht sein internes
* Payload-Format durch, das je nach Chart-Typ leicht abweicht.
*/
interface TooltipPayload {
name?: unknown;
value?: unknown;
color?: string;
dataKey?: unknown;
}
export function ChartTooltip({
active,
label,
payload,
formatWert,
labelPrefix,
}: {
active?: boolean;
label?: unknown;
payload?: readonly TooltipPayload[];
formatWert: (wert: number) => string;
labelPrefix?: string;
}) {
if (!active || !payload?.length) return null;
const reihen = payload
.filter((eintrag) => typeof eintrag.value === "number")
.map((eintrag) => ({
name: String(eintrag.name ?? eintrag.dataKey ?? ""),
value: eintrag.value as number,
color: eintrag.color,
}));
if (!reihen.length) return null;
const labelText = label === undefined || label === null ? "" : String(label);
return (
<div
className="pointer-events-none rounded-md px-3 py-2 shadow-lg"
style={{ background: CHART.tooltipBg, color: CHART.tooltipFg }}
>
{labelText ? (
<div className="mb-1 text-[12px] opacity-80">
{labelPrefix ? `${labelPrefix} ` : ""}
{labelText}
</div>
) : null}
<div className="flex flex-col gap-0.5">
{reihen.map((reihe) => (
<div key={reihe.name} className="flex items-center gap-2 text-[13px]">
{reihe.color ? (
<span
className="h-2.5 w-2.5 flex-none rounded-sm"
style={{ background: reihe.color }}
/>
) : null}
<span className="opacity-80">{reihe.name}</span>
<span className="ml-auto font-semibold tabular-nums">{formatWert(reihe.value)}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import React from "react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { ChartLeer } from "./ChartLeer";
import { DONUT, farbeFuer, gruppiereRest } from "../../utils/chartTheme";
/**
* Donut für Anteile.
*
* Regeln aus der Planung + Carbon:
* - maximal 5 Segmente, Rest als "Sonstige" (gruppiereRest)
* - größtes Segment startet oben, dann im Uhrzeigersinn absteigend
* - Gesamtwert steht in der Mitte, sonst muss man die Segmente addieren
* - Legende rechts mit Wert, einzeilig — nicht im Chart
* - Klick auf einen Legendeneintrag hebt das Segment hervor
*/
export function Donut({
daten,
formatWert,
hoehe = 220,
mitteLabel = "Gesamt",
maxSegmente,
/**
* Format der Ringmitte. Getrennt vom Legendenformat, weil die Mitte eine
* Schlagzahl ist: Steht dieselbe Summe als Kennzahl auf der Seite, muss sie
* gleich geschrieben sein — "285,3 T€" neben "285.318 €" liest sich wie
* zwei verschiedene Zahlen.
*/
formatMitte,
}: {
daten: { name: string; value: number }[];
formatWert: (wert: number) => string;
hoehe?: number;
mitteLabel?: string;
maxSegmente?: number;
formatMitte?: (wert: number) => string;
}) {
const [aktiv, setAktiv] = React.useState<string | null>(null);
const segmente = gruppiereRest(daten.filter((d) => d.value > 0), maxSegmente);
if (!segmente.length) return <ChartLeer hoehe={hoehe} />;
const gesamt = segmente.reduce((summe, s) => summe + s.value, 0);
return (
<div className="grid grid-cols-[168px_minmax(0,1fr)] items-center gap-5">
<div className="relative" style={{ height: hoehe }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segmente}
dataKey="value"
nameKey="name"
innerRadius={DONUT.innenRadius}
outerRadius={DONUT.aussenRadius}
paddingAngle={DONUT.segmentAbstand}
startAngle={90}
endAngle={-270}
stroke="#ffffff"
strokeWidth={2}
isAnimationActive={false}
>
{segmente.map((segment, index) => (
<Cell
key={segment.name}
fill={farbeFuer(segment.name, index)}
opacity={aktiv && aktiv !== segment.name ? 0.35 : 1}
/>
))}
</Pie>
<Tooltip content={(props) => <ChartTooltip {...props} formatWert={formatWert} />} />
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] uppercase tracking-[0.06em] text-text-neutral-tertiary">
{mitteLabel}
</span>
<span className="text-[15px] font-semibold tabular-nums text-text-neutral-primary">
{(formatMitte ?? formatWert)(gesamt)}
</span>
</div>
</div>
<ul className="flex flex-col gap-1.5">
{segmente.map((segment, index) => {
const anteil = gesamt > 0 ? (segment.value / gesamt) * 100 : 0;
return (
<li key={segment.name}>
<button
onMouseEnter={() => setAktiv(segment.name)}
onMouseLeave={() => setAktiv(null)}
onClick={() => setAktiv((wert) => (wert === segment.name ? null : segment.name))}
className="flex w-full items-start gap-2 rounded-sm px-1 py-1 text-left hover:bg-neutral-50"
>
<span
className="mt-1 h-2.5 w-2.5 flex-none rounded-sm"
style={{ background: farbeFuer(segment.name, index) }}
/>
<span className="min-w-0 grow text-[12px] leading-snug text-text-neutral-secondary">
{segment.name}
</span>
<span className="flex-none whitespace-nowrap text-[12px] tabular-nums text-text-neutral-primary">
<span className="font-semibold">{formatWert(segment.value)}</span>
<span className="ml-1.5 text-text-neutral-tertiary">
{anteil.toFixed(0)} %
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import React from "react";
/**
* Miniaturverlauf in KPI-Karten.
* Bewusst als handgeschriebenes SVG: Recharts würde für 8 Punkte
* einen ResponsiveContainer samt Resize-Beobachter aufziehen.
*/
export function Sparkline({ werte, farbe }: { werte: number[]; farbe: string }) {
if (werte.length < 2) return null;
const max = Math.max(...werte);
const min = Math.min(...werte);
const spanne = Math.max(1, max - min);
const punkte = werte
.map((wert, index) => {
const x = (index / (werte.length - 1)) * 100;
const y = 28 - ((wert - min) / spanne) * 24;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
const id = `spark-${farbe.replace("#", "")}`;
return (
<svg viewBox="0 0 100 32" className="h-full w-full" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={farbe} stopOpacity="0.22" />
<stop offset="100%" stopColor={farbe} stopOpacity="0" />
</linearGradient>
</defs>
<polygon points={`0,32 ${punkte} 100,32`} fill={`url(#${id})`} />
<polyline
points={punkte}
fill="none"
stroke={farbe}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
@@ -0,0 +1,85 @@
"use client";
import React from "react";
import { KATEGORIE_FARBEN } from "../../utils/chartTheme";
import { ChartLeer } from "./ChartLeer";
/**
* Vertriebstrichter.
*
* Zeigt je Stufe die absolute Zahl, die Balkenlänge als Anteil an der ersten
* Stufe und die Übergangsquote von der vorherigen Stufe. Genau die
* Übergangsquote ist die Zahl, nach der Betriebe ihren Vertrieb steuern:
* Sie zeigt, wo Vorgänge verloren gehen.
*
* Einfarbig: Die Stufen sind eine Reihenfolge, keine unabhängigen Kategorien —
* DESIGN-SYSTEM.md §1 „Rangfolgen einfarbig". Eine Farbabstufung würde nur
* kodieren, was die Reihenfolge ohnehin sagt.
*
* Die Balken sind zentriert, nicht linksbündig: So liest man die Verjüngung als
* Trichterform, ohne die gemeinsame Skala aufzugeben. Ein echtes Funnel-Chart
* mit breitenkodierten Segmenten kennt Carbon bewusst nicht — es verzerrt.
*/
export function Trichter({
stufen,
formatWert,
}: {
stufen: { name: string; value: number }[];
formatWert: (wert: number) => string;
}) {
const [aktiv, setAktiv] = React.useState<number | null>(null);
if (!stufen.length || stufen[0].value === 0) return <ChartLeer hoehe={220} />;
const start = stufen[0].value;
return (
<ol className="flex flex-col gap-2">
{stufen.map((stufe, index) => {
const anteilGesamt = (stufe.value / start) * 100;
const vorher = index > 0 ? stufen[index - 1].value : null;
const uebergang = vorher && vorher > 0 ? (stufe.value / vorher) * 100 : null;
const hervorgehoben = aktiv === index;
return (
<li
key={stufe.name}
onMouseEnter={() => setAktiv(index)}
onMouseLeave={() => setAktiv(null)}
className="grid grid-cols-[96px_minmax(0,1fr)_52px_44px] items-center gap-2"
>
<span className="truncate text-[12px] text-text-neutral-secondary">{stufe.name}</span>
<span className="flex h-6 w-full items-center justify-center rounded-sm bg-neutral-100">
<span
className="block h-full rounded-sm transition-[width,opacity] duration-300"
style={{
width: `${Math.max(3, anteilGesamt)}%`,
background: KATEGORIE_FARBEN[0],
opacity: aktiv !== null && !hervorgehoben ? 0.45 : 1,
}}
/>
</span>
<span className="text-right text-[12px] font-semibold tabular-nums text-text-neutral-primary">
{formatWert(stufe.value)}
</span>
<span
className={`text-right text-[12px] tabular-nums ${
hervorgehoben ? "font-semibold text-text-neutral-primary" : "text-text-neutral-tertiary"
}`}
title={uebergang !== null ? "Übergang von der vorherigen Stufe" : "Startwert des Trichters"}
>
{uebergang !== null ? `${uebergang.toFixed(0)} %` : "—"}
</span>
</li>
);
})}
<li className="mt-1 flex items-center justify-end gap-1 border-t border-solid border-border-default pt-2 text-[11px] text-text-neutral-tertiary">
Rechte Spalte: Übergang von der vorherigen Stufe
</li>
</ol>
);
}
@@ -0,0 +1,203 @@
"use client";
import React from "react";
import { FeatherEye, FeatherRotateCcw, FeatherX } from "@subframe/core";
import { MehrfachAuswahl } from "./MehrfachAuswahl";
import { useFilter, type MehrfachFeld } from "../../context/FilterContext";
import { useWidgets } from "../../context/WidgetContext";
import { DATENSATZ } from "../../data/seed";
import { FILTER_RELEVANZ, type SeitenId } from "../../data/filtering";
import {
AUFTRAG_STATUS_LABEL,
GEWERKE,
ZEITRAUM_LABEL,
type AuftragStatus,
type ZeitraumPreset,
} from "../../data/types";
const ZEITRAEUME: ZeitraumPreset[] = ["heute", "7_tage", "30_tage", "quartal", "jahr", "alles"];
/**
* Filterleiste über dem Seiteninhalt.
*
* Zeigt nur die Filter, die auf der jeweiligen Seite etwas bewirken
* (FILTER_RELEVANZ) — ein Auftragsstatus-Filter auf der Finanzseite würde
* sonst wirkungslos wirken und Vertrauen kosten.
* Aktive Einschränkungen erscheinen darunter als entfernbare Chips (FILT-06).
*/
export function AnalyticsToolbar({ seite }: { seite: SeitenId }) {
const { filter, setFilter, toggleWert, leereFeld, zuruecksetzen } = useFilter();
const { versteckt, titel, zeige, zeigeAlle } = useWidgets();
const erlaubt = new Set(FILTER_RELEVANZ[seite]);
const quellen = React.useMemo(
() => [...new Set(DATENSATZ.kunden.map((k) => k.quelle))].sort(),
[]
);
const regionen = React.useMemo(
() => [...new Set(DATENSATZ.kunden.map((k) => k.region))].sort(),
[]
);
const monteure = React.useMemo(
() =>
DATENSATZ.mitarbeiter
.filter((m) => m.rolle === "Monteur" || m.rolle === "Meister")
.map((m) => ({ wert: m.id, label: m.name, zusatz: m.gewerk })),
[]
);
const chips: { feld: MehrfachFeld; wert: string; label: string }[] = [];
if (erlaubt.has("gewerke"))
chips.push(...filter.gewerke.map((g) => ({ feld: "gewerke" as const, wert: g, label: g })));
if (erlaubt.has("auftragStatus"))
chips.push(
...filter.auftragStatus.map((s) => ({
feld: "auftragStatus" as const,
wert: s,
label: AUFTRAG_STATUS_LABEL[s],
}))
);
if (erlaubt.has("kundenQuellen"))
chips.push(
...filter.kundenQuellen.map((q) => ({ feld: "kundenQuellen" as const, wert: q, label: q }))
);
if (erlaubt.has("regionen"))
chips.push(...filter.regionen.map((r) => ({ feld: "regionen" as const, wert: r, label: r })));
if (erlaubt.has("mitarbeiterIds"))
chips.push(
...filter.mitarbeiterIds.map((id) => ({
feld: "mitarbeiterIds" as const,
wert: id,
label: DATENSATZ.mitarbeiter.find((m) => m.id === id)?.name ?? id,
}))
);
return (
<div className="dv-no-print sticky top-14 z-20 flex flex-col gap-2 border-b border-solid border-border-default bg-neutral-50/95 px-6 py-3 backdrop-blur">
<div className="flex flex-wrap items-center gap-2">
{/* Zeitraum als Segment-Control: eine Auswahl, immer sichtbar. */}
<div className="flex items-center rounded-md border border-solid border-border-default bg-white p-0.5">
{ZEITRAEUME.map((preset) => (
<button
key={preset}
onClick={() => setFilter((aktuell) => ({ ...aktuell, zeitraum: preset }))}
aria-pressed={filter.zeitraum === preset}
className={`rounded px-2.5 py-1 text-[13px] font-medium transition-colors ${
filter.zeitraum === preset
? "bg-neutral-800 text-white"
: "text-text-neutral-secondary hover:bg-neutral-100"
}`}
>
{ZEITRAUM_LABEL[preset]}
</button>
))}
</div>
<span className="mx-1 h-6 w-px bg-border-default" />
{erlaubt.has("gewerke") ? (
<MehrfachAuswahl
label="Gewerk"
optionen={GEWERKE.map((g) => ({ wert: g, label: g }))}
ausgewaehlt={filter.gewerke}
onToggle={(wert) => toggleWert("gewerke", wert)}
onLeeren={() => leereFeld("gewerke")}
/>
) : null}
{erlaubt.has("auftragStatus") ? (
<MehrfachAuswahl
label="Status"
optionen={(Object.keys(AUFTRAG_STATUS_LABEL) as AuftragStatus[]).map((s) => ({
wert: s,
label: AUFTRAG_STATUS_LABEL[s],
}))}
ausgewaehlt={filter.auftragStatus}
onToggle={(wert) => toggleWert("auftragStatus", wert)}
onLeeren={() => leereFeld("auftragStatus")}
/>
) : null}
{erlaubt.has("kundenQuellen") ? (
<MehrfachAuswahl
label="Quelle"
optionen={quellen.map((q) => ({ wert: q, label: q }))}
ausgewaehlt={filter.kundenQuellen}
onToggle={(wert) => toggleWert("kundenQuellen", wert)}
onLeeren={() => leereFeld("kundenQuellen")}
/>
) : null}
{erlaubt.has("regionen") ? (
<MehrfachAuswahl
label="Region"
optionen={regionen.map((r) => ({ wert: r, label: r }))}
ausgewaehlt={filter.regionen}
onToggle={(wert) => toggleWert("regionen", wert)}
onLeeren={() => leereFeld("regionen")}
/>
) : null}
{erlaubt.has("mitarbeiterIds") ? (
<MehrfachAuswahl
label="Mitarbeiter"
optionen={monteure}
ausgewaehlt={filter.mitarbeiterIds}
onToggle={(wert) => toggleWert("mitarbeiterIds", wert)}
onLeeren={() => leereFeld("mitarbeiterIds")}
/>
) : null}
{chips.length ? (
<button
onClick={zuruecksetzen}
className="ml-auto flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-100 hover:text-text-neutral-primary"
>
<FeatherRotateCcw className="text-[14px]" />
Filter zurücksetzen
</button>
) : null}
</div>
{chips.length ? (
<div className="flex flex-wrap items-center gap-1.5">
{chips.map((chip) => (
<button
key={`${chip.feld}-${chip.wert}`}
onClick={() => toggleWert(chip.feld, chip.wert)}
className="group flex items-center gap-1.5 rounded-full border border-solid border-border-default bg-white py-1 pl-2.5 pr-2 text-[12px] font-medium text-text-neutral-secondary hover:border-border-strong"
>
{chip.label}
<FeatherX className="text-[12px] text-text-neutral-tertiary group-hover:text-text-neutral-primary" />
</button>
))}
</div>
) : null}
{versteckt.length ? (
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border-strong bg-white px-3 py-2">
<span className="flex items-center gap-1.5 text-[12px] font-medium text-text-neutral-tertiary">
<FeatherEye className="text-[13px]" />
{versteckt.length} ausgeblendet:
</span>
{versteckt.map((id) => (
<button
key={id}
onClick={() => zeige(id)}
className="rounded-full border border-solid border-border-default px-2.5 py-1 text-[12px] font-medium text-text-neutral-secondary hover:bg-neutral-50"
>
{titel[id] ?? id} einblenden
</button>
))}
<button
onClick={zeigeAlle}
className="ml-auto text-[12px] font-semibold text-text-neutral-primary underline underline-offset-2"
>
Alle einblenden
</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
import React from "react";
import { FeatherCheck, FeatherChevronDown, FeatherSearch } from "@subframe/core";
/**
* Mehrfachauswahl mit Suche (COMP-06).
* Bewusst schlank gehalten: ein Button, ein Panel, Klick außerhalb schließt.
*/
export function MehrfachAuswahl({
label,
optionen,
ausgewaehlt,
onToggle,
onLeeren,
suchbarAb = 8,
}: {
label: string;
optionen: { wert: string; label: string; zusatz?: string }[];
ausgewaehlt: string[];
onToggle: (wert: string) => void;
onLeeren: () => void;
/** Ab wie vielen Optionen ein Suchfeld eingeblendet wird. */
suchbarAb?: number;
}) {
const [offen, setOffen] = React.useState(false);
const [suche, setSuche] = React.useState("");
const wrapper = React.useRef<HTMLDivElement>(null);
const ausloeser = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (!offen) return;
const schliesse = (event: MouseEvent) => {
if (!wrapper.current?.contains(event.target as Node)) setOffen(false);
};
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOffen(false);
ausloeser.current?.focus();
}
};
document.addEventListener("mousedown", schliesse);
document.addEventListener("keydown", taste);
return () => {
document.removeEventListener("mousedown", schliesse);
document.removeEventListener("keydown", taste);
};
}, [offen]);
const gefiltert = optionen.filter((o) =>
o.label.toLowerCase().includes(suche.toLowerCase())
);
return (
<div ref={wrapper} className="relative">
<button
ref={ausloeser}
onClick={() => setOffen((wert) => !wert)}
aria-expanded={offen}
aria-haspopup="listbox"
className={`flex h-8 items-center gap-1.5 rounded-md border border-solid px-2.5 text-[13px] font-medium transition-colors ${
ausgewaehlt.length
? "border-neutral-800 bg-neutral-800 text-white"
: "border-border-default bg-white text-text-neutral-secondary hover:bg-neutral-50"
}`}
>
{label}
{ausgewaehlt.length ? (
<span className="rounded bg-white/20 px-1.5 text-[11px] font-semibold">
{ausgewaehlt.length}
</span>
) : null}
<FeatherChevronDown className="text-[14px]" />
</button>
{offen ? (
<div className="absolute left-0 top-9 z-50 flex w-64 flex-col rounded-lg border border-solid border-border-default bg-white shadow-lg">
{optionen.length >= suchbarAb ? (
<div className="flex items-center gap-2 border-b border-solid border-border-default px-3 py-2">
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
<input
autoFocus
value={suche}
onChange={(event) => setSuche(event.target.value)}
placeholder="Suchen …"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle"
/>
</div>
) : null}
<div className="dv-scroll max-h-64 overflow-y-auto py-1">
{gefiltert.map((option) => {
const aktiv = ausgewaehlt.includes(option.wert);
return (
<button
key={option.wert}
onClick={() => onToggle(option.wert)}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-text-neutral-primary hover:bg-neutral-50"
>
<span
className={`flex h-4 w-4 flex-none items-center justify-center rounded border border-solid ${
aktiv ? "border-neutral-800 bg-neutral-800 text-white" : "border-border-strong"
}`}
>
{aktiv ? <FeatherCheck className="text-[11px]" /> : null}
</span>
<span className="min-w-0 grow truncate">{option.label}</span>
{option.zusatz ? (
<span className="flex-none text-[12px] text-text-neutral-tertiary">
{option.zusatz}
</span>
) : null}
</button>
);
})}
{!gefiltert.length ? (
<div className="px-3 py-6 text-center text-[13px] text-text-neutral-tertiary">
Kein Treffer
</div>
) : null}
</div>
<div className="flex items-center justify-between border-t border-solid border-border-default px-3 py-2">
<button
onClick={onLeeren}
className="text-[12px] font-medium text-text-neutral-tertiary hover:text-text-neutral-primary"
>
Auswahl leeren
</button>
<button
onClick={() => setOffen(false)}
className="rounded-md bg-neutral-800 px-2.5 py-1 text-[12px] font-semibold text-white"
>
Fertig
</button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,159 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
FeatherBell,
FeatherHelpCircle,
FeatherPanelLeft,
FeatherSearch,
} from "@subframe/core";
import { ALLE_SEITEN, NAV_GRUPPEN } from "./navigation";
import { FeatherIcon } from "./FeatherIcon";
import { DATENSATZ_INFO } from "../../data/seed";
import { formatDatum, formatZahl } from "../../utils/formatters";
import { HEUTE } from "../../utils/dateUtils";
/**
* Rahmen der Anwendung: feste Seitenleiste links, Inhalt rechts.
*
* Warum Seitenleiste statt Tab-Leiste: Die echte HiveGrid-Oberfläche
* navigiert links; so passt der Prototyp beim Einbau ohne Umbau der
* Informationsarchitektur. Die Leiste lässt sich auf Icon-Breite einklappen,
* damit breite Tabellen und 12-Spalten-Raster Platz bekommen.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [eingeklappt, setEingeklappt] = React.useState(false);
const aktiveSeite =
ALLE_SEITEN.find((s) => s.href !== "/" && pathname.startsWith(s.href)) ??
ALLE_SEITEN.find((s) => s.href === "/");
return (
<div className="flex min-h-screen w-full bg-neutral-50">
<aside
className={`dv-no-print sticky top-0 flex h-screen flex-none flex-col border-r border-solid border-border-default bg-white transition-[width] duration-200 ${
eingeklappt ? "w-[68px]" : "w-[248px]"
}`}
>
<div className="flex h-14 flex-none items-center gap-2 border-b border-solid border-border-default px-4">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-[#161616] text-[13px] font-bold text-white">
HG
</span>
{!eingeklappt ? (
<div className="flex min-w-0 flex-col">
<span className="truncate text-[13px] font-semibold leading-tight text-text-neutral-primary">
HiveGrid
</span>
<span className="truncate text-[11px] leading-tight text-text-neutral-tertiary">
Analytics
</span>
</div>
) : null}
</div>
<nav className="flex min-h-0 grow flex-col gap-5 overflow-y-auto px-3 py-4">
{NAV_GRUPPEN.map((gruppe) => (
<div key={gruppe.titel} className="flex flex-col gap-1">
{!eingeklappt ? (
<span className="px-2 pb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-text-neutral-tertiary">
{gruppe.titel}
</span>
) : (
<span className="mx-2 mb-1 h-px bg-border-default" />
)}
{gruppe.eintraege.map((eintrag) => {
const aktiv = eintrag.href === pathname;
return (
<Link
key={eintrag.href}
href={eintrag.href}
title={eingeklappt ? eintrag.label : eintrag.beschreibung}
aria-current={aktiv ? "page" : undefined}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium transition-colors ${
aktiv
? "bg-neutral-100 text-text-neutral-primary"
: "text-text-neutral-secondary hover:bg-neutral-50 hover:text-text-neutral-primary"
}`}
>
<FeatherIcon
name={eintrag.icon}
className={`text-[17px] ${aktiv ? "text-text-neutral-primary" : "text-text-neutral-tertiary"}`}
/>
{!eingeklappt ? <span className="truncate">{eintrag.label}</span> : null}
</Link>
);
})}
</div>
))}
</nav>
<div className="flex-none border-t border-solid border-border-default px-3 py-3">
{!eingeklappt ? (
<div className="mb-3 rounded-md bg-neutral-50 px-3 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-wide text-text-neutral-tertiary">
Demo-Datenstand
</div>
<div className="mt-1 text-[12px] leading-relaxed text-text-neutral-secondary">
{formatZahl(DATENSATZ_INFO.auftraege)} Aufträge
<br />
{formatZahl(DATENSATZ_INFO.rechnungen)} Rechnungen
<br />
Stichtag {formatDatum(HEUTE.toISOString())}
</div>
</div>
) : null}
<button
onClick={() => setEingeklappt((wert) => !wert)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-50 hover:text-text-neutral-primary"
aria-label={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
>
<FeatherPanelLeft className="text-[17px]" />
{!eingeklappt ? <span>Einklappen</span> : null}
</button>
</div>
</aside>
<div className="flex min-w-0 grow flex-col">
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
<div className="flex min-w-0 items-center gap-2 text-[13px]">
<span className="text-text-neutral-tertiary">Statistiken</span>
<span className="text-text-neutral-subtle">/</span>
<span className="truncate font-semibold text-text-neutral-primary">
{aktiveSeite?.label ?? "Übersicht"}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="hidden h-9 items-center gap-2 rounded-md border border-solid border-border-default px-3 text-[13px] text-text-neutral-subtle lg:flex">
<FeatherSearch className="text-[15px]" />
<span>Suchen</span>
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[10px] font-medium text-text-neutral-tertiary">
⌘K
</kbd>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Benachrichtigungen"
>
<FeatherBell className="text-[17px]" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Hilfe"
>
<FeatherHelpCircle className="text-[17px]" />
</button>
<span className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-[12px] font-semibold text-white">
MK
</span>
</div>
</header>
<main className="min-w-0 grow">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import React from "react";
import * as SubframeCore from "@subframe/core";
type IconKomponente = React.ComponentType<{ className?: string }>;
/**
* Löst einen Icon-Namen ("Euro") gegen das Feather-Set von @subframe/core auf.
* So können Konfigurationsdateien (navigation.ts) Icons als String führen,
* ohne React importieren zu müssen.
*/
export function FeatherIcon({ name, className }: { name: string; className?: string }) {
const registry = SubframeCore as unknown as Record<string, IconKomponente | undefined>;
const Komponente = registry[`Feather${name}`];
if (!Komponente) return null;
return <Komponente className={className} />;
}
@@ -0,0 +1,38 @@
import React from "react";
/**
* Inhaltsspalte einer Seite.
* 24 px Außenabstand, 16 px Rasterabstand — entspricht dem 8-px-System
* aus den UI-Guidelines (internal ≤ external).
*/
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-6 px-6 pb-10">{children}</div>;
}
/** Standard-Raster für eine Kartenreihe. */
export function Raster({
spalten,
/**
* Karten oben ausrichten statt auf gleiche Hoehe strecken.
* Gleiche Hoehe ist richtig, wenn alle Karten dieselbe Struktur haben
* (KPI-Reihe). Sobald ein Chart mit fester Hoehe neben einer Tabelle steht,
* erzeugt Strecken nur Weissraum.
*/
obenBuendig = false,
children,
}: {
/** Spaltenzahl auf großen Bildschirmen. */
spalten: 2 | 3 | 4 | 5;
obenBuendig?: boolean;
children: React.ReactNode;
}) {
const klassen: Record<number, string> = {
2: "grid grid-cols-1 gap-4 md:grid-cols-2",
3: "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3",
4: "grid grid-cols-2 gap-4 lg:grid-cols-4",
5: "grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-5",
};
return (
<div className={`${klassen[spalten]} ${obenBuendig ? "items-start" : ""}`}>{children}</div>
);
}
@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { FeatherDownload, FeatherRefreshCw, FeatherShare2 } from "@subframe/core";
import { useToast } from "../../context/ToastContext";
/**
* Kopf einer Analyse-Seite: Titel, Einordnung, Seitenaktionen.
* Die Aktionen sind im Prototyp bewusst folgenlos (Toast statt Export) —
* siehe HANDOVER.md, Abschnitt "Was noch Logik braucht".
*/
export function PageHeader({
titel,
beschreibung,
aktionen,
}: {
titel: string;
beschreibung: string;
aktionen?: React.ReactNode;
}) {
const { zeigeToast } = useToast();
return (
<div className="flex flex-wrap items-start justify-between gap-4 px-6 pb-6 pt-6">
<div className="flex min-w-0 flex-col gap-0">
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-text-neutral-primary">
{titel}
</h1>
<p className="text-[13px] leading-relaxed text-text-neutral-tertiary">{beschreibung}</p>
</div>
<div className="dv-no-print flex flex-none items-center gap-2">
{aktionen}
<button
onClick={() => zeigeToast("Ansicht aktualisiert", "Demo-Daten, Stichtag 30.07.2026")}
className="flex h-9 items-center gap-2 rounded-md border border-solid border-border-default bg-white px-3 text-[13px] font-medium text-text-neutral-secondary hover:bg-neutral-50"
>
<FeatherRefreshCw className="text-[15px]" />
Aktualisieren
</button>
<button
onClick={() => zeigeToast("Export vorgemerkt", "PDF/CSV folgt in der Umsetzung")}
className="flex h-9 items-center gap-2 rounded-md border border-solid border-border-default bg-white px-3 text-[13px] font-medium text-text-neutral-secondary hover:bg-neutral-50"
>
<FeatherDownload className="text-[15px]" />
Export
</button>
<button
onClick={() => zeigeToast("Link kopiert", "Teilen wird serverseitig umgesetzt")}
className="flex h-9 w-9 items-center justify-center rounded-md border border-solid border-border-default bg-white text-text-neutral-tertiary hover:bg-neutral-50"
aria-label="Ansicht teilen"
>
<FeatherShare2 className="text-[15px]" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import type { SeitenId } from "../../data/filtering";
export interface NavEintrag {
id: SeitenId | "komponenten";
href: string;
label: string;
/** Icon-Name aus @subframe/core (Feather-Set). */
icon: string;
beschreibung: string;
}
/**
* Navigationsstruktur.
*
* Vier Analyse-Seiten kommen direkt aus REQUIREMENTS-analytics.md
* (Übersicht, Finanzen, Aufträge, Kunden). "Team" war dort als v2 markiert,
* ist hier aber enthalten, weil Auslastung im Handwerk die Kennzahl ist,
* nach der Betriebe steuern. "Komponenten" ist reine Entwickler-Doku.
*/
export const NAV_GRUPPEN: { titel: string; eintraege: NavEintrag[] }[] = [
{
titel: "Analyse",
eintraege: [
{
id: "uebersicht",
href: "/",
label: "Übersicht",
icon: "LayoutDashboard",
beschreibung: "Alle Kernkennzahlen auf einen Blick",
},
{
id: "finanzen",
href: "/finanzen",
label: "Finanzen",
icon: "Euro",
beschreibung: "Umsatz, Rechnungen, Zahlungseingang",
},
{
id: "auftraege",
href: "/auftraege",
label: "Aufträge",
icon: "ClipboardList",
beschreibung: "Pipeline, Status, Termine",
},
{
id: "kunden",
href: "/kunden",
label: "Kunden",
icon: "Users",
beschreibung: "Akquise, Quellen, Bestand",
},
{
id: "team",
href: "/team",
label: "Team",
icon: "HardHat",
beschreibung: "Auslastung und Produktivität der Monteure",
},
],
},
{
titel: "Entwicklung",
eintraege: [
{
id: "komponenten",
href: "/komponenten",
label: "Komponenten",
icon: "Component",
beschreibung: "Bausteine, Regeln und Übergabehinweise",
},
],
},
];
export const ALLE_SEITEN = NAV_GRUPPEN.flatMap((g) => g.eintraege);
@@ -0,0 +1,215 @@
"use client";
import React from "react";
import {
FeatherArrowDown,
FeatherArrowUp,
FeatherChevronLeft,
FeatherChevronRight,
FeatherSearch,
} from "@subframe/core";
export interface Spalte<T> {
key: string;
titel: string;
/** Zellinhalt; darf beliebige Knoten liefern (Badges, Links). */
zelle: (zeile: T) => React.ReactNode;
/** Sortierwert — ohne diesen ist die Spalte nicht sortierbar. */
sortWert?: (zeile: T) => number | string;
/** Zahlen gehören rechtsbündig und in Tabellenziffern. */
numerisch?: boolean;
breite?: string;
}
/**
* Tabelle für Arbeitslisten (offene Rechnungen, Aufträge, Kunden).
*
* Enthält die Interaktionen, die man in einer Demo wirklich braucht:
* Sortieren per Klick auf den Spaltenkopf, Volltextsuche, Blättern.
* Alles clientseitig — bei echten Datenmengen gehört das auf den Server.
*/
export function DataTable<T extends { id: string }>({
zeilen,
spalten,
proSeite = 8,
suchbar = true,
suchFelder,
leerText = "Keine Einträge",
}: {
zeilen: T[];
spalten: Spalte<T>[];
proSeite?: number;
suchbar?: boolean;
/** Felder, die die Suche durchsucht. */
suchFelder?: (zeile: T) => string;
leerText?: string;
}) {
const [suche, setSuche] = React.useState("");
const [seite, setSeite] = React.useState(0);
const [sortierung, setSortierung] = React.useState<{ key: string; richtung: "asc" | "desc" } | null>(
null
);
const gefiltert = React.useMemo(() => {
if (!suche.trim() || !suchFelder) return zeilen;
const begriff = suche.toLowerCase();
return zeilen.filter((zeile) => suchFelder(zeile).toLowerCase().includes(begriff));
}, [zeilen, suche, suchFelder]);
const sortiert = React.useMemo(() => {
if (!sortierung) return gefiltert;
const spalte = spalten.find((s) => s.key === sortierung.key);
if (!spalte?.sortWert) return gefiltert;
return [...gefiltert].sort((a, b) => {
const wertA = spalte.sortWert!(a);
const wertB = spalte.sortWert!(b);
const vergleich =
typeof wertA === "number" && typeof wertB === "number"
? wertA - wertB
: String(wertA).localeCompare(String(wertB), "de");
return sortierung.richtung === "asc" ? vergleich : -vergleich;
});
}, [gefiltert, sortierung, spalten]);
const seiten = Math.max(1, Math.ceil(sortiert.length / proSeite));
const aktuelleSeite = Math.min(seite, seiten - 1);
const sichtbar = sortiert.slice(aktuelleSeite * proSeite, (aktuelleSeite + 1) * proSeite);
const sortiereNach = (key: string) => {
setSeite(0);
setSortierung((aktuell) =>
aktuell?.key === key
? { key, richtung: aktuell.richtung === "asc" ? "desc" : "asc" }
: { key, richtung: "desc" }
);
};
return (
<div className="flex flex-col gap-3">
{suchbar && suchFelder ? (
<div className="flex h-8 items-center gap-2 rounded-md border border-solid border-border-default px-2.5">
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
<input
value={suche}
onChange={(event) => {
setSuche(event.target.value);
setSeite(0);
}}
placeholder="In Liste suchen …"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle"
/>
</div>
) : null}
<div className="dv-scroll -mx-1 overflow-x-auto px-1">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-solid border-border-default">
{spalten.map((spalte) => {
const aktiv = sortierung?.key === spalte.key;
return (
<th
key={spalte.key}
style={{ width: spalte.breite }}
aria-sort={
!spalte.sortWert
? undefined
: aktiv
? sortierung!.richtung === "asc"
? "ascending"
: "descending"
: "none"
}
className={`px-2 pb-2 text-[12px] font-medium text-text-neutral-tertiary ${
spalte.numerisch ? "text-right" : "text-left"
}`}
>
{spalte.sortWert ? (
<button
onClick={() => sortiereNach(spalte.key)}
className={`inline-flex items-center gap-1 hover:text-text-neutral-primary ${
spalte.numerisch ? "flex-row-reverse" : ""
} ${aktiv ? "text-text-neutral-primary" : ""}`}
>
{spalte.titel}
{aktiv ? (
sortierung!.richtung === "asc" ? (
<FeatherArrowUp className="text-[11px]" />
) : (
<FeatherArrowDown className="text-[11px]" />
)
) : null}
</button>
) : (
spalte.titel
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sichtbar.map((zeile) => (
<tr
key={zeile.id}
className="border-b border-solid border-border-default last:border-b-0"
>
{spalten.map((spalte) => (
<td
key={spalte.key}
className={`px-2 py-2.5 text-[13px] text-text-neutral-primary ${
spalte.numerisch ? "text-right tabular-nums" : "text-left"
}`}
>
{spalte.zelle(zeile)}
</td>
))}
</tr>
))}
{!sichtbar.length ? (
<tr>
<td
colSpan={spalten.length}
className="px-2 py-10 text-center text-[13px] text-text-neutral-tertiary"
>
{leerText}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{sortiert.length > proSeite ? (
<div className="dv-no-print flex items-center justify-between">
<span className="text-[12px] text-text-neutral-tertiary">
{aktuelleSeite * proSeite + 1}–{Math.min((aktuelleSeite + 1) * proSeite, sortiert.length)} von{" "}
{sortiert.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setSeite((s) => Math.max(0, s - 1))}
disabled={aktuelleSeite === 0}
aria-label="Vorherige Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronLeft className="text-[14px]" />
</button>
<span className="px-1 text-[12px] tabular-nums text-text-neutral-secondary">
{aktuelleSeite + 1} / {seiten}
</span>
<button
onClick={() => setSeite((s) => Math.min(seiten - 1, s + 1))}
disabled={aktuelleSeite >= seiten - 1}
aria-label="Nächste Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronRight className="text-[14px]" />
</button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,38 @@
import React from "react";
import type { AuftragStatus, RechnungStatus } from "../../data/types";
import { AUFTRAG_STATUS_LABEL, RECHNUNG_STATUS_LABEL } from "../../data/types";
import { BADGE_TON } from "../../utils/chartTheme";
/** Farbzuordnung: Endzustände neutral, Problemzustände rot, Laufendes blau. */
const AUFTRAG_TON: Record<AuftragStatus, string> = {
anfrage: BADGE_TON.neutral,
aufmass: BADGE_TON.information,
angebot: BADGE_TON.lila,
beauftragt: BADGE_TON.teal,
in_arbeit: BADGE_TON.positiv,
abgeschlossen: BADGE_TON.neutral,
verloren: BADGE_TON.negativ,
};
const RECHNUNG_TON: Record<RechnungStatus, string> = {
entwurf: BADGE_TON.neutral,
gestellt: BADGE_TON.information,
bezahlt: BADGE_TON.positiv,
ueberfaellig: BADGE_TON.negativ,
};
export function AuftragBadge({ status }: { status: AuftragStatus }) {
return (
<span className={`inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${AUFTRAG_TON[status]}`}>
{AUFTRAG_STATUS_LABEL[status]}
</span>
);
}
export function RechnungBadge({ status }: { status: RechnungStatus }) {
return (
<span className={`inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-medium ${RECHNUNG_TON[status]}`}>
{RECHNUNG_STATUS_LABEL[status]}
</span>
);
}
@@ -0,0 +1,17 @@
"use client";
import React from "react";
import { FilterProvider } from "./FilterContext";
import { WidgetProvider } from "./WidgetContext";
import { ToastProvider } from "./ToastContext";
/** Sammelt alle Client-Provider, damit das Root-Layout schlank bleibt. */
export function DashboardProviders({ children }: { children: React.ReactNode }) {
return (
<FilterProvider>
<WidgetProvider>
<ToastProvider>{children}</ToastProvider>
</WidgetProvider>
</FilterProvider>
);
}
@@ -0,0 +1,70 @@
"use client";
import React from "react";
import { LEERER_FILTER, type FilterState } from "../data/types";
import { erstellePersistentenStore } from "./persistenterStore";
/**
* Globaler Filter-State über alle Analytics-Seiten hinweg (INFRA-01),
* persistiert in localStorage (INFRA-02) — ein Reload verwirft die Auswahl
* nicht, im Kundentermin ein spürbarer Unterschied.
*/
export type MehrfachFeld =
| "gewerke"
| "auftragStatus"
| "kundenQuellen"
| "regionen"
| "mitarbeiterIds";
const store = erstellePersistentenStore<FilterState>(
"hivegrid.analytics.filter.v1",
LEERER_FILTER
);
interface FilterContextValue {
filter: FilterState;
setFilter: (naechster: FilterState | ((vorher: FilterState) => FilterState)) => void;
/** Einzelnen Mehrfachwert an- oder abwählen. */
toggleWert: (feld: MehrfachFeld, wert: string) => void;
/** Ein Feld komplett leeren. */
leereFeld: (feld: MehrfachFeld) => void;
zuruecksetzen: () => void;
}
const FilterContext = React.createContext<FilterContextValue | null>(null);
export function FilterProvider({ children }: { children: React.ReactNode }) {
const filter = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);
const wert = React.useMemo<FilterContextValue>(
() => ({
filter,
setFilter: store.setzen,
toggleWert: (feld, eintrag) =>
store.setzen((aktuell) => {
const liste = aktuell[feld] as string[];
const neu = liste.includes(eintrag)
? liste.filter((w) => w !== eintrag)
: [...liste, eintrag];
return { ...aktuell, [feld]: neu } as FilterState;
}),
leereFeld: (feld) =>
store.setzen((aktuell) => ({ ...aktuell, [feld]: [] }) as FilterState),
zuruecksetzen: () => store.setzen(LEERER_FILTER),
}),
[filter]
);
return <FilterContext.Provider value={wert}>{children}</FilterContext.Provider>;
}
export function useFilter(): FilterContextValue {
const ctx = React.useContext(FilterContext);
if (!ctx) throw new Error("useFilter muss innerhalb von <FilterProvider> verwendet werden");
return ctx;
}
@@ -0,0 +1,65 @@
"use client";
import React from "react";
import { FeatherCheck, FeatherInfo } from "@subframe/core";
/**
* Minimale Rückmeldung für Demo-Aktionen (Export, Speichern, Teilen).
*
* Im Prototyp passiert dahinter nichts — der Toast macht aber sichtbar,
* dass der Klick registriert wurde, statt dass der Button "tot" wirkt.
* In der echten Software durch die vorhandene Toast-Komponente ersetzen.
*/
interface ToastContextValue {
zeigeToast: (text: string, detail?: string) => void;
}
const ToastContext = React.createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toast, setToast] = React.useState<{ text: string; detail?: string } | null>(null);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const zeigeToast = React.useCallback((text: string, detail?: string) => {
setToast({ text, detail });
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setToast(null), 2600);
}, []);
React.useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
return (
<ToastContext.Provider value={{ zeigeToast }}>
{children}
{toast ? (
<div
role="status"
aria-live="polite"
className="dv-no-print fixed bottom-6 right-6 z-[200] flex max-w-sm items-start gap-3 rounded-lg border border-solid border-border-default bg-white px-4 py-3 shadow-lg"
>
<span className="mt-0.5 flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[#defbe6] text-[#198038]">
<FeatherCheck className="text-[15px]" />
</span>
<div className="flex flex-col gap-0.5">
<span className="text-[13px] font-semibold text-text-neutral-primary">{toast.text}</span>
{toast.detail ? (
<span className="flex items-center gap-1.5 text-[12px] text-text-neutral-tertiary">
<FeatherInfo className="text-[12px]" />
{toast.detail}
</span>
) : null}
</div>
</div>
) : null}
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error("useToast muss innerhalb von <ToastProvider> verwendet werden");
return ctx;
}
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { erstellePersistentenStore } from "./persistenterStore";
/**
* Sichtbarkeit einzelner Widgets.
*
* Jede Karte hat eine stabile ID; über das ⋯-Menü lässt sie sich ausblenden.
* Ausgeblendete Karten sammelt die Filterleiste und macht sie einzeln oder
* gesammelt wieder sichtbar. Der Zustand überlebt einen Reload.
*/
interface WidgetState {
versteckt: string[];
/** Titel für die "wieder einblenden"-Liste. */
titel: Record<string, string>;
}
const STANDARD: WidgetState = { versteckt: [], titel: {} };
const store = erstellePersistentenStore<WidgetState>("hivegrid.analytics.widgets.v1", STANDARD);
interface WidgetContextValue extends WidgetState {
istSichtbar: (id: string) => boolean;
verstecke: (id: string, titel: string) => void;
zeige: (id: string) => void;
zeigeAlle: () => void;
}
const WidgetContext = React.createContext<WidgetContextValue | null>(null);
export function WidgetProvider({ children }: { children: React.ReactNode }) {
const zustand = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);
const wert = React.useMemo<WidgetContextValue>(
() => ({
versteckt: zustand.versteckt,
titel: zustand.titel,
istSichtbar: (id) => !zustand.versteckt.includes(id),
verstecke: (id, kartenTitel) =>
store.setzen((aktuell) => ({
versteckt: aktuell.versteckt.includes(id)
? aktuell.versteckt
: [...aktuell.versteckt, id],
titel: { ...aktuell.titel, [id]: kartenTitel },
})),
zeige: (id) =>
store.setzen((aktuell) => ({
...aktuell,
versteckt: aktuell.versteckt.filter((eintrag) => eintrag !== id),
})),
zeigeAlle: () => store.setzen((aktuell) => ({ ...aktuell, versteckt: [] })),
}),
[zustand]
);
return <WidgetContext.Provider value={wert}>{children}</WidgetContext.Provider>;
}
export function useWidgets(): WidgetContextValue {
const ctx = React.useContext(WidgetContext);
if (!ctx) throw new Error("useWidgets muss innerhalb von <WidgetProvider> verwendet werden");
return ctx;
}
@@ -0,0 +1,60 @@
/**
* Kleiner Store für Werte, die einen Reload überleben sollen.
*
* Warum kein `useState` + `useEffect`: Beim Serverrendering gibt es keinen
* localStorage. Lädt man den gespeicherten Wert nachträglich im Effekt, löst
* das eine zweite Renderrunde aus (und React beanstandet das zu Recht).
* `useSyncExternalStore` ist genau für diesen Fall gebaut: Es rendert
* serverseitig den Standardwert und wechselt nach der Hydration auf den
* gespeicherten — ohne Mismatch und ohne Zusatzrender.
*/
export interface PersistenterStore<T> {
subscribe: (hoerer: () => void) => () => void;
getSnapshot: () => T;
getServerSnapshot: () => T;
setzen: (naechster: T | ((vorher: T) => T)) => void;
}
export function erstellePersistentenStore<T>(
schluessel: string,
standard: T,
/** Gelesene Daten mit dem Standard verschmelzen — fängt alte Formate ab. */
zusammenfuehren: (gespeichert: unknown, standard: T) => T = (g, s) =>
({ ...s, ...(g as object) }) as T
): PersistenterStore<T> {
let wert = standard;
let gelesen = false;
const hoerer = new Set<() => void>();
function lade(): T {
if (gelesen || typeof window === "undefined") return wert;
gelesen = true;
try {
const roh = window.localStorage.getItem(schluessel);
if (roh) wert = zusammenfuehren(JSON.parse(roh), standard);
} catch {
// Defektes JSON oder gesperrter Storage: beim Standard bleiben.
}
return wert;
}
return {
subscribe(neuerHoerer) {
hoerer.add(neuerHoerer);
return () => hoerer.delete(neuerHoerer);
},
getSnapshot: () => lade(),
getServerSnapshot: () => standard,
setzen(naechster) {
lade();
wert = typeof naechster === "function" ? (naechster as (v: T) => T)(wert) : naechster;
try {
window.localStorage.setItem(schluessel, JSON.stringify(wert));
} catch {
// Privater Modus o. Ä. — Persistenz ist optional.
}
hoerer.forEach((h) => h());
},
};
}
+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;
}
@@ -0,0 +1,30 @@
"use client";
import React from "react";
import { DATENSATZ } from "../data/seed";
import { wendeFilterAn, wirksameFilter, type SeitenId } from "../data/filtering";
import { useFilter } from "../context/FilterContext";
import type { Datensatz, FilterState } from "../data/types";
/**
* Liefert den auf die aktive Filterauswahl zugeschnittenen Datensatz.
*
* `daten` — gefiltert nach den auf dieser Seite wirksamen Dimensionen
* `filter` — die tatsächlich wirksamen Filter (irrelevante sind neutralisiert)
*
* Der Zeitraum steckt bewusst nur im Filter, nicht im Datensatz: Manche
* Kennzahlen sind zeitraumbezogen (Umsatz), andere stichtagsbezogen
* (offene Posten). Die Berechnungen entscheiden das jeweils selbst.
*/
export function useAnalyseDaten(seite: SeitenId): {
daten: Datensatz;
filter: FilterState;
gesamt: Datensatz;
} {
const { filter } = useFilter();
const wirksam = React.useMemo(() => wirksameFilter(filter, seite), [filter, seite]);
const daten = React.useMemo(() => wendeFilterAn(DATENSATZ, wirksam), [wirksam]);
return { daten, filter: wirksam, gesamt: DATENSATZ };
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { StatZeile } from "../components/cards/StatZeile";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { Trichter } from "../components/charts/Trichter";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { AuftragBadge } from "../components/tables/StatusBadge";
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
import {
abschlussquote,
aktiveAuftraege,
auftraegeImZeitraum,
auftraegeNachGewerk,
auftraegeNachStatus,
auftragseingang,
durchlaufzeit,
foerderQuote,
ueberfaelligeAuftraege,
vertriebsTrichter,
} from "../data/calculations";
import {
berechneTrend,
formatDatum,
formatEuro,
formatEuroKompakt,
formatProzent,
formatTage,
formatZahl,
tageSeit,
tageLabel,
tageLabelDativ,
} from "../utils/formatters";
import { STATUS_TEXT } from "../utils/chartTheme";
import { HEUTE, vorperiode, imFenster } from "../utils/dateUtils";
import type { Auftrag } from "../data/types";
export function AuftraegeSeite() {
const { daten, filter } = useAnalyseDaten("auftraege");
const imZeitraum = auftraegeImZeitraum(daten.auftraege, filter.zeitraum);
const vorher = daten.auftraege.filter((a) => imFenster(a.angelegtAm, vorperiode(filter.zeitraum)));
const aktiv = aktiveAuftraege(daten.auftraege);
const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege);
const quote = abschlussquote(daten.auftraege);
const laufzeit = durchlaufzeit(daten.auftraege);
const foerderung = foerderQuote(daten.auftraege);
const eingang = auftragseingang(daten.auftraege, filter.zeitraum);
const bestand = aktiv.reduce((s, a) => s + a.wert, 0);
const mitarbeiterName = React.useMemo(
() => new Map(daten.mitarbeiter.map((m) => [m.id, m.name])),
[daten.mitarbeiter]
);
const kundeName = React.useMemo(
() => new Map(daten.kunden.map((k) => [k.id, k.name])),
[daten.kunden]
);
const spalten: Spalte<Auftrag>[] = [
{
key: "titel",
titel: "Auftrag",
breite: "28%",
zelle: (a) => (
<div className="flex flex-col">
<span className="font-medium">{a.titel}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{a.nummer} · {kundeName.get(a.kundeId) ?? "Unbekannt"}
</span>
</div>
),
sortWert: (a) => a.titel,
},
{
key: "gewerk",
titel: "Gewerk",
zelle: (a) => <span className="text-text-neutral-secondary">{a.gewerk}</span>,
sortWert: (a) => a.gewerk,
},
{ key: "status", titel: "Status", zelle: (a) => <AuftragBadge status={a.status} /> },
{
key: "monteur",
titel: "Verantwortlich",
zelle: (a) => (
<span className="text-text-neutral-secondary">
{mitarbeiterName.get(a.mitarbeiterId) ?? "—"}
</span>
),
sortWert: (a) => mitarbeiterName.get(a.mitarbeiterId) ?? "",
},
{
key: "faellig",
titel: "Termin",
numerisch: true,
zelle: (a) => {
const tage = tageSeit(a.faelligAm, HEUTE);
return tage > 0 ? (
<span className="font-medium" style={{ color: STATUS_TEXT.negativ }}>
{tageLabel(tage, "über")}
</span>
) : (
<span className="text-text-neutral-secondary">{formatDatum(a.faelligAm)}</span>
);
},
sortWert: (a) => tageSeit(a.faelligAm, HEUTE),
},
{
key: "wert",
titel: "Wert",
numerisch: true,
zelle: (a) => <span className="font-medium">{formatEuro(a.wert)}</span>,
sortWert: (a) => a.wert,
},
];
return (
<>
<PageHeader
titel="Aufträge"
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
/>
<AnalyticsToolbar seite="auftraege" />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Neue Vorgänge"
wert={formatZahl(imZeitraum.length)}
zusatz="Nach Anlagedatum gezählt"
trend={berechneTrend(imZeitraum.length, vorher.length)}
icon="ClipboardList"
verlauf={eingang.slice(-10).map((p) => p.value)}
/>
<KPICard
label="Aktive Aufträge"
wert={formatZahl(aktiv.length)}
zusatz={`${formatEuro(bestand)} Auftragsbestand`}
icon="Package"
bezug="stichtag"
/>
<KPICard
label="Über Termin"
wert={formatZahl(ueberfaellig.length)}
zusatz={
ueberfaellig.length
? `Ältester seit ${tageLabelDativ(tageSeit(ueberfaellig[0].faelligAm, HEUTE))} fällig`
: "Alle Termine gehalten"
}
icon="AlertTriangle"
bezug="stichtag"
akzent={ueberfaellig.length ? "negativ" : "positiv"}
/>
<KPICard
label="Abschlussquote"
wert={formatProzent(quote, 0)}
zusatz="Aus Angebot wird Auftrag"
icon="Target"
bezug="stichtag"
/>
<KPICard
label="Ø Durchlaufzeit"
wert={formatTage(laufzeit)}
zusatz="Anfrage bis Abnahme"
icon="Clock"
bezug="stichtag"
richtungGut="negativ"
/>
</Raster>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-3">
<ChartCard
id="auftraege-eingang"
titel="Auftragseingang"
untertitel="Neu angelegte Vorgänge im Zeitverlauf"
className="xl:col-span-2"
>
<BalkenVertikal daten={eingang} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
<StatZeile
spalten={2}
eintraege={[
{ label: "Vorperiode", wert: formatZahl(vorher.length) },
{
label: "Ø Auftragswert",
wert: formatEuro(
imZeitraum.length
? imZeitraum.reduce((s, a) => s + a.wert, 0) / imZeitraum.length
: 0
),
},
]}
/>
</ChartCard>
<ChartCard
id="auftraege-trichter"
titel="Vertriebstrichter"
untertitel="Angebotsgeschäft ab 6.000 €, nach erreichter Stufe"
hinweis="Nur Vorhaben mit schriftlichem Angebot. Wartung und Notdienst gehen ohne Angebot direkt in die Ausführung — mitgezählt wäre der Trichter unten breiter als oben. Verlorene Vorgänge zählen bis zu der Stufe, auf der sie abgesprungen sind."
>
<Trichter stufen={vertriebsTrichter(daten.auftraege)} formatWert={(w) => formatZahl(w)} />
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<ChartCard
id="auftraege-status"
titel="Vorgänge nach Status"
untertitel="Alle Vorgänge seit Beginn — unabhängig vom Zeitraumfilter"
>
<Donut
daten={auftraegeNachStatus(daten.auftraege)}
formatWert={(w) => formatZahl(w)}
hoehe={220}
mitteLabel="Vorgänge"
/>
</ChartCard>
<ChartCard
id="auftraege-gewerke"
titel="Vorgänge nach Gewerk"
untertitel="Anzahl seit Beginn, zum Stichtag"
>
<BalkenHorizontal
daten={auftraegeNachGewerk(daten.auftraege)}
formatWert={(w) => formatZahl(w)}
einfarbig={false}
maxBalken={5}
/>
</ChartCard>
<ChartCard
id="auftraege-foerderung"
titel="Förderquote BAFA / KfW"
untertitel={`${formatProzent(foerderung.quote, 0)} der ${formatZahl(
foerderung.foerderfaehig
)} förderfähigen Aufträge`}
hinweis="Bezugsgröße sind nur förderfähige Leistungen: Wärmepumpe, PV-Anlage, Batteriespeicher und Lüftungsanlage. Über alle Aufträge gerechnet wäre die Quote wertlos."
>
<Donut
daten={foerderung.verteilung}
formatWert={(w) => formatZahl(w)}
hoehe={200}
mitteLabel="Förderfähig"
/>
<StatZeile
spalten={2}
eintraege={[
{ label: "Fördersumme", wert: formatEuroKompakt(foerderung.summe) },
{
label: "Ø je Auftrag",
wert: formatEuroKompakt(
foerderung.bewilligt ? foerderung.summe / foerderung.bewilligt : 0
),
},
]}
/>
</ChartCard>
</div>
<ChartCard
id="auftraege-liste"
titel={`Aktive Aufträge (${aktiv.length})`}
untertitel="Beauftragt oder in Arbeit"
>
<DataTable
zeilen={aktiv}
spalten={spalten}
proSeite={8}
suchFelder={(a) =>
`${a.titel} ${a.nummer} ${a.gewerk} ${mitarbeiterName.get(a.mitarbeiterId) ?? ""}`
}
leerText="Keine aktiven Aufträge"
/>
</ChartCard>
</PageContainer>
</>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { StatZeile } from "../components/cards/StatZeile";
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { ChartLegende } from "../components/charts/ChartLegende";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { RechnungBadge } from "../components/tables/StatusBadge";
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
import {
marge,
offenePosten,
offeneNachStatus,
umsatz,
umsatzNachGewerk,
umsatzVerlauf,
umsatzVorperiode,
zahlungsdauer,
} from "../data/calculations";
import {
berechneTrend,
formatDatum,
formatEuro,
formatEuroAchse,
formatEuroKompakt,
formatProzent,
formatTage,
tageSeit,
tageLabel,
} from "../utils/formatters";
import { HEUTE } from "../utils/dateUtils";
import {
PRIMAER_FARBE,
RISIKO_RAMPE,
STATUS_TEXT,
VERGLEICH_FARBE,
} from "../utils/chartTheme";
import type { Rechnung } from "../data/types";
/** Ampelfarben für die Altersstruktur: je älter die Forderung, desto kritischer. */
const ALTER_FARBEN = [...RISIKO_RAMPE];
export function FinanzenSeite() {
const { daten, filter } = useAnalyseDaten("finanzen");
const umsatzAktuell = umsatz(daten.rechnungen, filter.zeitraum);
const umsatzVorher = umsatzVorperiode(daten.rechnungen, filter.zeitraum);
const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum);
const posten = offenePosten(daten.rechnungen);
const dauer = zahlungsdauer(daten.rechnungen, filter.zeitraum);
const margeProzent = marge(daten.auftraege, filter.zeitraum);
const kundeName = React.useMemo(
() => new Map(daten.kunden.map((k) => [k.id, k.name])),
[daten.kunden]
);
const spalten: Spalte<Rechnung>[] = [
{
key: "nummer",
titel: "Rechnung",
zelle: (r) => (
<div className="flex flex-col">
<span className="font-medium">{r.nummer}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{kundeName.get(r.kundeId) ?? "Unbekannt"}
</span>
</div>
),
sortWert: (r) => r.nummer,
breite: "30%",
},
{ key: "status", titel: "Status", zelle: (r) => <RechnungBadge status={r.status} /> },
{
key: "gestellt",
titel: "Gestellt",
zelle: (r) => formatDatum(r.gestelltAm),
sortWert: (r) => r.gestelltAm,
},
{
key: "faellig",
titel: "Überfällig",
numerisch: true,
zelle: (r) => {
const tage = tageSeit(r.faelligAm, HEUTE);
return tage > 0 ? (
<span
className={tage > 30 ? "font-medium" : ""}
style={{ color: tage > 30 ? STATUS_TEXT.negativ : STATUS_TEXT.warnung }}
>
{tageLabel(tage)}
</span>
) : (
<span className="text-text-neutral-tertiary">
in {Math.abs(tage) === 1 ? "1 Tag" : `${Math.abs(tage)} Tagen`}
</span>
);
},
sortWert: (r) => tageSeit(r.faelligAm, HEUTE),
},
{
key: "betrag",
titel: "Betrag",
numerisch: true,
zelle: (r) => <span className="font-medium">{formatEuro(r.betrag)}</span>,
sortWert: (r) => r.betrag,
},
];
return (
<>
<PageHeader
titel="Finanzen"
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
/>
<AnalyticsToolbar seite="finanzen" />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Umsatz (bezahlt)"
wert={formatEuro(umsatzAktuell)}
zusatz="Zahlungseingang im Zeitraum"
trend={berechneTrend(umsatzAktuell, umsatzVorher)}
icon="Euro"
verlauf={verlauf.slice(-10).map((p) => p.value)}
/>
<KPICard
label="Offene Forderungen"
wert={formatEuro(posten.gesamt)}
zusatz={`${posten.anzahl} Rechnungen`}
icon="FileText"
bezug="stichtag"
/>
<KPICard
label="Überfällig"
wert={formatEuro(posten.ueberfaelligBetrag)}
zusatz={`${posten.ueberfaelligAnzahl} Rechnungen im Mahnlauf`}
icon="AlertTriangle"
bezug="stichtag"
akzent={posten.ueberfaelligBetrag > 0 ? "negativ" : "positiv"}
/>
<KPICard
label="Ø Zahlungsdauer"
wert={formatTage(dauer)}
zusatz="Rechnung bis Zahlungseingang"
icon="Clock"
akzent={dauer > 30 ? "warnung" : "positiv"}
richtungGut="negativ"
/>
<KPICard
label="Deckungsbeitrag"
wert={formatProzent(margeProzent)}
zusatz="Gewichtet über Abschlüsse"
icon="Percent"
/>
</Raster>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<ChartCard
id="finanzen-verlauf"
titel="Zahlungseingang im Zeitverlauf"
untertitel="Aktueller Zeitraum gegen Vorjahr"
hinweis="Grundlage sind bezahlte Ausgangsrechnungen zum Zahlungsdatum."
className="xl:col-span-2"
legende={
<ChartLegende
eintraege={[
{ name: "Aktueller Zeitraum", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE, gestrichelt: true },
]}
/>
}
>
<AreaVerlauf
daten={verlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={250}
/>
<StatZeile
spalten={2}
eintraege={[
{ label: "Vorperiode", wert: formatEuro(umsatzVorher) },
{
label: "Veränderung",
wert: `${umsatzAktuell >= umsatzVorher ? "+" : "−"}${formatEuro(Math.abs(umsatzAktuell - umsatzVorher))}`,
},
]}
/>
</ChartCard>
<ChartCard
id="finanzen-status"
titel="Offener Bestand nach Status"
untertitel="Wie viel des offenen Bestands bereits überfällig ist"
hinweis="Bezugsgröße ist ausschließlich der offene Bestand zum Stichtag. Bezahlte Rechnungen sind nicht enthalten — sie würden über 90 % des Rings belegen und die Aussage erdrücken."
>
<Donut
daten={offeneNachStatus(daten.rechnungen)}
formatWert={formatEuroKompakt}
hoehe={200}
mitteLabel="Offen"
formatMitte={formatEuro}
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<ChartCard
className="lg:col-span-2"
id="finanzen-alter"
titel="Offene Forderungen nach Alter"
untertitel={`Gesamt ${formatEuro(posten.gesamt)}`}
hinweis="Alter ab Fälligkeitsdatum. Ab 60 Tagen sinkt die Einbringungsquote erfahrungsgemäß deutlich."
>
<BalkenVertikal
daten={posten.nachAlter}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
hoehe={240}
farbenProBalken={ALTER_FARBEN}
/>
<StatZeile
spalten={2}
eintraege={[
{
label: "Älter als 90 Tage",
wert: formatEuro(posten.nachAlter.find((b) => b.name === "> 90 Tage")?.value ?? 0),
hinweis: "Abschreibungsrisiko",
},
{ label: "Überfällig gesamt", wert: formatEuro(posten.ueberfaelligBetrag) },
]}
/>
</ChartCard>
<ChartCard
id="finanzen-gewerke"
titel="Umsatz nach Gewerk"
untertitel="Zahlungseingang im Zeitraum"
>
<BalkenHorizontal
daten={umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum)}
formatWert={formatEuroKompakt}
einfarbig={false}
maxBalken={5}
/>
</ChartCard>
</div>
<ChartCard
id="finanzen-offene-liste"
titel={`Offene Rechnungen (${posten.anzahl})`}
untertitel="Sortiert nach Fälligkeit — die Arbeitsliste für den Mahnlauf"
>
<DataTable
zeilen={posten.liste}
spalten={spalten}
proSeite={8}
suchFelder={(r) => `${r.nummer} ${kundeName.get(r.kundeId) ?? ""}`}
leerText="Keine offenen Rechnungen"
/>
</ChartCard>
</PageContainer>
</>
);
}
@@ -0,0 +1,352 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer } from "../components/layout/PageContainer";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { AlertList } from "../components/cards/AlertList";
import { StatZeile } from "../components/cards/StatZeile";
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { Trichter } from "../components/charts/Trichter";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { ChartLeer } from "../components/charts/ChartLeer";
import { ChartLegende } from "../components/charts/ChartLegende";
import { AuftragBadge, RechnungBadge } from "../components/tables/StatusBadge";
import {
KATEGORIE_FARBEN,
MAX_KATEGORIEN,
PRIMAER_FARBE,
STATUS_FARBEN,
VERGLEICH_FARBE,
} from "../utils/chartTheme";
import {
formatEuro,
formatEuroAchse,
formatEuroKompakt,
formatProzent,
formatZahl,
} from "../utils/formatters";
import { DATENSATZ_INFO } from "../data/seed";
/** Beispieldaten, damit die Galerie unabhängig von Filtern immer etwas zeigt. */
const BEISPIEL_REIHE = Array.from({ length: 12 }, (_, i) => ({
datum: `2026-${String(i + 1).padStart(2, "0")}`,
label: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"][i],
value: [148, 162, 171, 158, 166, 149, 152, 161, 189, 205, 198, 176][i] * 1000,
vergleich: [131, 140, 152, 146, 151, 138, 141, 149, 168, 181, 176, 158][i] * 1000,
}));
const BEISPIEL_KATEGORIEN = [
{ name: "Heizung", value: 842_000 },
{ name: "Sanitär", value: 486_000 },
{ name: "Photovoltaik", value: 421_000 },
{ name: "Klima & Lüftung", value: 268_000 },
{ name: "Elektro", value: 194_000 },
{ name: "Sonstiges", value: 61_000 },
];
export function KomponentenSeite() {
return (
<>
<PageHeader
titel="Komponenten & Regeln"
beschreibung="Alle Bausteine dieses Prototyps an einem Ort — als Referenz für den Einbau in die Software."
/>
<PageContainer>
<Abschnitt
titel="Warum diese Seite existiert"
text="Der Prototyp ist eine Gestaltungsvorlage, kein Produkt. Diese Seite zeigt jeden Baustein isoliert, damit beim Einbau klar ist, welche Komponente wofür gedacht ist und welche Regeln dahinterstehen. Datei-Pfade stehen jeweils darunter."
/>
<ChartCard
id="komp-farben"
titel="Farbpalette"
untertitel="IBM Carbon, kategoriale Palette (14 Farben) — Reihenfolge nie umsortieren"
hinweis="Die Reihenfolge ist auf Unterscheidbarkeit optimiert. Farben immer von vorne vergeben, nicht nach Geschmack auswählen."
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
{KATEGORIE_FARBEN.map((farbe, index) => (
<div key={farbe} className="flex w-[104px] flex-col gap-1">
<span className="h-10 w-full rounded-md" style={{ background: farbe }} />
<span className="text-[11px] font-medium text-text-neutral-secondary">
{index + 1}. {farbe}
</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-4 border-t border-solid border-border-default pt-3">
{Object.entries(STATUS_FARBEN).map(([name, farbe]) => (
<span key={name} className="flex items-center gap-2 text-[12px] text-text-neutral-secondary">
<span className="h-4 w-4 rounded" style={{ background: farbe }} />
Status „{name}“ · {farbe}
</span>
))}
</div>
<p className="text-[12px] leading-relaxed text-text-neutral-tertiary">
Kategorien nutzen die Carbon-Palette, Statusaussagen die Ampelfarben. Beides nie
mischen: eine grüne Kategorie neben einem grünen Positiv-Status ist nicht mehr lesbar.
Maximal {MAX_KATEGORIEN} Segmente je Chart, der Rest wird zu „Sonstige“
zusammengefasst.
</p>
</div>
</ChartCard>
<Abschnitt titel="Kennzahlen-Karten" text="features/dashboard/components/cards/KPICard.tsx" />
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KPICard
label="Mit Trend und Sparkline"
wert={formatEuro(238_412)}
zusatz="Zahlungseingang im Zeitraum"
trend={{ wert: 12.4, richtung: "up" }}
icon="Euro"
akzent="positiv"
verlauf={[42, 48, 45, 52, 58, 55, 63, 71]}
/>
<KPICard
label="Negativer Trend"
wert={formatProzent(58.2, 0)}
zusatz="Abschlussquote"
trend={{ wert: 6.1, richtung: "down" }}
icon="Target"
akzent="negativ"
/>
<KPICard
label="Kleiner ist besser"
wert="34,2 Tage"
zusatz="Ø Zahlungsdauer"
trend={{ wert: 8.3, richtung: "down" }}
icon="Clock"
akzent="positiv"
richtungGut="negativ"
/>
<KPICard
label="Ohne Trend"
wert={formatZahl(1900)}
zusatz="Vorgänge im Datensatz"
icon="ClipboardList"
akzent="neutral"
/>
</div>
<Hinweis text="richtungGut='negativ' dreht die Farblogik um: Bei Zahlungsdauer oder Reaktionszeit ist ein Rückgang gut. Ohne diesen Schalter würde die Karte eine Verbesserung rot einfärben." />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<ChartCard
id="komp-area"
titel="Flächendiagramm mit Vergleich"
untertitel="components/charts/AreaVerlauf.tsx — für Zeitverläufe"
className="xl:col-span-2"
legende={
<ChartLegende
eintraege={[
{ name: "Aktuell", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE, gestrichelt: true },
]}
/>
}
>
<AreaVerlauf
daten={BEISPIEL_REIHE}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={220}
/>
</ChartCard>
<ChartCard
id="komp-donut"
titel="Ringdiagramm"
untertitel="components/charts/Donut.tsx — für Anteile"
>
<Donut daten={BEISPIEL_KATEGORIEN} formatWert={formatEuroKompakt} hoehe={200} />
</ChartCard>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard
id="komp-balken"
titel="Säulendiagramm"
untertitel="components/charts/BalkenVertikal.tsx — Mengen mit Nulllinie"
>
<BalkenVertikal
daten={BEISPIEL_REIHE.map((p) => ({ label: p.label, value: p.value / 1000 }))}
formatWert={(w) => formatZahl(w)}
hoehe={200}
nurGanzzahlen
/>
</ChartCard>
<ChartCard
id="komp-hbalken"
titel="Balkendiagramm (liegend)"
untertitel="components/charts/BalkenHorizontal.tsx — Rangfolgen"
>
<BalkenHorizontal
daten={BEISPIEL_KATEGORIEN}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</ChartCard>
<ChartCard
id="komp-trichter"
titel="Trichter"
untertitel="components/charts/Trichter.tsx — Stufen mit Übergangsquote"
>
<Trichter
stufen={[
{ name: "Anfrage", value: 1900 },
{ name: "Vor-Aufmaß", value: 1420 },
{ name: "Angebot", value: 1180 },
{ name: "Beauftragt", value: 690 },
{ name: "Abgeschlossen", value: 612 },
]}
formatWert={(w) => formatZahl(w)}
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard
id="komp-auslastung"
titel="Auslastungsbalken"
untertitel="components/charts/AuslastungsBalken.tsx — mit Ampel und 100-%-Marke"
>
<AuslastungsBalken
zeilen={[
{ name: "Thomas Krause", wert: 104, zusatz: "Meister · Heizung" },
{ name: "Dennis Meyer", wert: 92, zusatz: "Monteur · Sanitär" },
{ name: "Maike Schmidt", wert: 78, zusatz: "Monteur · Photovoltaik" },
{ name: "Kevin Wolf", wert: 54, zusatz: "Auszubildender · Elektro" },
]}
/>
</ChartCard>
<ChartCard
id="komp-alerts"
titel="Handlungsbedarf"
untertitel="components/cards/AlertList.tsx — nach Dringlichkeit sortiert"
>
<AlertList
eintraege={[
{
id: "a",
text: "25 überfällige Rechnungen",
detail: "Zahlungsziel überschritten",
betrag: 96_194,
aktion: "Mahnlauf starten",
art: "kritisch",
},
{
id: "b",
text: "32 Aufträge über Termin",
detail: "Ältester seit 99 Tagen fällig",
betrag: 148_600,
aktion: "Termine neu planen",
art: "warnung",
},
{
id: "c",
text: "23 Förderanträge in Bearbeitung",
detail: "Bescheid ausstehend",
betrag: 116_100,
aktion: "Status prüfen",
art: "hinweis",
},
]}
/>
</ChartCard>
<ChartCard
id="komp-leer"
titel="Leerzustand"
untertitel="components/charts/ChartLeer.tsx — greift, wenn Filter alles wegschneiden"
>
<ChartLeer hoehe={180} />
</ChartCard>
</div>
<ChartCard
id="komp-badges"
titel="Status-Kennzeichnung"
untertitel="components/tables/StatusBadge.tsx"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="w-24 text-[12px] text-text-neutral-tertiary">Auftrag</span>
<AuftragBadge status="anfrage" />
<AuftragBadge status="aufmass" />
<AuftragBadge status="angebot" />
<AuftragBadge status="beauftragt" />
<AuftragBadge status="in_arbeit" />
<AuftragBadge status="abgeschlossen" />
<AuftragBadge status="verloren" />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="w-24 text-[12px] text-text-neutral-tertiary">Rechnung</span>
<RechnungBadge status="entwurf" />
<RechnungBadge status="gestellt" />
<RechnungBadge status="bezahlt" />
<RechnungBadge status="ueberfaellig" />
</div>
<StatZeile
eintraege={[
{ label: "Aufträge", wert: formatZahl(DATENSATZ_INFO.auftraege), hinweis: "im Demo-Datensatz" },
{ label: "Rechnungen", wert: formatZahl(DATENSATZ_INFO.rechnungen) },
{ label: "Kunden", wert: formatZahl(DATENSATZ_INFO.kunden) },
{ label: "Zeiteinträge", wert: formatZahl(DATENSATZ_INFO.zeiteintraege) },
]}
/>
</div>
</ChartCard>
<ChartCard
id="komp-regeln"
titel="Regeln, die beim Einbau erhalten bleiben sollten"
untertitel="Ausführlich in docs/DESIGN-SYSTEM.md"
>
<ul className="flex flex-col gap-2.5">
{[
"Nur horizontale Gridlines in #e0e0e0. Vertikale Linien lenken vom Verlauf ab.",
"Achsenbeschriftungen 12 px in #525252, keine Tick-Marks, Achsenlinie #8d8d8d.",
"Ein Tooltip für alle Charts: dunkle Fläche #393939, weißer Text, kein Doppelpunkt.",
"Maximal fünf Kategorien je Chart, alles darüber wird zu „Sonstige“ zusammengefasst.",
"Balkenabstand mindestens halbe Balkenbreite; Balken nie breiter als 48 px.",
"Zahlen rechtsbündig und in Tabellenziffern, Text linksbündig.",
"Karten: 16 px Innenabstand, 16 px Abstand zwischen Karten — innen nie größer als außen.",
"Jede Karte braucht einen Leerzustand. Filter können jedes Chart leer laufen lassen.",
].map((regel) => (
<li key={regel} className="flex gap-2.5 text-[13px] leading-relaxed text-text-neutral-secondary">
<span className="mt-1.5 h-1.5 w-1.5 flex-none rounded-full bg-neutral-400" />
{regel}
</li>
))}
</ul>
</ChartCard>
</PageContainer>
</>
);
}
function Abschnitt({ titel, text }: { titel: string; text: string }) {
return (
<div className="flex flex-col gap-1 pt-2">
<h2 className="text-[15px] font-semibold text-text-neutral-primary">{titel}</h2>
<p className="max-w-3xl text-[13px] leading-relaxed text-text-neutral-tertiary">{text}</p>
</div>
);
}
function Hinweis({ text }: { text: string }) {
return (
<p className="rounded-md border border-solid border-border-default bg-white px-3 py-2 text-[12px] leading-relaxed text-text-neutral-secondary">
{text}
</p>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
import {
inaktiveKunden,
kundenEntwicklung,
kundenNach,
neueKunden,
topKunden,
wiederkehrquote,
zahlungsauffaellig,
} from "../data/calculations";
import {
berechneTrend,
formatDatum,
formatEuro,
formatEuroKompakt,
formatProzent,
formatZahl,
tageLabel,
} from "../utils/formatters";
import { STATUS_TEXT } from "../utils/chartTheme";
import { imFenster, vorperiode } from "../utils/dateUtils";
import type { Kunde } from "../data/types";
export function KundenSeite() {
const { daten, filter } = useAnalyseDaten("kunden");
const neu = neueKunden(daten.kunden, filter.zeitraum);
const neuVorher = daten.kunden.filter((k) =>
imFenster(k.angelegtAm, vorperiode(filter.zeitraum))
).length;
const mitAuftrag = daten.kunden.filter((k) => k.auftraegeGesamt > 0);
const wiederkehrend = wiederkehrquote(daten.kunden);
const kundenwert =
mitAuftrag.length > 0
? mitAuftrag.reduce((s, k) => s + k.umsatzGesamt, 0) / mitAuftrag.length
: 0;
const inaktiv = inaktiveKunden(daten.kunden);
const auffaellig = zahlungsauffaellig(daten.kunden);
const entwicklung = kundenEntwicklung(daten.kunden, filter.zeitraum);
const spalten: Spalte<Kunde>[] = [
{
key: "name",
titel: "Kunde",
breite: "26%",
zelle: (k) => (
<div className="flex flex-col">
<span className="font-medium">{k.name}</span>
<span className="text-[11px] text-text-neutral-tertiary">{k.typ}</span>
</div>
),
sortWert: (k) => k.name,
},
{
key: "quelle",
titel: "Quelle",
zelle: (k) => <span className="text-text-neutral-secondary">{k.quelle}</span>,
sortWert: (k) => k.quelle,
},
{
key: "auftraege",
titel: "Aufträge",
numerisch: true,
zelle: (k) => formatZahl(k.auftraegeGesamt),
sortWert: (k) => k.auftraegeGesamt,
},
{
key: "umsatz",
titel: "Umsatz",
numerisch: true,
zelle: (k) => <span className="font-medium">{formatEuro(k.umsatzGesamt)}</span>,
sortWert: (k) => k.umsatzGesamt,
},
{
key: "zahlung",
titel: "Ø Zahlung",
numerisch: true,
zelle: (k) => (
<span
className={k.zahlungsmoralTage > 30 ? "font-medium" : ""}
style={k.zahlungsmoralTage > 30 ? { color: STATUS_TEXT.warnung } : undefined}
>
{tageLabel(k.zahlungsmoralTage)}
</span>
),
sortWert: (k) => k.zahlungsmoralTage,
},
{
key: "letzter",
titel: "Letzter Auftrag",
zelle: (k) =>
k.letzterAuftragAm ? (
formatDatum(k.letzterAuftragAm)
) : (
<span className="text-text-neutral-subtle">—</span>
),
sortWert: (k) => k.letzterAuftragAm ?? "",
},
];
return (
<>
<PageHeader
titel="Kunden"
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
/>
<AnalyticsToolbar seite="kunden" />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Kunden gesamt"
wert={formatZahl(daten.kunden.length)}
zusatz={`${formatZahl(mitAuftrag.length)} mit mindestens einem Auftrag`}
icon="Users"
bezug="stichtag"
/>
<KPICard
label="Neue Kunden"
wert={formatZahl(neu)}
zusatz="Nach Anlagedatum gezählt"
trend={berechneTrend(neu, neuVorher)}
icon="UserPlus"
verlauf={entwicklung.slice(-10).map((p) => p.value)}
/>
<KPICard
label="Wiederkehrende Kunden"
wert={formatProzent(wiederkehrend, 0)}
zusatz="Mehr als ein Auftrag"
icon="Repeat"
bezug="stichtag"
/>
<KPICard
label="Ø Kundenwert"
wert={formatEuro(kundenwert)}
zusatz="Je Kunde mit mindestens einem Auftrag"
icon="CircleDollarSign"
bezug="gesamt"
/>
<KPICard
label="Reaktivierbar"
wert={formatZahl(inaktiv.length)}
zusatz="Über 12 Monate ohne Auftrag"
icon="Clock"
bezug="stichtag"
akzent={inaktiv.length > 0 ? "warnung" : "neutral"}
/>
</Raster>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-3">
<ChartCard
id="kunden-entwicklung"
titel="Neukunden im Zeitverlauf"
untertitel="Anlagedatum des Kundendatensatzes"
className="xl:col-span-2"
>
<BalkenVertikal daten={entwicklung} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
</ChartCard>
<ChartCard
id="kunden-quelle"
titel="Kunden nach Quelle"
untertitel="Wie der Erstkontakt zustande kam"
hinweis="Empfehlungen und Bestandskunden zusammen zeigen, wie stark der Betrieb ohne Werbebudget wächst."
>
<Donut
daten={kundenNach(daten.kunden, "quelle")}
formatWert={(w) => formatZahl(w)}
hoehe={220}
mitteLabel="Kunden"
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard id="kunden-typ" titel="Kundenstruktur" untertitel="Privat, Gewerbe, Verwaltung">
<Donut
daten={kundenNach(daten.kunden, "typ")}
formatWert={(w) => formatZahl(w)}
hoehe={200}
mitteLabel="Kunden"
/>
</ChartCard>
<ChartCard
id="kunden-top"
titel="Top-Kunden nach Umsatz"
untertitel="Bezahlter Gesamtumsatz"
>
<BalkenHorizontal daten={topKunden(daten.kunden, 6)} formatWert={formatEuroKompakt} />
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<ChartCard
id="kunden-inaktiv"
titel={`Reaktivierungspotenzial (${inaktiv.length})`}
untertitel="Kunden mit Historie, aber über 12 Monate ohne Auftrag"
>
<DataTable
zeilen={inaktiv}
spalten={spalten.filter((s) => s.key !== "zahlung")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort} ${k.quelle}`}
leerText="Alle Kunden waren zuletzt aktiv"
/>
</ChartCard>
<ChartCard
id="kunden-zahlung"
titel={`Auffällige Zahlungsmoral (${auffaellig.length})`}
untertitel="Durchschnittlich über 30 Tage bis zum Zahlungseingang"
>
<DataTable
zeilen={auffaellig}
spalten={spalten.filter((s) => s.key !== "letzter")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort}`}
leerText="Keine Auffälligkeiten"
/>
</ChartCard>
</div>
</PageContainer>
</>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
import { teamKennzahlen, type MitarbeiterKennzahl } from "../data/calculations";
import { formatEuro, formatEuroKompakt, formatProzent, formatStunden, formatZahl } from "../utils/formatters";
import { imFenster, zeitfenster } from "../utils/dateUtils";
export function TeamSeite() {
const { daten, filter } = useAnalyseDaten("team");
const kennzahlen = teamKennzahlen(
daten.mitarbeiter,
daten.zeiten,
daten.auftraege,
filter.zeitraum
);
const aktive = kennzahlen.filter((k) => k.mitarbeiter.status === "aktiv");
const durchschnitt =
aktive.length > 0 ? aktive.reduce((s, k) => s + k.auslastungProzent, 0) / aktive.length : 0;
const stundenGesamt = aktive.reduce((s, k) => s + k.stunden, 0);
const produktivGesamt = aktive.reduce((s, k) => s + k.produktivStunden, 0);
const produktivQuote = stundenGesamt > 0 ? (produktivGesamt / stundenGesamt) * 100 : 0;
const fenster = zeitfenster(filter.zeitraum);
const abwesenheiten = daten.zeiten.filter(
(z) => z.art === "abwesenheit" && imFenster(z.datum, fenster)
);
const abwesenheitNach = React.useMemo(() => {
const zaehler = new Map<string, number>();
const label: Record<string, string> = {
urlaub: "Urlaub",
krank: "Krank",
fortbildung: "Fortbildung",
};
for (const eintrag of abwesenheiten) {
const name = label[eintrag.abwesenheitsgrund ?? "sonstige"] ?? "Sonstige";
zaehler.set(name, (zaehler.get(name) ?? 0) + eintrag.stunden);
}
return [...zaehler.entries()].map(([name, value]) => ({ name, value }));
}, [abwesenheiten]);
const spalten: Spalte<MitarbeiterKennzahl & { id: string }>[] = [
{
key: "name",
titel: "Mitarbeiter",
breite: "26%",
zelle: (k) => (
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-neutral-100 text-[11px] font-semibold text-text-neutral-secondary">
{k.mitarbeiter.kuerzel}
</span>
<div className="flex flex-col">
<span className="font-medium">{k.mitarbeiter.name}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{k.mitarbeiter.rolle} · {k.mitarbeiter.gewerk}
</span>
</div>
</div>
),
sortWert: (k) => k.mitarbeiter.name,
},
{
key: "status",
titel: "Status",
zelle: (k) =>
k.mitarbeiter.status === "aktiv" ? (
<span className="rounded-full bg-[#defbe6] px-2 py-0.5 text-[11px] font-medium text-[#0e6027]">
aktiv
</span>
) : (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-600">
inaktiv
</span>
),
sortWert: (k) => k.mitarbeiter.status,
},
{
key: "stunden",
titel: "Stunden",
numerisch: true,
zelle: (k) => formatStunden(k.stunden),
sortWert: (k) => k.stunden,
},
{
key: "produktiv",
titel: "davon produktiv",
numerisch: true,
zelle: (k) => formatStunden(k.produktivStunden),
sortWert: (k) => k.produktivStunden,
},
{
key: "auslastung",
titel: "Auslastung",
numerisch: true,
zelle: (k) => (
<span
className={
k.auslastungProzent > 95
? "font-medium text-[#8a3800]"
: k.auslastungProzent < 70
? "text-[#00539a]"
: "font-medium text-[#0e6027]"
}
>
{formatProzent(k.auslastungProzent, 0)}
</span>
),
sortWert: (k) => k.auslastungProzent,
},
{
key: "abgeschlossen",
titel: "Abgeschlossen",
numerisch: true,
zelle: (k) => formatZahl(k.abgeschlossen),
sortWert: (k) => k.abgeschlossen,
},
{
key: "umsatz",
titel: "Auftragswert",
numerisch: true,
zelle: (k) => <span className="font-medium">{formatEuro(k.umsatz)}</span>,
sortWert: (k) => k.umsatz,
},
];
const tabellenZeilen = kennzahlen.map((k) => ({ ...k, id: k.mitarbeiter.id }));
return (
<>
<PageHeader
titel="Team"
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
/>
<AnalyticsToolbar seite="team" />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Im Einsatz"
wert={formatZahl(aktive.length)}
zusatz={`von ${formatZahl(kennzahlen.length)} gewerblich Beschäftigten`}
icon="HardHat"
bezug="stichtag"
/>
<KPICard
label="Ø Auslastung"
wert={formatProzent(durchschnitt, 0)}
zusatz="Produktive Stunden zu verfügbaren"
icon="Activity"
akzent={durchschnitt > 95 ? "warnung" : "positiv"}
/>
<KPICard
label="Produktivquote"
wert={formatProzent(produktivQuote, 0)}
zusatz="Anteil auftragsbezogener Stunden"
icon="Target"
/>
<KPICard
label="Erfasste Stunden"
wert={formatStunden(stundenGesamt)}
zusatz={`${formatStunden(produktivGesamt)} auf Aufträge gebucht`}
icon="Clock"
/>
<KPICard
label="Abwesenheit"
wert={formatStunden(abwesenheiten.reduce((s, a) => s + a.stunden, 0))}
zusatz="Urlaub, Krankheit, Fortbildung"
icon="CalendarOff"
richtungGut="negativ"
/>
</Raster>
<ChartCard
id="team-auslastung"
titel="Auslastung je Mitarbeiter"
untertitel="Produktive Stunden gegen vertraglich verfügbare Stunden"
hinweis="Werte über 100 % bedeuten Überstunden. Dauerhaft über 95 % ist ein Warnsignal für Terminrisiken."
>
<AuslastungsBalken
zeilen={aktive.map((k) => ({
name: k.mitarbeiter.name,
wert: k.auslastungProzent,
zusatz: `${k.mitarbeiter.rolle} · ${k.mitarbeiter.gewerk}`,
}))}
maxZeilen={12}
/>
</ChartCard>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard
id="team-abschluesse"
titel="Abschlüsse je Mitarbeiter"
untertitel="Anzahl im gewählten Zeitraum, dahinter der zugehörige Auftragswert"
hinweis="Der Balken zeigt die Anzahl — sie ist die vergleichbare Größe. Der Auftragswert steht daneben, ist aber ausdrücklich kein Leistungsvergleich: Ein Wärmepumpen-Auftrag ist um ein Vielfaches größer als eine Wartung."
>
<BalkenHorizontal
daten={aktive.map((k) => ({
name: k.mitarbeiter.name,
value: k.abgeschlossen,
zweitwert: k.umsatz,
}))}
formatWert={(w) => formatZahl(w)}
formatZweitwert={formatEuroKompakt}
maxBalken={8}
/>
</ChartCard>
<ChartCard
id="team-abwesenheit"
titel="Abwesenheiten"
untertitel="Stunden im gewählten Zeitraum"
>
<Donut
daten={abwesenheitNach}
formatWert={(w) => formatStunden(w)}
hoehe={210}
mitteLabel="Stunden"
/>
</ChartCard>
</div>
<ChartCard
id="team-tabelle"
titel="Mitarbeiterübersicht"
untertitel="Alle gewerblich Beschäftigten mit Kennzahlen"
>
<DataTable
zeilen={tabellenZeilen}
spalten={spalten}
proSeite={10}
suchFelder={(k) => `${k.mitarbeiter.name} ${k.mitarbeiter.rolle} ${k.mitarbeiter.gewerk}`}
/>
</ChartCard>
</PageContainer>
</>
);
}
@@ -0,0 +1,273 @@
"use client";
import React from "react";
import { PageHeader } from "../components/layout/PageHeader";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { AlertList } from "../components/cards/AlertList";
import { StatZeile } from "../components/cards/StatZeile";
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { Trichter } from "../components/charts/Trichter";
import { ChartLegende } from "../components/charts/ChartLegende";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { AuftragBadge } from "../components/tables/StatusBadge";
import { useAnalyseDaten } from "../hooks/useAnalyseDaten";
import {
aktiveAuftraege,
handlungsbedarf,
marge,
neueKunden,
neueKundenVorperiode,
zahlungsdauer,
offenePosten,
teamKennzahlen,
ueberfaelligeAuftraege,
umsatz,
umsatzNachGewerk,
umsatzVerlauf,
umsatzVorperiode,
vertriebsTrichter,
} from "../data/calculations";
import {
berechneTrend,
formatEuro,
formatEuroAchse,
formatEuroKompakt,
formatProzent,
formatZahl,
tageSeit,
tageLabel,
formatTage,
} from "../utils/formatters";
import { HEUTE } from "../utils/dateUtils";
import { PRIMAER_FARBE, VERGLEICH_FARBE,
STATUS_TEXT,
} from "../utils/chartTheme";
import type { Auftrag } from "../data/types";
export function UebersichtSeite() {
const { daten, filter } = useAnalyseDaten("uebersicht");
const umsatzAktuell = umsatz(daten.rechnungen, filter.zeitraum);
const umsatzVorher = umsatzVorperiode(daten.rechnungen, filter.zeitraum);
const verlauf = umsatzVerlauf(daten.rechnungen, filter.zeitraum);
const posten = offenePosten(daten.rechnungen);
const aktiv = aktiveAuftraege(daten.auftraege);
const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege);
const team = teamKennzahlen(daten.mitarbeiter, daten.zeiten, daten.auftraege, filter.zeitraum);
const alerts = handlungsbedarf(daten);
const monteureAktiv = team.filter((t) => t.mitarbeiter.status === "aktiv");
const durchschnittAuslastung =
monteureAktiv.length > 0
? monteureAktiv.reduce((s, t) => s + t.auslastungProzent, 0) / monteureAktiv.length
: 0;
const sparkline = verlauf.slice(-10).map((p) => p.value);
const gewerkUmsatz = umsatzNachGewerk(daten.auftraege, daten.rechnungen, filter.zeitraum);
const trichter = vertriebsTrichter(daten.auftraege);
const auftragSpalten: Spalte<Auftrag>[] = [
{
key: "nummer",
titel: "Auftrag",
breite: "26%",
zelle: (a) => (
<div className="flex flex-col">
<span className="font-medium">{a.titel}</span>
<span className="text-[11px] text-text-neutral-tertiary">{a.nummer}</span>
</div>
),
sortWert: (a) => a.nummer,
},
{
key: "gewerk",
titel: "Gewerk",
zelle: (a) => <span className="text-text-neutral-secondary">{a.gewerk}</span>,
sortWert: (a) => a.gewerk,
},
{ key: "status", titel: "Status", zelle: (a) => <AuftragBadge status={a.status} /> },
{
key: "faellig",
titel: "Fällig seit",
numerisch: true,
zelle: (a) => (
<span className="font-medium" style={{ color: STATUS_TEXT.negativ }}>
{tageLabel(tageSeit(a.faelligAm, HEUTE))}
</span>
),
sortWert: (a) => tageSeit(a.faelligAm, HEUTE),
},
{
key: "wert",
titel: "Wert",
numerisch: true,
zelle: (a) => formatEuro(a.wert),
sortWert: (a) => a.wert,
},
];
return (
<>
<PageHeader
titel="Übersicht"
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
/>
<AnalyticsToolbar seite="uebersicht" />
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Umsatz (Zahlungseingang)"
wert={formatEuro(umsatzAktuell)}
zusatz={`Deckungsbeitrag ${formatProzent(marge(daten.auftraege, filter.zeitraum))}`}
trend={berechneTrend(umsatzAktuell, umsatzVorher)}
icon="Euro"
verlauf={sparkline}
/>
<KPICard
label="Offene Forderungen"
wert={formatEuro(posten.gesamt)}
zusatz={`${posten.anzahl} Rechnungen · ${posten.ueberfaelligAnzahl} überfällig`}
icon="FileText"
bezug="stichtag"
akzent={posten.ueberfaelligAnzahl > 0 ? "warnung" : "neutral"}
/>
<KPICard
label="Aktive Aufträge"
wert={formatZahl(aktiv.length)}
zusatz={`${formatEuro(aktiv.reduce((s, a) => s + a.wert, 0))} Auftragsbestand`}
icon="ClipboardList"
bezug="stichtag"
/>
<KPICard
label="Ø Auslastung Monteure"
wert={formatProzent(durchschnittAuslastung, 0)}
zusatz={`${monteureAktiv.length} Personen im Einsatz`}
icon="HardHat"
akzent={durchschnittAuslastung > 95 ? "warnung" : durchschnittAuslastung < 70 ? "info" : "positiv"}
/>
<KPICard
label="Neue Kunden"
wert={formatZahl(neueKunden(daten.kunden, filter.zeitraum))}
zusatz={`${formatZahl(daten.kunden.filter((k) => k.auftraegeGesamt > 0).length)} Kunden mit Auftrag insgesamt`}
trend={berechneTrend(
neueKunden(daten.kunden, filter.zeitraum),
neueKundenVorperiode(daten.kunden, filter.zeitraum)
)}
icon="Users"
/>
</Raster>
<ChartCard
id="uebersicht-handlungsbedarf"
titel="Handlungsbedarf"
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
>
<AlertList eintraege={alerts} spalten={2} />
</ChartCard>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-3">
<ChartCard
id="uebersicht-umsatzverlauf"
titel="Zahlungseingang im Zeitverlauf"
untertitel="Aktueller Zeitraum gegen den Vorjahreszeitraum"
hinweis="Gezählt wird der Tag des Zahlungseingangs, nicht das Rechnungsdatum. Dadurch entspricht die Kurve dem tatsächlichen Geldfluss."
className="xl:col-span-2"
legende={
<ChartLegende
eintraege={[
{ name: "Aktueller Zeitraum", farbe: PRIMAER_FARBE },
{ name: "Vorjahr", farbe: VERGLEICH_FARBE, gestrichelt: true },
]}
/>
}
>
<AreaVerlauf
daten={verlauf}
formatWert={formatEuroKompakt}
formatAchse={formatEuroAchse}
zeigeVergleich
hoehe={268}
/>
<StatZeile
spalten={3}
eintraege={[
{ label: "Vorperiode", wert: formatEuro(umsatzVorher) },
{
label: "Ø Auftragswert",
wert: formatEuro(
daten.auftraege.length
? daten.auftraege.reduce((s, a) => s + a.wert, 0) / daten.auftraege.length
: 0
),
},
{ label: "Ø Zahlungsdauer", wert: formatTage(zahlungsdauer(daten.rechnungen, filter.zeitraum)) },
]}
/>
</ChartCard>
<ChartCard
id="uebersicht-gewerke"
titel="Umsatz nach Gewerk"
untertitel="Zahlungseingang im gewählten Zeitraum"
hinweis="Fünf Gewerke in fester Rangfolge. Dieselbe Kodierung wie auf der Finanzseite — ein Ring und eine Balkenliste für dieselbe Zahl wären zwei Bilder für einen Sachverhalt."
>
<BalkenHorizontal
daten={gewerkUmsatz}
formatWert={formatEuroKompakt}
einfarbig={false}
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard
id="uebersicht-trichter"
titel="Vertriebstrichter"
untertitel="Angebotsgeschäft ab 6.000 €, zum Stichtag"
hinweis="Nur Vorhaben, für die ein schriftliches Angebot erstellt wird. Wartung und Notdienst laufen ohne Angebot und würden die Quoten verfälschen. Rechte Spalte: Übergang von der vorherigen Stufe."
>
<Trichter stufen={trichter} formatWert={(w) => formatZahl(w)} />
</ChartCard>
<ChartCard
id="uebersicht-auslastung"
titel="Auslastung Monteure"
untertitel="Produktive Stunden gegen verfügbare Stunden"
hinweis="Nur auf Aufträge gebuchte Stunden zählen als produktiv. Über 100 % bedeutet Überstunden."
>
<AuslastungsBalken
zeilen={monteureAktiv.slice(0, 6).map((t) => ({
name: t.mitarbeiter.name,
wert: t.auslastungProzent,
zusatz: t.mitarbeiter.gewerk,
}))}
/>
</ChartCard>
</div>
<ChartCard
id="uebersicht-ueberfaellig"
titel={`Aufträge über Termin (${ueberfaellig.length})`}
untertitel="Fälligkeitsdatum überschritten, noch nicht abgeschlossen"
>
<DataTable
zeilen={ueberfaellig}
spalten={auftragSpalten}
proSeite={6}
suchFelder={(a) => `${a.titel} ${a.nummer} ${a.gewerk}`}
leerText="Kein Auftrag ist über Termin — sehr gut."
/>
</ChartCard>
</PageContainer>
</>
);
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Zentrales Chart-Theme — abgeleitet aus IBM Carbon Data Visualization.
*
* Die Werte stammen aus dem ausgelieferten Stylesheet von @carbon/charts
* (dist/styles.css, Light Theme):
* Gridlines #e0e0e0 (--cds-layer-accent-01)
* Achse #8d8d8d (--cds-border-strong-01)
* Text primär #161616 (--cds-text-primary)
* Text sek. #525252 (--cds-text-secondary)
* Tooltip #393939 (--cds-background-inverse) auf #ffffff Text
*
* Warum kein bunter "Marketing"-Farbsatz: Die Carbon-Palette ist auf
* Unterscheidbarkeit und Kontrast optimiert und in dieser Reihenfolge
* getestet. Reihenfolge deshalb nie umsortieren, nur von vorne nehmen.
*
* Siehe docs/DESIGN-SYSTEM.md.
*/
/** Carbon "14-color" categorical palette, Light Theme, in Originalreihenfolge. */
export const KATEGORIE_FARBEN = [
"#6929c4", // purple 70
"#1192e8", // cyan 50
"#005d5d", // teal 70
"#9f1853", // magenta 70
"#fa4d56", // red 50
"#520408", // red 100
"#198038", // green 60
"#002d9c", // blue 80
"#ee5396", // magenta 50
"#b28600", // yellow 50
"#009d9a", // teal 50
"#012749", // cyan 90
"#8a3800", // orange 70
"#a56eff", // purple 50
] as const;
/**
* Carbon empfiehlt maximal 5 gleichzeitig unterscheidbare Kategorien.
* Alles darüber wird in "Sonstige" zusammengefasst (siehe gruppiereRest()).
*/
export const MAX_KATEGORIEN = 5;
/** Ampelfarben für Status-Aussagen (nicht für Kategorien verwenden). */
export const STATUS_FARBEN = {
positiv: "#198038",
warnung: "#b28600",
negativ: "#da1e28",
neutral: "#8d8d8d",
info: "#1192e8",
} as const;
/**
* Feste Farbe je Gewerk.
*
* Eine Kategorie behält ihre Farbe über alle Charts und Seiten hinweg —
* sonst ist Heizung im Ring lila und im Balkendiagramm blau, und man muss
* jede Legende neu lesen. Die Werte stammen aus der Carbon-Palette.
*/
export const GEWERK_FARBEN: Record<string, string> = {
Heizung: "#6929c4",
Sanitär: "#1192e8",
"Klima & Lüftung": "#005d5d",
Photovoltaik: "#9f1853",
Elektro: "#198038",
};
/**
* Feste Farben für Statuswerte.
*
* Hier trägt die Farbe Bedeutung: „Verloren" und „Überfällig" sind rot,
* „Bezahlt" und „In Arbeit" grün, Neutrales grau. Eine zufällige
* Palettenfarbe für „Verloren" würde die Aussage der Grafik verwässern.
*/
export const STATUS_KATEGORIE_FARBEN: Record<string, string> = {
// Auftragsstatus
Anfrage: "#a56eff",
"Vor-Aufmaß": "#1192e8",
Angebot: "#6929c4",
Beauftragt: "#009d9a",
"In Arbeit": "#198038",
Abgeschlossen: "#005d5d",
Verloren: "#fa4d56",
// Rechnungsstatus
Bezahlt: "#198038",
Gestellt: "#1192e8",
Überfällig: "#da1e28",
Entwurf: "#8d8d8d",
// Förderung
Bewilligt: "#198038",
Beantragt: "#b28600",
"Nicht beantragt": "#8d8d8d",
// Abwesenheit
Urlaub: "#1192e8",
Krank: "#fa4d56",
Fortbildung: "#6929c4",
// Sammelposten
Sonstige: "#8d8d8d",
};
/**
* Textfarben für Statusaussagen in Tabellen und Listen.
*
* Nicht dieselben Werte wie STATUS_FARBEN: Flächen dürfen heller sein, Text
* braucht auf Weiß mehr Kontrast (WCAG AA, 4,5:1 bei 12–13 px). Deshalb je
* eine Stufe dunkler aus derselben Carbon-Familie.
*/
export const STATUS_TEXT = {
negativ: "#a2191f", // red 70
warnung: "#8a3800", // orange 70
positiv: "#0e6027", // green 70
} as const;
/**
* Vierstufige Risikorampe für geordnete Dimensionen (Forderungsalter).
*
* Durchgehend Statusfarben aus der Carbon-Skala, keine Palettenfarbe
* dazwischen: Altersklassen sind eine Reihenfolge mit wachsendem Risiko,
* keine unabhängigen Kategorien.
*/
export const RISIKO_RAMPE = [
"#198038", // green 60 — im Zahlungsziel
"#b28600", // yellow 50 — beobachten
"#ba4e00", // orange 60 — mahnen
"#da1e28", // red 60 — Abschreibungsrisiko
] as const;
/**
* Badge-Töne: helle Fläche mit dunklem Text derselben Carbon-Familie.
* Alle Paare erreichen auf ihrer Fläche mindestens 7:1 — Badges sind klein
* (11 px) und brauchen mehr Reserve als Fließtext.
*/
export const BADGE_TON = {
neutral: "bg-neutral-100 text-neutral-700",
information: "bg-[#e5f6ff] text-[#00539a]",
lila: "bg-[#e8daff] text-[#491d8b]",
teal: "bg-[#d9fbfb] text-[#005d5d]",
positiv: "bg-[#defbe6] text-[#0e6027]",
negativ: "bg-[#fff1f1] text-[#a2191f]",
} as const;
/** Einzelserie (Umsatzverlauf o. Ä.) — Carbon Cyan 50. */
export const PRIMAER_FARBE = "#1192e8";
/** Vergleichsserie (Vorjahr) — bewusst neutral, damit sie zurücktritt. */
export const VERGLEICH_FARBE = "#8d8d8d";
export const CHART = {
grid: "#e0e0e0",
achse: "#8d8d8d",
text: "#161616",
textSekundaer: "#525252",
tooltipBg: "#393939",
tooltipFg: "#ffffff",
} as const;
/**
* Chart-Innenabstände.
* Carbon rechnet Achsenbeschriftungen in den Chart-Rahmen ein; die Karte selbst
* liefert bereits 16 px Padding, deshalb hier nur der Platz für Achsen.
*/
export const MARGIN = {
/** Vertikale Balken / Flächen mit Y-Achse links.
* Rechts genug Platz, damit die letzte Achsenbeschriftung nicht abschneidet. */
standard: { top: 8, right: 24, bottom: 4, left: 4 },
/** Horizontale Balken: links Platz für Kategorienamen. */
horizontal: { top: 4, right: 56, bottom: 4, left: 4 },
/** Sparkline / Mini-Chart ohne Achsen. */
kompakt: { top: 2, right: 2, bottom: 2, left: 2 },
} as const;
/** Typografie im Chart (Carbon: 12 px Labels, 14 px Titel). */
export const CHART_FONT = {
achse: 12,
label: 12,
legende: 12,
titel: 14,
} as const;
/** Gemeinsame Achsen-Props für Recharts (Carbon-Look: keine Ticks, dünne Achse). */
export const achseX = {
tickLine: false,
axisLine: { stroke: CHART.achse, strokeWidth: 1 },
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
dy: 4,
} as const;
export const achseY = {
tickLine: false,
axisLine: false,
tick: { fill: CHART.textSekundaer, fontSize: CHART_FONT.achse },
// 58 px laut docs/DESIGN-SYSTEM.md §2 — reicht für "245 T€".
// Werte im Mio.-Bereich bekommen über achsenFormat() eine kurze Einheit.
width: 58,
} as const;
/** Balkenbreiten nach Carbon: Abstand zwischen Balken ≥ halbe Balkenbreite. */
export const BALKEN = {
maxBreite: 48,
/** Recharts-Kategorieabstand in Prozent der Kategoriebreite. */
kategorieAbstand: "28%",
radius: 2,
hoeheHorizontal: 20,
} as const;
/** Donut-Proportionen (Carbon: kräftiger Ring, Gesamtwert in der Mitte). */
export const DONUT = {
innenRadius: "62%",
aussenRadius: "88%",
segmentAbstand: 2,
} as const;
/**
* Reduziert eine Kategorienliste auf MAX_KATEGORIEN und fasst den Rest
* zu "Sonstige" zusammen — verhindert unlesbare Donuts mit 12 Segmenten.
*/
export function gruppiereRest<T extends { name: string; value: number }>(
daten: T[],
max: number = MAX_KATEGORIEN
): { name: string; value: number }[] {
const sortiert = [...daten].sort((a, b) => b.value - a.value);
if (sortiert.length <= max) return sortiert.map((d) => ({ name: d.name, value: d.value }));
const kopf = sortiert.slice(0, max - 1).map((d) => ({ name: d.name, value: d.value }));
const rest = sortiert.slice(max - 1).reduce((summe, d) => summe + d.value, 0);
// "Sonstige" wird einsortiert, nicht angehaengt: der Ring laeuft sonst nicht
// mehr absteigend, obwohl DESIGN-SYSTEM.md §4 genau das verlangt.
return [...kopf, { name: "Sonstige", value: rest }].sort((a, b) => b.value - a.value);
}
/**
* Rundes Achsenraster.
*
* Recharts leitet Ticks aus max/(n-1) ab und rundet den Rohschritt auf — bei
* 219 T€ ergibt das 0/55/110/165/220. Krumme Achsenwerte kosten den Leser
* bei jedem Blick eine Rechenoperation. Deshalb Schrittweiten aus der
* 1-2-2,5-5-Reihe erzwingen.
*/
export function nettesRaster(
max: number,
maxIntervalle = 5
): { domain: [number, number]; ticks: number[] } {
if (!Number.isFinite(max) || max <= 0) return { domain: [0, 1], ticks: [0, 1] };
const groessenordnung = 10 ** Math.floor(Math.log10(max));
const kandidaten = [1, 2, 2.5, 5, 10]
.flatMap((k) => [(k * groessenordnung) / 10, k * groessenordnung, k * groessenordnung * 10])
.sort((a, b) => a - b);
for (const schritt of kandidaten) {
const anzahl = Math.ceil(max / schritt);
if (anzahl <= maxIntervalle) {
return {
domain: [0, schritt * anzahl],
ticks: Array.from({ length: anzahl + 1 }, (_, i) => i * schritt),
};
}
}
return { domain: [0, max], ticks: [0, max] };
}
/**
* Ab wie vielen Datenpunkten nur noch jede n-te Beschriftung gezeigt wird.
* Eine Konstante, damit Flaeche und Balken nicht unterschiedlich ausduennen.
*/
export const MAX_ACHSENLABELS = 12;
/** Farbe nach Index, rotiert sicher innerhalb der Palette. */
export function farbe(index: number): string {
return KATEGORIE_FARBEN[index % KATEGORIE_FARBEN.length];
}
/**
* Farbe für einen Kategorienamen: feste Zuordnung wenn vorhanden,
* sonst Position in der Palette.
*/
export function farbeFuer(name: string, index: number): string {
return GEWERK_FARBEN[name] ?? STATUS_KATEGORIE_FARBEN[name] ?? farbe(index);
}
/** Abgestufte Einfärbung für Trichterstufen — eine Farbe, abnehmende Sättigung. */
export function trichterFarbe(index: number, gesamt: number): string {
const anteil = gesamt > 1 ? index / (gesamt - 1) : 0;
// Von Carbon Purple 70 nach Purple 40: gleiche Farbfamilie, klare Reihenfolge.
const start = { r: 0x69, g: 0x29, b: 0xc4 };
const ende = { r: 0xbe, g: 0x95, b: 0xff };
const misch = (a: number, b: number) => Math.round(a + (b - a) * anteil);
return `rgb(${misch(start.r, ende.r)} ${misch(start.g, ende.g)} ${misch(start.b, ende.b)})`;
}
+132
View File
@@ -0,0 +1,132 @@
import type { ZeitraumPreset } from "../data/types";
/**
* Fester "Heute"-Bezugspunkt des Prototyps.
*
* Wichtig: Die Dummy-Daten sind deterministisch relativ zu diesem Datum erzeugt.
* Dadurch bleiben alle Zahlen ueber Reloads und Screenshots hinweg identisch.
* In der echten Software wird das durch `new Date()` ersetzt.
*/
export const HEUTE = new Date("2026-07-30T12:00:00");
export interface Zeitfenster {
von: Date;
bis: Date;
}
export function addTage(datum: Date, tage: number): Date {
const d = new Date(datum);
d.setDate(d.getDate() + tage);
return d;
}
export function addMonate(datum: Date, monate: number): Date {
const d = new Date(datum);
d.setMonth(d.getMonth() + monate);
return d;
}
export function toISO(datum: Date): string {
return datum.toISOString().slice(0, 10);
}
/** Zeitfenster eines Presets, bezogen auf HEUTE. */
export function zeitfenster(preset: ZeitraumPreset): Zeitfenster {
const bis = new Date(HEUTE);
bis.setHours(23, 59, 59, 999);
switch (preset) {
case "heute": {
const von = new Date(HEUTE);
von.setHours(0, 0, 0, 0);
return { von, bis };
}
case "7_tage":
return { von: addTage(bis, -7), bis };
case "30_tage":
return { von: addTage(bis, -30), bis };
case "quartal":
return { von: addMonate(bis, -3), bis };
case "jahr":
return { von: addMonate(bis, -12), bis };
case "alles":
default:
return { von: new Date("2000-01-01"), bis };
}
}
/** Gleich langes Fenster direkt davor — Basis fuer Trend-Vergleiche. */
export function vorperiode(preset: ZeitraumPreset): Zeitfenster {
const { von, bis } = zeitfenster(preset);
const dauer = bis.getTime() - von.getTime();
return {
von: new Date(von.getTime() - dauer),
bis: new Date(von.getTime() - 1),
};
}
export function imFenster(iso: string | null, fenster: Zeitfenster): boolean {
if (!iso) return false;
const t = new Date(iso).getTime();
return t >= fenster.von.getTime() && t <= fenster.bis.getTime();
}
/** ISO-Kalenderwoche als sortierbarer Schluessel: "2026-W31". */
export function isoWoche(datum: Date): string {
const d = new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate()));
// Donnerstag derselben Woche bestimmt nach ISO 8601 Jahr und Wochennummer.
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const jahresstart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const woche = Math.ceil(((d.getTime() - jahresstart.getTime()) / 86_400_000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(woche).padStart(2, "0")}`;
}
/** "2026-W31" -> "KW 31" */
export function formatWoche(schluessel: string): string {
return `KW ${schluessel.slice(6)}`;
}
/**
* Passende Buckets fuer eine Zeitreihe.
*
* Die Aufloesung folgt der Frage, nicht dem Kalender: Bei rund zwei Vorgaengen
* pro Tag ist eine Tagesreihe ueber 30 Tage Rauschen, kein Verlauf. Deshalb
* Tage nur bis eine Woche, danach Kalenderwochen, ab einem Jahr Monate.
*/
export function bucketsFuer(preset: ZeitraumPreset): {
art: "tag" | "woche" | "monat";
schluessel: string[];
} {
const { von, bis } = zeitfenster(preset);
if (preset === "heute" || preset === "7_tage") {
const schluessel: string[] = [];
for (let d = new Date(von); d <= bis; d = addTage(d, 1)) schluessel.push(toISO(d));
return { art: "tag", schluessel };
}
if (preset === "30_tage" || preset === "quartal") {
const schluessel: string[] = [];
for (let d = new Date(von); d <= bis; d = addTage(d, 7)) {
const key = isoWoche(d);
if (!schluessel.includes(key)) schluessel.push(key);
}
const letzte = isoWoche(bis);
if (!schluessel.includes(letzte)) schluessel.push(letzte);
return { art: "woche", schluessel };
}
const monate: string[] = [];
const start = preset === "alles" ? addMonate(bis, -23) : von;
for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= bis; d = addMonate(d, 1)) {
monate.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return { art: "monat", schluessel: monate };
}
/** Bucket-Schluessel eines Datums fuer die gewaehlte Aufloesung. */
export function bucketKey(iso: string, art: "tag" | "woche" | "monat"): string {
if (art === "monat") return iso.slice(0, 7);
if (art === "woche") return isoWoche(new Date(iso));
return iso.slice(0, 10);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Deutsche Zahlen-, Waehrungs- und Datumsformatierung.
* Regeln aus UI_UX_GUIDELINES.md (Abschnitt 3, Zahlenformatierung):
* Punkt als Tausender-Trenner, Komma als Dezimaltrenner, grosse Zahlen abkuerzen.
*/
const NF = new Intl.NumberFormat("de-DE");
/** 12450 -> "12.450" · 1250000 -> "1,3 Mio." */
export function formatZahl(value: number, dezimalstellen = 0): string {
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1).replace(".", ",")} Mio.`;
}
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: dezimalstellen,
maximumFractionDigits: dezimalstellen,
}).format(value);
}
/** 8420 -> "8.420 €" · 245000 -> "245.000 €" (immer voll, fuer KPI-Werte) */
export function formatEuro(value: number): string {
return `${NF.format(Math.round(value))} €`;
}
/**
* Kompakte Waehrung fuer Achsen und Balken-Labels.
* 8420 -> "8,4 T€" · 245000 -> "245 T€" · 1250000 -> "1,25 Mio. €"
*/
export function formatEuroKompakt(value: number): string {
const abs = Math.abs(value);
if (abs >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2).replace(".", ",")} Mio. €`;
}
if (abs >= 1_000) {
return `${(value / 1_000).toFixed(1).replace(".", ",")} T€`;
}
return `${NF.format(Math.round(value))} €`;
}
/**
* Noch kompakter für Achsenbeschriftungen: dort zählt jede Stelle,
* und Nachkommastellen sind bei einer Skala ohnehin überflüssig.
* 34000 -> "34 T€" · 1250000 -> "1,3 Mio. €"
*/
export function formatEuroAchse(value: number): string {
// Die Nulllinie traegt keine Einheit: "0 €" neben "10 T€" liest sich wie ein
// Einheitenwechsel mitten in der Skala.
if (value === 0) return "0";
const abs = Math.abs(value);
if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(".", ",")} Mio. €`;
if (abs >= 1_000) return `${Math.round(value / 1_000)} T€`;
return `${NF.format(Math.round(value))} €`;
}
/**
* Achsenformat, einmal aus dem Maximum gewaehlt.
*
* formatEuroAchse entscheidet die Einheit pro Wert — dadurch steht "0 €"
* neben "10 T€". Eine Achse traegt aber genau eine Einheit.
*/
export function achsenFormat(max: number): (wert: number) => string {
if (max >= 1_000_000) {
return (wert) =>
wert === 0 ? "0" : `${(wert / 1_000_000).toFixed(1).replace(".", ",")} Mio. €`;
}
if (max >= 1_000) {
return (wert) => (wert === 0 ? "0" : `${NF.format(Math.round(wert / 1_000))} T€`);
}
return (wert) => (wert === 0 ? "0" : formatEuro(wert));
}
/** 45.32 -> "45,3 %" */
export function formatProzent(value: number, dezimalstellen = 1): string {
return `${value.toFixed(dezimalstellen).replace(".", ",")} %`;
}
const NF1 = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
/** 2.4 -> "2,4 Tage" · 1234.5 -> "1.234,5 Tage" */
export function formatTage(value: number): string {
return `${NF1.format(value)} Tage`;
}
/** 2812.5 -> "2.812,5 h" */
export function formatStunden(value: number): string {
return `${NF1.format(value)} h`;
}
/**
* Ganze Tage mit korrektem Numerus — "1 Tag", "3 Tage".
* Ohne das steht in der Terminspalte "1 Tage ueber".
*/
export function tageLabel(anzahl: number, suffix = ""): string {
const wort = Math.abs(anzahl) === 1 ? "Tag" : "Tage";
return `${NF.format(anzahl)} ${wort}${suffix ? ` ${suffix}` : ""}`;
}
/** Dativform fuer "seit …": "seit 1 Tag" / "seit 99 Tagen". */
export function tageLabelDativ(anzahl: number): string {
const wort = Math.abs(anzahl) === 1 ? "Tag" : "Tagen";
return `${NF.format(anzahl)} ${wort}`;
}
/** "2026-07-30" -> "30.07.2026" */
export function formatDatum(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/** "2026-07-30" -> "30.07." */
export function formatDatumKurz(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
}
/** "2026-07" -> "Jul 26" */
export function formatMonatKurz(iso: string): string {
const [jahr, monat] = iso.split("-");
const namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
return `${namen[Number(monat) - 1]} ${jahr.slice(2)}`;
}
/** Tage zwischen heute und einem ISO-Datum (positiv = in der Vergangenheit). */
export function tageSeit(iso: string, referenz: Date): number {
const diff = referenz.getTime() - new Date(iso).getTime();
return Math.floor(diff / 86_400_000);
}
/**
* Trend zwischen aktueller und vorheriger Periode.
* Gibt Prozentpunkte und Richtung zurueck; < 0,5 % gilt als unveraendert.
*/
export function berechneTrend(
aktuell: number,
vorher: number
): { wert: number; richtung: "up" | "down" | "flat" } {
if (vorher === 0) {
return aktuell > 0 ? { wert: 100, richtung: "up" } : { wert: 0, richtung: "flat" };
}
const veraenderung = ((aktuell - vorher) / vorher) * 100;
if (Math.abs(veraenderung) < 0.5) return { wert: 0, richtung: "flat" };
return {
wert: Math.abs(veraenderung),
richtung: veraenderung > 0 ? "up" : "down",
};
}
/** Text auf maxLaenge kuerzen, Ellipse anhaengen. */
export function kuerze(text: string, maxLaenge: number): string {
return text.length <= maxLaenge ? text : `${text.slice(0, maxLaenge - 1)}…`;
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Das Entwicklungs-Abzeichen unten links überlagert die Seitenleiste und
// stört bei Screenshots für die Abstimmung.
devIndicators: false,
};
export default nextConfig;
+8465
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "hivegrid-dashboard",
"version": "0.1.0",
"private": true,
"description": "HiveGrid Analytics Dashboard - UI/UX Prototyp (SHK/Handwerk) zur Uebergabe an die Entwicklung",
"scripts": {
"dev": "next dev -p 4300",
"build": "next build",
"start": "next start -p 4300",
"lint": "eslint"
},
"dependencies": {
"@subframe/core": "^1.155.0",
"clsx": "^2.1.1",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^2.15.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
/*
* Documentation:
* Accordion — https://app.subframe.com/f023064a5ca0/library?component=Accordion_d2e81e20-863a-4027-826a-991d8910efd9
*/
import React from "react";
import { FeatherChevronRight } from "@subframe/core";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface ChevronProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Chevron> {
className?: string;
}
const Chevron = React.forwardRef<
React.ElementRef<typeof FeatherChevronRight>,
ChevronProps
>(function Chevron({ className, ...otherProps }: ChevronProps, ref) {
return (
<SubframeCore.Collapsible.Chevron {...otherProps}>
<FeatherChevronRight
className={SubframeUtils.twClassNames(
"text-body-default-regular-150 font-body-default-regular-150 text-icon-neutral-primary group-data-[state=open]/d2e81e20:-rotate-90 group-aria-[invalid=true]/d2e81e20:text-icon-feedback-negative group-aria-[disabled=true]/d2e81e20:text-icon-neutral-disabled",
className
)}
ref={ref}
/>
</SubframeCore.Collapsible.Chevron>
);
});
export interface ContentProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Content> {
children?: React.ReactNode;
className?: string;
}
const Content = React.forwardRef<HTMLDivElement, ContentProps>(function Content(
{ children, className, ...otherProps }: ContentProps,
ref
) {
return children ? (
<SubframeCore.Collapsible.Content asChild={true} {...otherProps}>
<div
className={SubframeUtils.twClassNames(
"flex w-full flex-col items-start gap-2",
className
)}
ref={ref}
>
{children}
</div>
</SubframeCore.Collapsible.Content>
) : null;
});
export interface TriggerProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Trigger> {
children?: React.ReactNode;
className?: string;
}
const Trigger = React.forwardRef<HTMLDivElement, TriggerProps>(function Trigger(
{ children, className, ...otherProps }: TriggerProps,
ref
) {
return children ? (
<SubframeCore.Collapsible.Trigger asChild={true} {...otherProps}>
<div
className={SubframeUtils.twClassNames(
"flex w-full cursor-pointer flex-col items-start gap-2",
className
)}
ref={ref}
>
{children}
</div>
</SubframeCore.Collapsible.Trigger>
) : null;
});
export interface AccordionRootProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Root> {
disabled?: boolean;
error?: boolean;
open?: boolean;
variant?: "card" | "ghost";
trigger?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}
const AccordionRoot = React.forwardRef<HTMLDivElement, AccordionRootProps>(
function AccordionRoot(
{
disabled = false,
error = false,
open,
variant = "card",
trigger,
children,
className,
defaultOpen = false,
...otherProps
}: AccordionRootProps,
ref
) {
return (
<SubframeCore.Collapsible.Root
open={open}
defaultOpen={defaultOpen}
asChild={true}
disabled={disabled}
{...otherProps}
>
<div
className={SubframeUtils.twClassNames(
"group/d2e81e20 flex w-full cursor-pointer flex-col items-start rounded-md border border-solid border-border-default bg-surface-neutral-secondary hover:bg-surface-neutral-tertiary",
{
"border-none bg-transparent hover:bg-transparent":
variant === "ghost",
"border-2 border-solid border-border-negative-solid hover:border-border-negative-solid":
error,
"border border-solid border-border-disabled bg-surface-neutral-disabled opacity-40 pointer-events-none hover:bg-surface-neutral-disabled hover:border-border-disabled":
disabled,
},
className
)}
ref={ref}
>
<Trigger>
{trigger ? (
<div className="flex w-full grow shrink-0 basis-0 flex-col items-start group-data-[state=open]/d2e81e20:flex-none">
{trigger}
</div>
) : null}
</Trigger>
<Content>
{children ? (
<div className="flex w-full grow shrink-0 basis-0 flex-col items-start">
{children}
</div>
) : null}
</Content>
</div>
</SubframeCore.Collapsible.Root>
);
}
);
export const Accordion = Object.assign(AccordionRoot, {
Chevron,
Content,
Trigger,
});
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Accordion as AccordionComponent } from "./Accordion";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Accordion = AccordionComponent;
@@ -0,0 +1,158 @@
"use client";
/*
* Documentation:
* Accordion Nested — https://app.subframe.com/f023064a5ca0/library?component=Accordion+Nested_91a645f4-5a9c-4c61-be00-d85b9d554e91
*/
import React from "react";
import { FeatherChevronRight } from "@subframe/core";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface ChevronProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Chevron> {
className?: string;
}
const Chevron = React.forwardRef<
React.ElementRef<typeof FeatherChevronRight>,
ChevronProps
>(function Chevron({ className, ...otherProps }: ChevronProps, ref) {
return (
<SubframeCore.Collapsible.Chevron {...otherProps}>
<FeatherChevronRight
className={SubframeUtils.twClassNames(
"text-body-default-regular-150 font-body-default-regular-150 text-icon-neutral-primary transition-transform group-data-[state=open]/91a645f4:-rotate-90 group-aria-[invalid=true]/91a645f4:text-icon-feedback-negative group-aria-[disabled=true]/91a645f4:text-icon-neutral-disabled",
className
)}
ref={ref}
/>
</SubframeCore.Collapsible.Chevron>
);
});
export interface ContentProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Content> {
children?: React.ReactNode;
className?: string;
}
const Content = React.forwardRef<HTMLDivElement, ContentProps>(function Content(
{ children, className, ...otherProps }: ContentProps,
ref
) {
return children ? (
<SubframeCore.Collapsible.Content asChild={true} {...otherProps}>
<div
className={SubframeUtils.twClassNames(
"flex w-full flex-col items-start gap-2",
className
)}
ref={ref}
>
{children}
</div>
</SubframeCore.Collapsible.Content>
) : null;
});
export interface TriggerProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Trigger> {
children?: React.ReactNode;
className?: string;
}
const Trigger = React.forwardRef<HTMLDivElement, TriggerProps>(function Trigger(
{ children, className, ...otherProps }: TriggerProps,
ref
) {
return children ? (
<SubframeCore.Collapsible.Trigger asChild={true} {...otherProps}>
<div
className={SubframeUtils.twClassNames(
"flex w-full cursor-pointer flex-col items-start gap-2",
className
)}
ref={ref}
>
{children}
</div>
</SubframeCore.Collapsible.Trigger>
) : null;
});
export interface AccordionNestedRootProps
extends React.ComponentProps<typeof SubframeCore.Collapsible.Root> {
disabled?: boolean;
error?: boolean;
open?: boolean;
variant?: "card" | "ghost";
trigger?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}
const AccordionNestedRoot = React.forwardRef<
HTMLDivElement,
AccordionNestedRootProps
>(function AccordionNestedRoot(
{
disabled = false,
error = false,
open,
variant = "card",
trigger,
children,
className,
defaultOpen = false,
...otherProps
}: AccordionNestedRootProps,
ref
) {
return (
<SubframeCore.Collapsible.Root
open={open}
defaultOpen={defaultOpen}
asChild={true}
disabled={disabled}
{...otherProps}
>
<div
className={SubframeUtils.twClassNames(
"group/91a645f4 flex w-full cursor-pointer flex-col items-start rounded-md border border-solid border-border-default bg-surface-neutral-secondary hover:bg-surface-neutral-tertiary",
{
"border-none bg-transparent hover:bg-transparent":
variant === "ghost",
"border-2 border-solid border-border-negative-solid hover:border-border-negative-solid":
error,
"border border-solid border-border-disabled bg-surface-neutral-disabled opacity-40 pointer-events-none hover:bg-surface-neutral-disabled hover:border-border-disabled":
disabled,
},
className
)}
ref={ref}
>
<Trigger>
{trigger ? (
<div className="flex w-full grow shrink-0 basis-0 flex-col items-start group-data-[state=open]/91a645f4:flex-none">
{trigger}
</div>
) : null}
</Trigger>
<Content>
{children ? (
<div className="flex w-full grow shrink-0 basis-0 flex-col items-start">
{children}
</div>
) : null}
</Content>
</div>
</SubframeCore.Collapsible.Root>
);
});
export const AccordionNested = Object.assign(AccordionNestedRoot, {
Chevron,
Content,
Trigger,
});
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { AccordionNested as AccordionNestedComponent } from "./AccordionNested";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const AccordionNested = AccordionNestedComponent;
@@ -0,0 +1,28 @@
## Wann verwenden
Überall, wo eine postalische Adresse erfasst oder bearbeitet wird (Kontakt, Projekt, Lieferadresse, Baustelle, Rechnungsadresse). Immer diese Komponente statt eines eigenen Adressformulars — nur so bleibt Feldreihenfolge und Beschriftung im ganzen Produkt identisch.
## Feldreihenfolge ist nicht verhandelbar
Die Reihenfolge folgt DIN 5008 / DHL-Adresslogik, also der Reihenfolge, in der eine Adresse auf ein Label gedruckt wird: Firma → Name → Straße + Nr. → Adresszusatz → PLZ + Ort → Land. Nutzer tippen Adressen als zusammenhängenden Block ab; jede Umsortierung erzeugt Lesefehler und Zahlendreher. **Land steht immer zuletzt**, nie oben.
PLZ steht links vor dem Ort (DE/AT/CH-Konvention), Hausnummer rechts nach der Straße. Beide Paare bleiben in einer Zeile, damit sie als eine Information gelesen werden.
## Pflicht vs. optional
- Fachlich nötig für Versand und Routing: Name (oder Firma), Straße, Nr., PLZ, Ort, Land.
- Optional und im Platzhalter als „(optional)" markiert: Firma, Adresszusatz. Adresszusatz nie für Straßendaten missbrauchen — er ist für Etage, Gebäude, c/o.
## Notiz zur Adresse
Interne Zustellinformation, kein Adressbestandteil: Zufahrt, Schlüsselübergabe, Parksituation, Torcode. Deshalb sitzt sie **unter der Trennlinie**, außerhalb des Adressblocks, und der Hilfetext sagt explizit, dass sie nicht auf Versandlabels gedruckt wird. Diese Trennung nicht auflösen und die Notiz nicht nach oben ziehen.
## Props im Einsatz
- `hideAddressType`: einschalten, wenn der Kontext den Typ schon festlegt (z. B. Dialog heißt „Rechnungsadresse ändern"). Doppelte Angabe derselben Information vermeiden.
- `hideNote`: nur einschalten, wo keine Zustellung stattfindet (z. B. reine Rechnungsadresse).
- `title` / `subtitle`: Titel benennt die Aktion („Adresse hinzufügen", „Lieferadresse bearbeiten"), der Untertitel sagt, wofür die Daten verwendet werden — nicht mit Bedienhinweisen füllen.
## Beschriftung
Labels bleiben kurz und in Nutzersprache („Nr.", nicht „Hausnummer/Zusatz"), Platzhalter zeigen ein echtes Beispiel („10115", „12a") statt einer Formatanweisung. Keine Sternchen für Pflichtfelder — optionale Felder werden markiert, nicht umgekehrt.
@@ -0,0 +1,478 @@
// @subframe/sync-disable
// @ts-nocheck
"use client";
/*
* Documentation:
* Address Dialog — https://app.subframe.com/f023064a5ca0/library?component=Address+Dialog_b3a6c4d9-a908-4acc-881c-132a867b5ef6
* Avatar — https://app.subframe.com/f023064a5ca0/library?component=Avatar_bec25ae6-5010-4485-b46b-cf79e3943ab2
* Button — https://app.subframe.com/f023064a5ca0/library?component=Button_3b777358-b86b-40af-9327-891efc6826fe
* Checkbox — https://app.subframe.com/f023064a5ca0/library?component=Checkbox_3816e3b5-c48c-499b-b45e-0777c6972523
* Dialog — https://app.subframe.com/f023064a5ca0/library?component=Dialog_ca59db17-43fb-4247-8094-3c55162e902d
* Icon Button — https://app.subframe.com/f023064a5ca0/library?component=Icon+Button_af9405b1-8c54-4e01-9786-5aad308224f6
* Icon with Background — https://app.subframe.com/f023064a5ca0/library?component=Icon+with+Background_4a18e9fc-cea3-46ca-8c53-2412462342ad
* Search Input — https://app.subframe.com/f023064a5ca0/library?component=Search+Input_75a35ed3-52c0-4435-8586-e390eb77d723
* Select — https://app.subframe.com/f023064a5ca0/library?component=Select_bb88f90b-8c43-4b73-9c2f-3558ce7838f3
* Text Area — https://app.subframe.com/f023064a5ca0/library?component=Text+Area_4ec05ee8-8f1c-46b2-b863-5419aa7f5cce
* Text Input — https://app.subframe.com/f023064a5ca0/library?component=Text+Input_be48ca43-f8e7-4c0e-8870-d219ea11abfe
*/
import React from "react";
import { FeatherArrowRight } from "@subframe/core";
import { FeatherCheck } from "@subframe/core";
import { FeatherChevronDown } from "@subframe/core";
import { FeatherMapPin } from "@subframe/core";
import { FeatherUserSearch } from "@subframe/core";
import { FeatherX } from "@subframe/core";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
import { Avatar } from "../Avatar";
import { Button } from "../Button";
import { Checkbox } from "../Checkbox";
import { Dialog } from "../Dialog";
import { IconButton } from "../IconButton";
import { IconWithBackground } from "../IconWithBackground";
import { SearchInput } from "../SearchInput";
import { Select } from "../Select";
import { TextArea } from "../TextArea";
import { TextInput } from "../TextInput";
export interface AddressDialogRootProps
extends React.ComponentProps<typeof Dialog> {
hideAddressType?: boolean;
hideNote?: boolean;
hideCompany?: boolean;
hideDefaultAddress?: boolean;
hideImport?: boolean;
title?: React.ReactNode;
subtitle?: React.ReactNode;
className?: string;
}
const AddressDialogRoot = React.forwardRef<
React.ElementRef<typeof Dialog>,
AddressDialogRootProps
>(function AddressDialogRoot(
{
hideAddressType = false,
hideNote = false,
hideCompany = false,
hideDefaultAddress = false,
hideImport = false,
title,
subtitle,
className,
...otherProps
}: AddressDialogRootProps,
ref
) {
return (
<Dialog
className={SubframeUtils.twClassNames("group/b3a6c4d9", className)}
ref={ref}
{...otherProps}
>
<Dialog.Content
className="mobile:w-full mobile:max-w-[calc(100vw-32px)]"
size="medium"
hideCloseIcon={true}
>
<div className="flex w-full flex-col items-start gap-4">
<div className="flex w-full items-start justify-between gap-3">
<div className="flex items-start gap-3">
<IconWithBackground
variant="primary"
size="medium"
icon={<FeatherMapPin />}
/>
<div className="flex flex-col items-start gap-1">
{title ? (
<span className="text-heading-0 font-heading-0 text-text-neutral-primary">
{title}
</span>
) : null}
{subtitle ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{subtitle}
</span>
) : null}
</div>
</div>
<IconButton variant="tertiary" size="small" icon={<FeatherX />} />
</div>
<div className="flex w-full flex-col items-start gap-3">
<div
className={SubframeUtils.twClassNames(
"flex w-full flex-col items-start gap-3",
{ hidden: hideImport }
)}
>
<div className="flex w-full items-center justify-between rounded-md border border-solid border-border-default bg-surface-neutral-secondary px-3 py-3 gap-3 mobile:flex-wrap">
<div className="flex flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">
Aus Kontakt übernehmen
</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
Adressdaten aus einem bestehenden Kontakt vorbefüllen
</span>
</div>
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild={true}>
<Button
variant="secondary"
size="small"
icon={<FeatherUserSearch />}
iconRight={<FeatherChevronDown />}
>
Kontakt suchen
</Button>
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content
side="bottom"
align="end"
sideOffset={4}
asChild={true}
>
<div className="flex w-80 flex-none flex-col items-start overflow-hidden rounded-md border border-solid border-border-default bg-surface-neutral-secondary shadow-lg">
<div className="flex w-full flex-col items-start px-2 py-2">
<SearchInput
className="h-9 w-full flex-none"
size="small"
fillContainer={true}
label="Label"
helpText="Help text"
message="Hilfetext"
hideShortcut={true}
>
<SearchInput.Input placeholder="Kontakt oder Firma suchen" />
</SearchInput>
</div>
<div className="flex h-px w-full flex-none flex-col items-start bg-border-default" />
<div className="flex w-full flex-col items-start px-1 py-1">
<div className="flex w-full items-center gap-3 rounded-md px-2 py-2 cursor-pointer hover:bg-surface-neutral-tertiary">
<Avatar size="small">MB</Avatar>
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<span className="line-clamp-1 w-full text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary">
Markus Bauer
</span>
<span className="line-clamp-1 w-full text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
Bauer Elektrotechnik GmbH · Berlin
</span>
</div>
</div>
<div className="flex w-full items-center gap-3 rounded-md px-2 py-2 cursor-pointer hover:bg-surface-neutral-tertiary">
<Avatar size="small">SK</Avatar>
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<span className="line-clamp-1 w-full text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary">
Sandra Keller
</span>
<span className="line-clamp-1 w-full text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
Keller Immobilien · Potsdam
</span>
</div>
</div>
<div className="flex w-full items-center gap-3 rounded-md px-2 py-2 cursor-pointer hover:bg-surface-neutral-tertiary">
<Avatar size="small">JR</Avatar>
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<span className="line-clamp-1 w-full text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary">
Jonas Richter
</span>
<span className="line-clamp-1 w-full text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
Richter Bau AG · Hamburg
</span>
</div>
</div>
<div className="flex w-full items-center gap-3 rounded-md px-2 py-2 cursor-pointer hover:bg-surface-neutral-tertiary">
<Avatar size="small">EV</Avatar>
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<span className="line-clamp-1 w-full text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-primary">
Elena Vogt
</span>
<span className="line-clamp-1 w-full text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
— · München
</span>
</div>
</div>
</div>
<div className="flex h-px w-full flex-none flex-col items-start bg-border-default" />
<div className="flex w-full items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-surface-neutral-tertiary">
<span className="grow shrink-0 basis-0 text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-secondary">
Alle Kontakte durchsuchen
</span>
<FeatherArrowRight className="text-body-small-regular-150 font-body-small-regular-150 text-icon-neutral-tertiary" />
</div>
</div>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
</div>
<div className="flex h-px w-full flex-none flex-col items-start bg-border-default" />
</div>
<Select
className={SubframeUtils.twClassNames({
hidden: hideAddressType,
})}
variant="outline"
fillContainer={true}
showLabel={true}
label="Adresstyp"
placeholder="Lieferadresse"
helpText=""
errorText=""
message=""
>
<Select.Item
value="Lieferadresse"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Lieferadresse
</Select.Item>
<Select.Item
value="Baustelle"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Baustelle
</Select.Item>
<Select.Item
value="Rechnungsadresse"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Rechnungsadresse
</Select.Item>
<Select.Item
value="Büro"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Büro
</Select.Item>
<Select.Item
value="Privat"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Privat
</Select.Item>
</Select>
<TextInput
className={SubframeUtils.twClassNames("h-auto w-full flex-none", {
hidden: hideCompany,
})}
fillContainer={true}
showLabel={true}
label="Firma"
helpText=""
message=""
>
<TextInput.Input placeholder="Firmenname (optional)" value="" />
</TextInput>
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="Name / Ansprechpartner"
helpText=""
message=""
>
<TextInput.Input placeholder="Vor- und Nachname" value="" />
</TextInput>
<div className="flex w-full items-end gap-3 mobile:flex-wrap">
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="Straße"
helpText=""
message=""
>
<TextInput.Input placeholder="Straßenname" value="" />
</TextInput>
</div>
<div className="flex w-48 flex-none flex-col items-start">
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="Nr."
helpText=""
message=""
>
<TextInput.Input placeholder="12a" value="" />
</TextInput>
</div>
</div>
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="Adresszusatz"
helpText=""
message=""
>
<TextInput.Input
placeholder="Etage, Gebäude, c/o (optional)"
value=""
/>
</TextInput>
<div className="flex w-full items-end gap-3 mobile:flex-wrap">
<div className="flex w-48 flex-none flex-col items-start">
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="PLZ"
helpText=""
message=""
>
<TextInput.Input placeholder="10115" value="" />
</TextInput>
</div>
<div className="flex grow shrink-0 basis-0 flex-col items-start">
<TextInput
className="h-auto w-full flex-none"
fillContainer={true}
showLabel={true}
label="Ort"
helpText=""
message=""
>
<TextInput.Input placeholder="Berlin" value="" />
</TextInput>
</div>
</div>
<Select
variant="outline"
fillContainer={true}
showLabel={true}
label="Land"
placeholder="Deutschland"
helpText=""
errorText=""
message=""
>
<Select.Item
value="Deutschland"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Deutschland
</Select.Item>
<Select.Item
value="Österreich"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Österreich
</Select.Item>
<Select.Item
value="Schweiz"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Schweiz
</Select.Item>
<Select.Item
value="Niederlande"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Niederlande
</Select.Item>
<Select.Item
value="Polen"
showItemText={true}
content={
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-tertiary">
Option
</span>
}
>
Polen
</Select.Item>
</Select>
<div
className={SubframeUtils.twClassNames(
"flex w-full flex-col items-start gap-3",
{ hidden: hideNote }
)}
>
<div className="flex h-px w-full flex-none flex-col items-start bg-border-default" />
<TextArea
className="h-auto w-full flex-none max-w-[9999px]"
error={false}
fillContainer={false}
showLabel={true}
label="Notiz zur Adresse"
helpText="Nur intern sichtbar, wird nicht auf Versandlabels gedruckt"
errorText=""
message=""
>
<TextArea.Input
className="h-16 w-full flex-none min-h-[64px]"
placeholder="z. B. Zufahrt über den Hinterhof, Schlüssel beim Nachbarn, Parkmöglichkeit"
value=""
/>
</TextArea>
</div>
<Checkbox
className={SubframeUtils.twClassNames({
hidden: hideDefaultAddress,
})}
label="Als Standardadresse verwenden"
description=""
helpText=""
/>
</div>
<div className="flex w-full items-center justify-end gap-2 bg-surface-neutral-secondary pt-2 sticky bottom-0">
<Button variant="tertiary">Abbrechen</Button>
<Button variant="primary" icon={<FeatherCheck />}>
Adresse speichern
</Button>
</div>
</div>
</Dialog.Content>
</Dialog>
);
});
export const AddressDialog = AddressDialogRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { AddressDialog as AddressDialogComponent } from "./AddressDialog";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const AddressDialog = AddressDialogComponent;
+153
View File
@@ -0,0 +1,153 @@
"use client";
/*
* Documentation:
* Alert — https://app.subframe.com/f023064a5ca0/library?component=Alert_3a65613d-d546-467c-80f4-aaba6a7edcd5
* Icon Button — https://app.subframe.com/f023064a5ca0/library?component=Icon+Button_af9405b1-8c54-4e01-9786-5aad308224f6
*/
import React from "react";
import { FeatherAlertCircle } from "@subframe/core";
import { FeatherAlertTriangle } from "@subframe/core";
import { FeatherCheckCircle } from "@subframe/core";
import { FeatherInbox } from "@subframe/core";
import { FeatherInfo } from "@subframe/core";
import { FeatherX } from "@subframe/core";
import * as SubframeUtils from "../../utils";
import { IconButton } from "../IconButton";
export interface AlertRootProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
variant?: "information" | "success" | "warning" | "error" | "neutral";
notDismissible?: boolean;
title?: React.ReactNode;
description?: React.ReactNode;
textSize?: "small" | "medium";
className?: string;
}
const AlertRoot = React.forwardRef<HTMLDivElement, AlertRootProps>(
function AlertRoot(
{
variant = "information",
notDismissible = false,
title,
description,
textSize = "medium",
className,
...otherProps
}: AlertRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/3a65613d flex w-full flex-col items-start gap-2 rounded-md border border-solid border-border-information-subtle bg-surface-feedback-subtle-information px-4 py-5 relative",
{
"px-3 py-3.5": textSize === "small",
"border border-solid border-border-default bg-surface-feedback-subtle-neutral":
variant === "neutral",
"border border-solid border-border-negative-subtle bg-surface-feedback-subtle-negative":
variant === "error",
"border border-solid border-border-warning-subtle bg-surface-feedback-subtle-warning":
variant === "warning",
"border border-solid border-border-positive-subtle bg-surface-feedback-subtle-positive":
variant === "success",
},
className
)}
ref={ref}
{...otherProps}
>
<div
className={SubframeUtils.twClassNames(
"flex w-full items-start gap-3",
{ "gap-2.5": textSize === "small" }
)}
>
<FeatherInfo
className={SubframeUtils.twClassNames(
"text-heading-0 font-heading-0 text-icon-feedback-information",
{
"text-body-large font-body-large": textSize === "small",
hidden:
variant === "neutral" ||
variant === "error" ||
variant === "warning" ||
variant === "success",
}
)}
/>
<FeatherCheckCircle
className={SubframeUtils.twClassNames(
"hidden text-heading-0 font-heading-0 text-icon-feedback-positive",
{ "inline-flex": variant === "success" }
)}
/>
<FeatherAlertTriangle
className={SubframeUtils.twClassNames(
"hidden text-heading-0 font-heading-0 text-icon-feedback-warning",
{ "inline-flex": variant === "warning" }
)}
/>
<FeatherAlertCircle
className={SubframeUtils.twClassNames(
"hidden text-heading-0 font-heading-0 text-icon-feedback-negative",
{ "inline-flex": variant === "error" }
)}
/>
<FeatherInbox
className={SubframeUtils.twClassNames(
"hidden text-heading-0 font-heading-0 text-icon-neutral-secondary",
{ "inline-flex": variant === "neutral" }
)}
/>
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-2">
{title ? (
<span
className={SubframeUtils.twClassNames(
"w-full whitespace-pre-wrap text-body-default-medium-0 font-body-default-medium-0 text-text-feedback-information",
{
"text-body-small-medium-0 font-body-small-medium-0":
textSize === "small",
"text-text-neutral-primary": variant === "neutral",
"text-text-feedback-negative": variant === "error",
"text-text-feedback-warning": variant === "warning",
"text-text-feedback-positive": variant === "success",
}
)}
>
{title}
</span>
) : null}
{description ? (
<span
className={SubframeUtils.twClassNames(
"w-full whitespace-pre-wrap text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary",
{
"text-body-small-regular-150 font-body-small-regular-150":
textSize === "small",
}
)}
>
{description}
</span>
) : null}
</div>
<div
className={SubframeUtils.twClassNames(
"flex items-center justify-end gap-1 px-1 py-1",
{ hidden: notDismissible }
)}
>
<IconButton variant="ghost" square={true} icon={<FeatherX />} />
</div>
</div>
<div className="hidden items-start justify-center absolute top-3 right-3">
<FeatherX className="text-body-default-regular-0 font-body-default-regular-0 text-icon-neutral-secondary" />
</div>
</div>
);
}
);
export const Alert = AlertRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Alert as AlertComponent } from "./Alert";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Alert = AlertComponent;
+42
View File
@@ -0,0 +1,42 @@
"use client";
/*
* Documentation:
* Area Chart — https://app.subframe.com/f023064a5ca0/library?component=Area+Chart_8aa1e7b3-5db6-4a62-aa49-137ced21a231
*/
import React from "react";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface AreaChartRootProps
extends React.ComponentProps<typeof SubframeCore.AreaChart> {
stacked?: boolean;
className?: string;
}
const AreaChartRoot = React.forwardRef<
React.ElementRef<typeof SubframeCore.AreaChart>,
AreaChartRootProps
>(function AreaChartRoot(
{ stacked = false, className, ...otherProps }: AreaChartRootProps,
ref
) {
return (
<SubframeCore.AreaChart
className={SubframeUtils.twClassNames("h-80 w-full", className)}
ref={ref}
stacked={stacked}
colors={[
"#3b82f6",
"#bfdbfe",
"#2563eb",
"#93c5fd",
"#1d4ed8",
"#60a5fa",
]}
{...otherProps}
/>
);
});
export const AreaChart = AreaChartRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { AreaChart as AreaChartComponent } from "./AreaChart";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const AreaChart = AreaChartComponent;
+108
View File
@@ -0,0 +1,108 @@
"use client";
/*
* Documentation:
* Avatar — https://app.subframe.com/f023064a5ca0/library?component=Avatar_bec25ae6-5010-4485-b46b-cf79e3943ab2
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface AvatarRootProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
color?: "neutral" | "blue" | "green" | "red" | "yellow" | "purple";
size?:
| "extra-small"
| "small"
| "medium"
| "large"
| "extra-large"
| "2-x-large"
| "3-x-large";
border?: boolean;
children?: React.ReactNode;
image?: string;
className?: string;
}
const AvatarRoot = React.forwardRef<HTMLDivElement, AvatarRootProps>(
function AvatarRoot(
{
color = "neutral",
size = "medium",
border = false,
children,
image,
className,
...otherProps
}: AvatarRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/bec25ae6 flex h-10 w-10 flex-col items-center justify-center gap-2 overflow-hidden rounded-round bg-surface-neutral-tertiary relative",
{
"border border-solid border-border-on-primary": border,
"h-32 w-32": size === "3-x-large",
"h-20 w-20": size === "2-x-large",
"h-16 w-16": size === "extra-large",
"h-12 w-12": size === "large",
"h-8 w-8": size === "small",
"h-6 w-6": size === "extra-small",
"bg-surface-ui-subtle-purple-default": color === "purple",
"bg-surface-ui-subtle-yellow-default": color === "yellow",
"bg-surface-ui-subtle-red-default": color === "red",
"bg-surface-ui-subtle-green-default": color === "green",
"bg-surface-ui-subtle-blue-default": color === "blue",
},
className
)}
ref={ref}
{...otherProps}
>
{children ? (
<span
className={SubframeUtils.twClassNames(
"line-clamp-1 w-full text-body-default-medium-150 font-body-default-medium-150 text-text-neutral-secondary text-center absolute",
{
"text-title-hero font-title-hero": size === "3-x-large",
"text-title-page font-title-page": size === "2-x-large",
"text-subtitle font-subtitle": size === "extra-large",
"text-heading-0 font-heading-0": size === "large",
"text-body-small-medium-150 font-body-small-medium-150":
size === "small",
"text-body-extra-small-medium-150 font-body-extra-small-medium-150":
size === "extra-small",
"text-text-ui-purple": color === "purple",
"text-text-ui-yellow": color === "yellow",
"text-text-ui-red": color === "red",
"text-text-ui-green": color === "green",
"text-text-ui-blue": color === "blue",
}
)}
>
{children}
</span>
) : null}
{image ? (
<img
className={SubframeUtils.twClassNames(
"h-10 w-10 flex-none object-cover absolute",
{
"h-24 w-24": size === "3-x-large",
"h-20 w-20": size === "2-x-large",
"h-16 w-16": size === "extra-large",
"h-12 w-12": size === "large",
"h-8 w-8": size === "small",
"h-6 w-6": size === "extra-small",
}
)}
src={image}
/>
) : null}
</div>
);
}
);
export const Avatar = AvatarRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Avatar as AvatarComponent } from "./Avatar";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Avatar = AvatarComponent;
+110
View File
@@ -0,0 +1,110 @@
"use client";
/*
* Documentation:
* Avatar Group — https://app.subframe.com/f023064a5ca0/library?component=Avatar+Group_6e7444ab-1d67-47f5-af23-1a2d3ecf1016
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface AvatarOverflowProps
extends React.HTMLAttributes<HTMLDivElement> {
border?: boolean;
overflowLabel?: React.ReactNode;
className?: string;
}
const AvatarOverflow = React.forwardRef<HTMLDivElement, AvatarOverflowProps>(
function AvatarOverflow(
{
border = false,
overflowLabel,
className,
...otherProps
}: AvatarOverflowProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/dbd63208 flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-round bg-surface-neutral-tertiary group/avataroverflow shrink-0 relative",
{ "border border-solid border-border-on-primary": border },
className
)}
ref={ref}
{...otherProps}
>
{overflowLabel ? (
<span className="text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-secondary">
{overflowLabel}
</span>
) : null}
</div>
);
}
);
export interface AvatarGroupRootProps
extends React.HTMLAttributes<HTMLDivElement> {
hoverEffect?: "on" | "off";
border?: boolean;
showOverflow?: boolean;
children?: React.ReactNode;
overflowLabel?: React.ReactNode;
className?: string;
}
const AvatarGroupRoot = React.forwardRef<HTMLDivElement, AvatarGroupRootProps>(
function AvatarGroupRoot(
{
hoverEffect = "off",
border = false,
showOverflow = false,
children,
overflowLabel,
className,
...otherProps
}: AvatarGroupRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/6e7444ab items-center group/mtavatargroup inline-flex [&>*:not(:first-child)]:-ml-2",
className
)}
ref={ref}
{...otherProps}
>
{children ? (
<div
className={SubframeUtils.twClassNames(
"items-center inline-flex [&>*:not(:first-child)]:-ml-2 [&>*]:relative relative [&>*]:z-0 z-0 [&>*:nth-child(1)]:z-[1] [&>*:nth-child(2)]:z-[2] [&>*:nth-child(3)]:z-[3] [&>*:nth-child(4)]:z-[4] [&>*:nth-child(5)]:z-[5]",
{
"[&>*]:border [&>*]:border-solid [&>*]:border-border-on-primary":
border,
"[&>*]:transition-transform [&>*]:duration-200 [&>*:hover]:scale-110 [&>*:hover]:rotate-6 [&>*:hover]:z-20":
hoverEffect === "on",
}
)}
>
{children}
</div>
) : null}
<AvatarOverflow
className={SubframeUtils.twClassNames("hidden relative z-10", {
flex: showOverflow,
"border border-solid border-border-on-primary": border,
"transition-transform duration-200 hover:scale-110 hover:rotate-6":
hoverEffect === "on",
})}
overflowLabel={overflowLabel}
/>
</div>
);
}
);
export const AvatarGroup = Object.assign(AvatarGroupRoot, {
AvatarOverflow,
});
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { AvatarGroup as AvatarGroupComponent } from "./AvatarGroup";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const AvatarGroup = AvatarGroupComponent;
+317
View File
@@ -0,0 +1,317 @@
"use client";
/*
* Documentation:
* Badge — https://app.subframe.com/f023064a5ca0/library?component=Badge_7d75e221-355a-4695-acb7-3902a9f6ffde
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface BadgeRootProps extends React.HTMLAttributes<HTMLDivElement> {
tone?:
| "red-subtle"
| "orange-subtle"
| "yellow-subtle"
| "pear-subtle"
| "lime-subtle"
| "green-subtle"
| "teal-subtle"
| "ocean-subtle"
| "sky-subtle"
| "blue-subtle"
| "indigo-subtle"
| "violet-subtle"
| "fuchsia-subtle"
| "pink-subtle"
| "sand-subtle"
| "brown-subtle"
| "red-solid"
| "orange-solid"
| "yellow-solid"
| "pear-solid"
| "lime-solid"
| "green-solid"
| "teal-solid"
| "ocean-solid"
| "sky-solid"
| "blue-solid"
| "indigo-solid"
| "violet-solid"
| "fuchsia-solid"
| "pink-solid"
| "sand-solid"
| "brown-solid"
| "red-outline"
| "orange-outline"
| "yellow-outline"
| "pear-outline"
| "lime-outline"
| "green-outline"
| "teal-outline"
| "ocean-outline"
| "sky-outline"
| "blue-outline"
| "indigo-outline"
| "violet-outline"
| "fuchsia-outline"
| "pink-outline"
| "sand-outline"
| "brown-outline"
| "brand-primary-solid"
| "brand-secondary-solid"
| "neutral-solid"
| "brand-primary-subtle"
| "brand-secondary-subtle"
| "neutral-subtle"
| "white";
children?: React.ReactNode;
size?: "small" | "medium" | "large";
hideDot?: boolean;
className?: string;
}
const BadgeRoot = React.forwardRef<HTMLDivElement, BadgeRootProps>(
function BadgeRoot(
{
tone = "neutral-subtle",
children,
size = "medium",
hideDot = false,
className,
...otherProps
}: BadgeRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/7d75e221 flex h-6 items-center gap-2 rounded-round border border-solid border-border-default bg-surface-feedback-subtle-neutral px-2.5 py-1",
{
"h-8 py-2": size === "large",
"h-5 py-0.5": size === "small",
"bg-surface-neutral-secondary": tone === "white",
"border border-solid border-brand-brand-secondary-surface-subtle-default bg-brand-brand-secondary-surface-subtle-default":
tone === "brand-secondary-subtle",
"border border-solid border-brand-brand-surface-subtle-default bg-brand-brand-surface-subtle-default":
tone === "brand-primary-subtle",
"border border-solid border-surface-feedback-solid-neutral bg-surface-feedback-solid-neutral":
tone === "neutral-solid",
"border border-solid border-brand-brand-secondary-surface-solid-default bg-brand-brand-secondary-surface-solid-default":
tone === "brand-secondary-solid",
"border border-solid border-brand-brand-surface-solid-default bg-brand-brand-surface-solid-default":
tone === "brand-primary-solid",
"border-2 border-solid border-[#c2a682] bg-[#fafafa]":
tone === "brown-outline",
"border-2 border-solid border-[#c8baa9] bg-[#fafafa]":
tone === "sand-outline",
"border-2 border-solid border-[#f9a8d4] bg-[#fafafa]":
tone === "pink-outline",
"border-2 border-solid border-[#f0abfc] bg-[#fafafa]":
tone === "fuchsia-outline",
"border-2 border-solid border-[#cfb2ff] bg-[#fafafa]":
tone === "violet-outline",
"border-2 border-solid border-[#b3b8ff] bg-[#fafafa]":
tone === "indigo-outline",
"border-2 border-solid border-[#85c7ff] bg-[#fafafa]":
tone === "blue-outline",
"border-2 border-solid border-[#79c7ff] bg-[#fafafa]":
tone === "sky-outline",
"border-2 border-solid border-[#81dbf8] bg-[#fafafa]":
tone === "ocean-outline",
"border-2 border-solid border-[#51f7e5] bg-[#fafafa]":
tone === "teal-outline",
"border-2 border-solid border-[#85f0a3] bg-[#fafafa]":
tone === "green-outline",
"border-2 border-solid border-[#bef264] bg-[#fafafa]":
tone === "lime-outline",
"border-2 border-solid border-[#e0eb6b] bg-[#fafafa]":
tone === "pear-outline",
"border-2 border-solid border-[#f6d84e] bg-[#fafafa]":
tone === "yellow-outline",
"border-2 border-solid border-[#ffa570] bg-[#fafafa]":
tone === "orange-outline",
"border-2 border-solid border-[#fba6a6] bg-[#fafafa]":
tone === "red-outline",
"border border-solid border-[#8a5f44] bg-[#8a5f44]":
tone === "brown-solid",
"border border-solid border-[#947562] bg-[#947562]":
tone === "sand-solid",
"border border-solid border-[#db2777] bg-[#db2777]":
tone === "pink-solid",
"border border-solid border-[#c026d3] bg-[#c026d3]":
tone === "fuchsia-solid",
"border border-solid border-[#7c2df0] bg-[#7c2df0]":
tone === "violet-solid",
"border border-solid border-[#4835f2] bg-[#4835f2]":
tone === "indigo-solid",
"border border-solid border-[#0c4bff] bg-[#0c4bff]":
tone === "blue-solid",
"border border-solid border-[#0071ce] bg-[#0071ce]":
tone === "sky-solid",
"border border-solid border-[#0890c1] bg-[#0890c1]":
tone === "ocean-solid",
"border border-solid border-[#05807c] bg-[#05807c]":
tone === "teal-solid",
"border border-solid border-[#15a43e] bg-[#15a43e]":
tone === "green-solid",
"border border-solid border-[#4d7c0f] bg-[#4d7c0f]":
tone === "lime-solid",
"border border-solid border-[#677615] bg-[#677615]":
tone === "pear-solid",
"border border-solid border-[#e0c200] bg-[#e0c200]":
tone === "yellow-solid",
"border border-solid border-[#ea580c] bg-[#ea580c]":
tone === "orange-solid",
"border border-solid border-[#da2828] bg-[#da2828]":
tone === "red-solid",
"border border-solid border-[#d9c8af] bg-[#ebe3d6]":
tone === "brown-subtle",
"border border-solid border-[#ded6ca] bg-[#ede9e3]":
tone === "sand-subtle",
"border border-solid border-[#fbcfe8] bg-[#fce7f3]":
tone === "pink-subtle",
"border border-solid border-[#f5d0fe] bg-[#fae8ff]":
tone === "fuchsia-subtle",
"border border-solid border-[#e4d4ff] bg-[#f0e7ff]":
tone === "violet-subtle",
"border border-solid border-[#d5d9ff] bg-[#e8e9ff]":
tone === "indigo-subtle",
"border border-solid border-[#b3dfff] bg-[#d5efff]":
tone === "blue-subtle",
"border border-solid border-border-information-subtle bg-surface-feedback-subtle-information":
tone === "sky-subtle",
"border border-solid border-[#bceafb] bg-[#e1f4fd]":
tone === "ocean-subtle",
"border border-solid border-[#90ffee] bg-[#c8fff7]":
tone === "teal-subtle",
"border border-solid border-[#baf8cb] bg-[#dcfce4]":
tone === "green-subtle",
"border border-solid border-[#d9f99d] bg-[#ecfccb]":
tone === "lime-subtle",
"border border-solid border-[#eff4a2] bg-[#f8facd]":
tone === "pear-subtle",
"border border-solid border-[#faeb8e] bg-[#fcf6c5]":
tone === "yellow-subtle",
"border border-solid border-[#ffcaa8] bg-[#ffe7d4]":
tone === "orange-subtle",
"border border-solid border-[#fdcbcb] bg-[#fee2e2]":
tone === "red-subtle",
},
className
)}
ref={ref}
{...otherProps}
>
<div
className={SubframeUtils.twClassNames(
"flex h-1 w-1 flex-none flex-col items-start rounded-round bg-icon-neutral-on-subtle",
{
hidden: hideDot,
"bg-brand-brand-secondary-surface-solid-default":
tone === "brand-secondary-subtle",
"bg-brand-brand-surface-solid-default":
tone === "brand-primary-subtle",
"bg-icon-neutral-on-solid":
tone === "neutral-solid" ||
tone === "brand-secondary-solid" ||
tone === "brand-primary-solid" ||
tone === "brown-solid" ||
tone === "sand-solid" ||
tone === "pink-solid" ||
tone === "fuchsia-solid" ||
tone === "violet-solid" ||
tone === "indigo-solid" ||
tone === "blue-solid" ||
tone === "sky-solid" ||
tone === "ocean-solid" ||
tone === "teal-solid" ||
tone === "green-solid" ||
tone === "lime-solid" ||
tone === "pear-solid" ||
tone === "yellow-solid" ||
tone === "orange-solid" ||
tone === "red-solid",
"bg-[#6f4a39]": tone === "brown-outline",
"bg-[#7c6052]": tone === "sand-outline",
"bg-[#be185d]": tone === "pink-outline",
"bg-[#a21caf]": tone === "fuchsia-outline",
"bg-[#691cd4]": tone === "violet-outline",
"bg-[#3923de]": tone === "indigo-outline",
"bg-[#003beb]": tone === "blue-outline",
"bg-[#0071ce]": tone === "sky-outline" || tone === "sky-subtle",
"bg-[#08739c]": tone === "ocean-outline",
"bg-[#05807c]": tone === "teal-outline" || tone === "teal-subtle",
"bg-[#148134]": tone === "green-outline",
"bg-[#4d7c0f]": tone === "lime-outline" || tone === "lime-subtle",
"bg-[#677615]": tone === "pear-outline" || tone === "pear-subtle",
"bg-[#9b5e0d]": tone === "yellow-outline",
"bg-[#c72307]": tone === "orange-outline",
"bg-[#c52020]": tone === "red-outline",
"bg-[#8a5f44]": tone === "brown-subtle",
"bg-[#947562]": tone === "sand-subtle",
"bg-[#db2777]": tone === "pink-subtle",
"bg-[#c026d3]": tone === "fuchsia-subtle",
"bg-[#7c2df0]": tone === "violet-subtle",
"bg-[#4835f2]": tone === "indigo-subtle",
"bg-[#0c4bff]": tone === "blue-subtle",
"bg-[#0890c1]": tone === "ocean-subtle",
"bg-[#15a43e]": tone === "green-subtle",
"bg-[#e0c200]": tone === "yellow-subtle",
"bg-[#ea580c]": tone === "orange-subtle",
"bg-[#da2828]": tone === "red-subtle",
}
)}
/>
{children ? (
<span
className={SubframeUtils.twClassNames(
"whitespace-nowrap text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-on-subtle",
{
"text-text-neutral-on-solid":
tone === "neutral-solid" ||
tone === "brand-secondary-solid" ||
tone === "brand-primary-solid" ||
tone === "brown-solid" ||
tone === "sand-solid" ||
tone === "pink-solid" ||
tone === "fuchsia-solid" ||
tone === "violet-solid" ||
tone === "indigo-solid" ||
tone === "blue-solid" ||
tone === "sky-solid" ||
tone === "ocean-solid" ||
tone === "teal-solid" ||
tone === "green-solid" ||
tone === "lime-solid" ||
tone === "pear-solid" ||
tone === "yellow-solid" ||
tone === "orange-solid" ||
tone === "red-solid",
"text-[#6f4a39]": tone === "brown-outline",
"text-[#7c6052]": tone === "sand-outline",
"text-[#be185d]": tone === "pink-outline",
"text-[#a21caf]": tone === "fuchsia-outline",
"text-[#691cd4]": tone === "violet-outline",
"text-[#3923de]": tone === "indigo-outline",
"text-[#003beb]": tone === "blue-outline",
"text-[#0071ce]": tone === "sky-outline",
"text-[#08739c]": tone === "ocean-outline",
"text-[#05807c]": tone === "teal-outline",
"text-[#148134]": tone === "green-outline",
"text-[#4d7c0f]": tone === "lime-outline",
"text-[#677615]": tone === "pear-outline",
"text-[#9b5e0d]": tone === "yellow-outline",
"text-[#c72307]": tone === "orange-outline",
"text-[#c52020]": tone === "red-outline",
}
)}
>
{children}
</span>
) : null}
</div>
);
}
);
export const Badge = BadgeRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Badge as BadgeComponent } from "./Badge";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Badge = BadgeComponent;
@@ -0,0 +1,169 @@
"use client";
/*
* Documentation:
* Badge Feedback — https://app.subframe.com/f023064a5ca0/library?component=Badge+Feedback_896531e4-54ea-4e5a-8710-c30fc2ce10fb
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface BadgeFeedbackRootProps
extends React.HTMLAttributes<HTMLDivElement> {
farbe?:
| "solid-neutral"
| "solid-positive"
| "solid-negative"
| "solid-warning"
| "solid-information"
| "solid-purple"
| "solid-blue"
| "solid-brand-primary"
| "solid-brand-secondary"
| "subtle-neutral"
| "subtle-positive"
| "subtle-negative"
| "subtle-warning"
| "subtle-information"
| "subtle-purple"
| "subtle-blue"
| "subtle-brand-primary"
| "subtle-brand-secondary"
| "ghost-neutral"
| "ghost-positive"
| "ghost-negative"
| "ghost-warning"
| "ghost-information"
| "ghost-purple"
| "ghost-blue"
| "ghost-brand-primary"
| "ghost-brand-secondary";
children?: React.ReactNode;
size?: "medium" | "small" | "large";
className?: string;
}
const BadgeFeedbackRoot = React.forwardRef<
HTMLDivElement,
BadgeFeedbackRootProps
>(function BadgeFeedbackRoot(
{
farbe = "solid-neutral",
children,
size = "medium",
className,
...otherProps
}: BadgeFeedbackRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/896531e4 flex h-6 items-center gap-2 rounded-round bg-surface-feedback-solid-neutral px-2.5 py-1",
{
"h-8 py-2": size === "large",
"h-5 py-0.5": size === "small",
"bg-transparent px-0 py-0":
farbe === "ghost-brand-secondary" ||
farbe === "ghost-brand-primary" ||
farbe === "ghost-blue" ||
farbe === "ghost-purple" ||
farbe === "ghost-information" ||
farbe === "ghost-warning" ||
farbe === "ghost-negative" ||
farbe === "ghost-positive" ||
farbe === "ghost-neutral",
"bg-brand-brand-secondary-surface-subtle-default":
farbe === "subtle-brand-secondary",
"bg-brand-brand-surface-subtle-default":
farbe === "subtle-brand-primary",
"bg-surface-ui-subtle-blue-default": farbe === "subtle-blue",
"bg-surface-ui-subtle-purple-default": farbe === "subtle-purple",
"bg-surface-feedback-subtle-information":
farbe === "subtle-information",
"bg-surface-feedback-subtle-warning": farbe === "subtle-warning",
"bg-surface-feedback-subtle-negative": farbe === "subtle-negative",
"bg-surface-feedback-subtle-positive": farbe === "subtle-positive",
"bg-surface-feedback-subtle-neutral": farbe === "subtle-neutral",
"bg-brand-brand-secondary-surface-solid-default":
farbe === "solid-brand-secondary",
"bg-brand-brand-surface-solid-default":
farbe === "solid-brand-primary",
"bg-surface-ui-solid-blue-default": farbe === "solid-blue",
"bg-surface-ui-solid-purple-default": farbe === "solid-purple",
"bg-surface-feedback-solid-information":
farbe === "solid-information",
"bg-surface-feedback-solid-warning": farbe === "solid-warning",
"bg-surface-feedback-solid-negative-default":
farbe === "solid-negative",
"bg-surface-feedback-solid-positive-default":
farbe === "solid-positive",
},
className
)}
ref={ref}
{...otherProps}
>
<div
className={SubframeUtils.twClassNames(
"flex h-1 w-1 flex-none flex-col items-start gap-2 rounded-round bg-icon-neutral-on-solid",
{
"bg-brand-brand-secondary-surface-solid-default":
farbe === "ghost-brand-secondary" ||
farbe === "subtle-brand-secondary",
"bg-brand-brand-surface-solid-default":
farbe === "ghost-brand-primary" ||
farbe === "subtle-brand-primary",
"bg-icon-ui-blue":
farbe === "ghost-blue" || farbe === "subtle-blue",
"bg-icon-ui-purple":
farbe === "ghost-purple" || farbe === "subtle-purple",
"bg-icon-feedback-information":
farbe === "ghost-information" || farbe === "subtle-information",
"bg-icon-feedback-warning":
farbe === "ghost-warning" || farbe === "subtle-warning",
"bg-icon-feedback-negative":
farbe === "ghost-negative" || farbe === "subtle-negative",
"bg-icon-feedback-positive":
farbe === "ghost-positive" || farbe === "subtle-positive",
"bg-icon-neutral-on-subtle":
farbe === "ghost-neutral" || farbe === "subtle-neutral",
}
)}
/>
{children ? (
<span
className={SubframeUtils.twClassNames(
"whitespace-nowrap text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-on-solid",
{
"text-brand-brand-secondary-surface-solid-default":
farbe === "ghost-brand-secondary",
"text-brand-brand-surface-solid-default":
farbe === "ghost-brand-primary",
"text-text-ui-blue": farbe === "ghost-blue",
"text-text-ui-purple": farbe === "ghost-purple",
"text-text-feedback-information": farbe === "ghost-information",
"text-text-feedback-warning": farbe === "ghost-warning",
"text-text-feedback-negative":
farbe === "ghost-negative" || farbe === "subtle-negative",
"text-text-feedback-positive":
farbe === "ghost-positive" || farbe === "subtle-positive",
"text-text-neutral-on-subtle":
farbe === "ghost-neutral" ||
farbe === "subtle-brand-secondary" ||
farbe === "subtle-brand-primary" ||
farbe === "subtle-blue" ||
farbe === "subtle-purple" ||
farbe === "subtle-information" ||
farbe === "subtle-warning" ||
farbe === "subtle-neutral",
}
)}
>
{children}
</span>
) : null}
</div>
);
});
export const BadgeFeedback = BadgeFeedbackRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { BadgeFeedback as BadgeFeedbackComponent } from "./BadgeFeedback";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const BadgeFeedback = BadgeFeedbackComponent;
@@ -0,0 +1,121 @@
"use client";
/*
* Documentation:
* Badge Shortcut — https://app.subframe.com/f023064a5ca0/library?component=Badge+Shortcut_97bdb082-1124-4dd7-a335-b14b822d0157
*/
import React from "react";
import { FeatherCommand } from "@subframe/core";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface BadgeShortcutRootProps
extends React.HTMLAttributes<HTMLDivElement> {
variant?:
| "neutral"
| "brand-primary"
| "brand-secondary"
| "error"
| "warning"
| "success";
iconLeft1?: React.ReactNode;
iconLeft2?: React.ReactNode;
children?: React.ReactNode;
iconRight?: React.ReactNode;
size?: "small" | "medium" | "large";
className?: string;
}
const BadgeShortcutRoot = React.forwardRef<
HTMLDivElement,
BadgeShortcutRootProps
>(function BadgeShortcutRoot(
{
variant = "neutral",
iconLeft1 = <FeatherCommand />,
iconLeft2 = null,
children,
iconRight = null,
size = "medium",
className,
...otherProps
}: BadgeShortcutRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/97bdb082 flex min-h-[20px] items-center justify-center rounded-sm border border-solid border-surface-feedback-subtle-neutral bg-surface-feedback-subtle-neutral px-1 py-0.5",
{
"min-h-[24px] px-1.5 py-1": size === "large",
"min-h-[14px] px-px py-px": size === "small",
"border border-solid border-surface-feedback-subtle-positive bg-surface-feedback-subtle-positive":
variant === "success",
"border border-solid border-surface-feedback-subtle-warning bg-surface-feedback-subtle-warning":
variant === "warning",
"border border-solid border-surface-feedback-subtle-negative bg-surface-feedback-subtle-negative":
variant === "error",
"border border-solid border-brand-brand-secondary-surface-subtle-default bg-brand-brand-secondary-surface-subtle-default":
variant === "brand-secondary",
"border border-solid border-brand-brand-surface-subtle-default bg-brand-brand-surface-subtle-default":
variant === "brand-primary",
},
className
)}
ref={ref}
{...otherProps}
>
{iconLeft1 ? (
<SubframeCore.IconWrapper
className={SubframeUtils.twClassNames(
"font-['Instrument_Sans'] text-[14px] font-[400] leading-[13px] text-icon-neutral-on-subtle",
{
"text-brand-brand-surface-solid-default":
variant === "brand-primary",
}
)}
>
{iconLeft1}
</SubframeCore.IconWrapper>
) : null}
{iconLeft2 ? (
<SubframeCore.IconWrapper
className={SubframeUtils.twClassNames(
"font-['Instrument_Sans'] text-[14px] font-[400] leading-[13px] text-icon-neutral-on-subtle",
{
"text-brand-brand-surface-solid-default":
variant === "brand-primary",
}
)}
>
{iconLeft2}
</SubframeCore.IconWrapper>
) : null}
{children ? (
<span className="whitespace-nowrap font-['Instrument_Sans'] text-[14px] font-[400] leading-[14px] text-text-neutral-on-subtle">
{children}
</span>
) : null}
{iconRight ? (
<SubframeCore.IconWrapper
className={SubframeUtils.twClassNames(
"font-['Inter'] text-[12px] font-[400] leading-[16px] text-icon-neutral-on-subtle",
{
"text-icon-feedback-positive": variant === "success",
"text-icon-feedback-warning": variant === "warning",
"text-icon-feedback-negative": variant === "error",
"text-brand-brand-secondary-surface-solid-default":
variant === "brand-secondary",
"text-brand-brand-surface-solid-default":
variant === "brand-primary",
}
)}
>
{iconRight}
</SubframeCore.IconWrapper>
) : null}
</div>
);
});
export const BadgeShortcut = BadgeShortcutRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { BadgeShortcut as BadgeShortcutComponent } from "./BadgeShortcut";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const BadgeShortcut = BadgeShortcutComponent;
+129
View File
@@ -0,0 +1,129 @@
"use client";
/*
* Documentation:
* Banner Alert — https://app.subframe.com/f023064a5ca0/library?component=Banner+Alert_a3e8dcff-3c57-42da-880f-432bb8ce4100
* Link Button — https://app.subframe.com/f023064a5ca0/library?component=Link+Button_a4ee726a-774c-4091-8c49-55b659356024
*/
import React from "react";
import { FeatherAlertCircle } from "@subframe/core";
import { FeatherAlertTriangle } from "@subframe/core";
import { FeatherCheckCircle } from "@subframe/core";
import { FeatherInfo } from "@subframe/core";
import { FeatherX } from "@subframe/core";
import * as SubframeUtils from "../../utils";
import { LinkButton } from "../LinkButton";
export interface BannerAlertRootProps
extends React.HTMLAttributes<HTMLDivElement> {
dismissing?: boolean;
type?: "information" | "success" | "warning" | "error";
fillContainer?: boolean;
hideAction?: boolean;
dismissible?: boolean;
children?: React.ReactNode;
actionLabel?: React.ReactNode;
className?: string;
}
const BannerAlertRoot = React.forwardRef<HTMLDivElement, BannerAlertRootProps>(
function BannerAlertRoot(
{
dismissing = false,
type = "information",
fillContainer = false,
hideAction = false,
dismissible = false,
children,
actionLabel,
className,
...otherProps
}: BannerAlertRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"group/a3e8dcff flex w-256 items-center gap-2 border-b border-solid border-border-information-solid bg-surface-feedback-subtle-information px-4 py-3",
{
"w-full": fillContainer,
"border-b border-x-0 border-t-0 border-solid border-border-negative-solid bg-surface-feedback-subtle-negative":
type === "error",
"border-b border-x-0 border-t-0 border-solid border-border-warning-solid bg-surface-feedback-subtle-warning":
type === "warning",
"border-b border-x-0 border-t-0 border-solid border-border-positive-solid bg-surface-feedback-subtle-positive":
type === "success",
hidden: dismissing,
},
className
)}
ref={ref}
{...otherProps}
>
<FeatherInfo
className={SubframeUtils.twClassNames(
"text-body-large-0 font-body-large-0 text-icon-feedback-information",
{
hidden:
type === "error" || type === "warning" || type === "success",
}
)}
/>
<FeatherCheckCircle
className={SubframeUtils.twClassNames(
"hidden text-body-large-0 font-body-large-0 text-icon-feedback-positive",
{ "inline-flex": type === "success" }
)}
/>
<FeatherAlertTriangle
className={SubframeUtils.twClassNames(
"hidden text-body-large-0 font-body-large-0 text-icon-feedback-warning",
{ "inline-flex": type === "warning" }
)}
/>
<FeatherAlertCircle
className={SubframeUtils.twClassNames(
"hidden text-body-large-0 font-body-large-0 text-icon-feedback-negative",
{ "inline-flex": type === "error" }
)}
/>
{children ? (
<span
className={SubframeUtils.twClassNames(
"grow shrink-0 basis-0 whitespace-pre-wrap text-body-default-regular-0 font-body-default-regular-0 text-text-feedback-information",
{
"text-text-feedback-negative": type === "error",
"text-text-feedback-warning": type === "warning",
"text-text-feedback-positive": type === "success",
}
)}
>
{children}
</span>
) : null}
<LinkButton iconRight={null} underline={true}>
Aktion
</LinkButton>
<div
className={SubframeUtils.twClassNames(
"hidden items-center justify-center rounded-md px-1 py-1 cursor-pointer hover:bg-black-100",
{ "inline-flex": dismissible }
)}
>
<FeatherX
className={SubframeUtils.twClassNames(
"text-body-large-0 font-body-large-0 text-icon-feedback-information",
{
"text-icon-feedback-negative": type === "error",
"text-icon-feedback-warning": type === "warning",
"text-icon-feedback-positive": type === "success",
}
)}
/>
</div>
</div>
);
}
);
export const BannerAlert = BannerAlertRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { BannerAlert as BannerAlertComponent } from "./BannerAlert";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const BannerAlert = BannerAlertComponent;
@@ -0,0 +1,51 @@
"use client";
/*
* Documentation:
* Banner Offline — https://app.subframe.com/f023064a5ca0/library?component=Banner+Offline_2582cb6e-0176-4a5c-b70f-c3bef3c7001d
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface BannerOfflineRootProps
extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default";
text?: React.ReactNode;
className?: string;
}
const BannerOfflineRoot = React.forwardRef<
HTMLDivElement,
BannerOfflineRootProps
>(function BannerOfflineRoot(
{
variant = "default",
text,
className,
...otherProps
}: BannerOfflineRootProps,
ref
) {
return (
<div
className={SubframeUtils.twClassNames(
"flex w-full items-center gap-2 border-b border-solid border-border-warning-solid bg-surface-feedback-subtle-warning px-4 py-3",
className
)}
ref={ref}
{...otherProps}
>
<img
className="h-1 w-1 flex-none object-cover"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNCIgaGVpZ2h0PSI0IiB2aWV3Qm94PSIwIDAgNCA0IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxjaXJjbGUgY3g9IjIiIGN5PSIyIiByPSIyIiBmaWxsPSIjOUI1RTBEIi8+PC9zdmc+"
/>
{text ? (
<span className="grow shrink-0 basis-0 whitespace-pre-wrap text-body-default-regular-0 font-body-default-regular-0 text-text-feedback-warning">
{text}
</span>
) : null}
</div>
);
});
export const BannerOffline = BannerOfflineRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { BannerOffline as BannerOfflineComponent } from "./BannerOffline";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const BannerOffline = BannerOfflineComponent;
+42
View File
@@ -0,0 +1,42 @@
"use client";
/*
* Documentation:
* Bar Chart — https://app.subframe.com/f023064a5ca0/library?component=Bar+Chart_4d4f30e7-1869-4980-8b96-617df3b37912
*/
import React from "react";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface BarChartRootProps
extends React.ComponentProps<typeof SubframeCore.BarChart> {
stacked?: boolean;
className?: string;
}
const BarChartRoot = React.forwardRef<
React.ElementRef<typeof SubframeCore.BarChart>,
BarChartRootProps
>(function BarChartRoot(
{ stacked = false, className, ...otherProps }: BarChartRootProps,
ref
) {
return (
<SubframeCore.BarChart
className={SubframeUtils.twClassNames("h-80 w-full", className)}
ref={ref}
stacked={stacked}
colors={[
"#3b82f6",
"#bfdbfe",
"#2563eb",
"#93c5fd",
"#1d4ed8",
"#60a5fa",
]}
{...otherProps}
/>
);
});
export const BarChart = BarChartRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { BarChart as BarChartComponent } from "./BarChart";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const BarChart = BarChartComponent;
+86
View File
@@ -0,0 +1,86 @@
"use client";
/*
* Documentation:
* Breadcrumbs — https://app.subframe.com/f023064a5ca0/library?component=Breadcrumbs_8898334b-a66f-4ee8-8bd1-afcfa8e37cc0
*/
import React from "react";
import { FeatherChevronRight } from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface ItemProps extends React.HTMLAttributes<HTMLSpanElement> {
children?: React.ReactNode;
active?: boolean;
className?: string;
}
const Item = React.forwardRef<HTMLSpanElement, ItemProps>(function Item(
{ children, active = false, className, ...otherProps }: ItemProps,
ref
) {
return children ? (
<span
className={SubframeUtils.twClassNames(
"group/bbdc1640 line-clamp-1 cursor-pointer break-words text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary hover:text-text-neutral-primary",
{ "text-text-neutral-primary": active },
className
)}
ref={ref}
{...otherProps}
>
{children}
</span>
) : null;
});
export interface DividerProps
extends React.ComponentProps<typeof FeatherChevronRight> {
className?: string;
}
const Divider = React.forwardRef<
React.ElementRef<typeof FeatherChevronRight>,
DividerProps
>(function Divider({ className, ...otherProps }: DividerProps, ref) {
return (
<FeatherChevronRight
className={SubframeUtils.twClassNames(
"text-body-small-regular-0 font-body-small-regular-0 text-icon-neutral-tertiary",
className
)}
ref={ref}
{...otherProps}
/>
);
});
export interface BreadcrumbsRootProps
extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
className?: string;
}
const BreadcrumbsRoot = React.forwardRef<HTMLDivElement, BreadcrumbsRootProps>(
function BreadcrumbsRoot(
{ children, className, ...otherProps }: BreadcrumbsRootProps,
ref
) {
return children ? (
<div
className={SubframeUtils.twClassNames(
"flex items-center gap-2",
className
)}
ref={ref}
{...otherProps}
>
{children}
</div>
) : null;
}
);
export const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item,
Divider,
});
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Breadcrumbs as BreadcrumbsComponent } from "./Breadcrumbs";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Breadcrumbs = BreadcrumbsComponent;
+152
View File
@@ -0,0 +1,152 @@
"use client";
/*
* Documentation:
* Button — https://app.subframe.com/f023064a5ca0/library?component=Button_3b777358-b86b-40af-9327-891efc6826fe
*/
import React from "react";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export interface ButtonRootProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
loading?: boolean;
variant?:
| "primary"
| "secondary"
| "tertiary"
| "ghost"
| "destructive"
| "neutral"
| "success"
| "warning"
| "information";
size?: "large" | "medium" | "small";
children?: React.ReactNode;
icon?: React.ReactNode;
iconRight?: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
const ButtonRoot = React.forwardRef<HTMLButtonElement, ButtonRootProps>(
function ButtonRoot(
{
disabled = false,
loading = false,
variant = "primary",
size = "medium",
children,
icon = null,
iconRight = null,
className,
type = "button",
...otherProps
}: ButtonRootProps,
ref
) {
return (
<button
className={SubframeUtils.twClassNames(
"group/3b777358 flex h-9 cursor-pointer items-center justify-center gap-3 rounded-md border-none bg-brand-brand-surface-solid-default px-4 py-2.5 text-left hover:bg-brand-brand-surface-solid-hover active:bg-brand-brand-surface-solid-active focus-within:shadow-focus disabled:cursor-default disabled:bg-surface-neutral-disabled disabled:pointer-events-none hover:disabled:cursor-default hover:disabled:bg-surface-neutral-disabled active:disabled:cursor-default active:disabled:bg-surface-neutral-disabled",
{
"h-8 gap-2 px-3 py-2": size === "small",
"h-12 gap-2.5 px-5 py-3.5": size === "large",
"bg-surface-feedback-solid-information hover:bg-surface-feedback-solid-information-hover active:bg-surface-feedback-solid-information-active":
variant === "information",
"bg-surface-feedback-solid-warning hover:bg-surface-feedback-solid-warning-hover active:bg-surface-feedback-solid-warning-active":
variant === "warning",
"bg-surface-feedback-solid-positive-default hover:bg-surface-feedback-solid-positive-hover active:bg-surface-feedback-solid-positive-active":
variant === "success",
"bg-surface-feedback-solid-neutral hover:bg-surface-feedback-solid-neutral-hover active:bg-surface-feedback-solid-neutral-active":
variant === "neutral",
"bg-surface-feedback-solid-negative-default hover:bg-surface-feedback-solid-negative-hover active:bg-surface-feedback-solid-negative-active":
variant === "destructive",
"bg-transparent hover:border hover:border-solid hover:border-border-strong hover:bg-surface-neutral-secondary active:bg-surface-neutral-tertiary focus-within:border focus-within:border-solid focus-within:border-border-default focus-within:bg-surface-neutral-secondary":
variant === "ghost",
"border border-solid border-border-default bg-surface-neutral-primary hover:border hover:border-solid hover:border-border-strong hover:bg-surface-neutral-secondary active:bg-surface-neutral-tertiary":
variant === "tertiary",
"bg-brand-brand-secondary-surface-subtle-default hover:bg-brand-brand-secondary-surface-subtle-hover active:bg-brand-brand-secondary-surface-subtle-active":
variant === "secondary",
},
className
)}
ref={ref}
type={type}
disabled={disabled}
{...otherProps}
>
{icon ? (
<SubframeCore.IconWrapper
className={SubframeUtils.twClassNames(
"text-body-default-regular-0 font-body-default-regular-0 text-icon-neutral-on-solid group-disabled/3b777358:text-icon-neutral-disabled",
{
"text-body-large-0 font-body-large-0": size === "large",
"text-icon-neutral-secondary": variant === "ghost",
"text-icon-neutral-on-subtle": variant === "tertiary",
"text-icon-neutral-primary": variant === "secondary",
hidden: loading,
}
)}
>
{icon}
</SubframeCore.IconWrapper>
) : null}
<div
className={SubframeUtils.twClassNames(
"hidden h-4 w-4 flex-none items-center justify-center gap-2",
{ "h-5 w-5": size === "large", flex: loading }
)}
>
<SubframeCore.Loader
className={SubframeUtils.twClassNames(
"text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-icon-neutral-on-solid group-disabled/3b777358:text-icon-neutral-disabled group-hover/3b777358:group-disabled/3b777358:text-icon-neutral-on-solid group-active/3b777358:group-disabled/3b777358:text-icon-neutral-disabled",
{
"text-icon-neutral-secondary": variant === "ghost",
"text-icon-neutral-on-subtle": variant === "tertiary",
"text-icon-neutral-primary": variant === "secondary",
}
)}
/>
</div>
{children ? (
<span
className={SubframeUtils.twClassNames(
"whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-on-solid group-disabled/3b777358:text-text-neutral-disabled",
{
"text-body-large-0 font-body-large-0": size === "large",
"text-text-neutral-primary":
variant === "ghost" ||
variant === "tertiary" ||
variant === "secondary",
"hidden text-body-small-medium-150 font-body-small-medium-150":
loading,
}
)}
>
{children}
</span>
) : null}
{iconRight ? (
<SubframeCore.IconWrapper
className={SubframeUtils.twClassNames(
"text-body-default-regular-150 font-body-default-regular-150 text-icon-neutral-on-solid group-disabled/3b777358:text-icon-neutral-disabled",
{
"text-body-large font-body-large": size === "large",
"text-icon-neutral-secondary": variant === "ghost",
"text-icon-neutral-on-subtle": variant === "tertiary",
"text-icon-neutral-primary": variant === "secondary",
hidden: loading,
}
)}
>
{iconRight}
</SubframeCore.IconWrapper>
) : null}
</button>
);
}
);
export const Button = ButtonRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { Button as ButtonComponent } from "./Button";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const Button = ButtonComponent;
+51
View File
@@ -0,0 +1,51 @@
"use client";
/*
* Documentation:
* Button Group — https://app.subframe.com/f023064a5ca0/library?component=Button+Group_e0379327-50f0-4852-9ae1-ad09b4bf01d3
*/
import React from "react";
import * as SubframeUtils from "../../utils";
export interface ButtonGroupRootProps
extends React.HTMLAttributes<HTMLDivElement> {
vertical?: boolean;
align?: "start" | "center" | "end" | "stretch";
children?: React.ReactNode;
className?: string;
}
const ButtonGroupRoot = React.forwardRef<HTMLDivElement, ButtonGroupRootProps>(
function ButtonGroupRoot(
{
vertical = false,
align = "start",
children,
className,
...otherProps
}: ButtonGroupRootProps,
ref
) {
return children ? (
<div
className={SubframeUtils.twClassNames(
"group/e0379327 flex w-full items-center gap-2",
{
"justify-start items-stretch [&>*]:grow [&>*]:basis-0":
align === "stretch",
"justify-end": align === "end",
"justify-center": align === "center",
"flex-col items-start": vertical,
},
className
)}
ref={ref}
{...otherProps}
>
{children}
</div>
) : null;
}
);
export const ButtonGroup = ButtonGroupRoot;
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { ButtonGroup as ButtonGroupComponent } from "./ButtonGroup";
/**
* Add wrapper components and business logic here.
* If you modify this file, disable Subframe sync for it to prevent overwrites.
* Learn more: https://docs.subframe.com/concepts/syncing-components#wrapping-components
*/
export const ButtonGroup = ButtonGroupComponent;
+53
View File
@@ -0,0 +1,53 @@
"use client";
/*
* Documentation:
* Calendar — https://app.subframe.com/f023064a5ca0/library?component=Calendar_5a87e517-ace2-49af-adcf-076c97ec3921
*/
import React from "react";
import * as SubframeCore from "@subframe/core";
import * as SubframeUtils from "../../utils";
export type CalendarRootProps = React.ComponentProps<
typeof SubframeCore.Calendar
> & { className?: string };
const CalendarRoot = React.forwardRef<
React.ElementRef<typeof SubframeCore.Calendar>,
CalendarRootProps
>(function CalendarRoot({ className, ...otherProps }: CalendarRootProps, ref) {
return (
<SubframeCore.Calendar
className={className}
ref={ref}
classNames={{
root: "relative border-box",
month: "flex flex-col gap-4",
months: "relative flex flex-wrap max-w-fit gap-4",
nav: "absolute flex items-center justify-between h-8 w-full p-0.5",
month_caption: "flex items-center justify-center h-8",
caption_label: "text-body-bold font-body-bold text-default-font",
button_previous:
"inline-flex items-center justify-center h-8 w-8 bg-transparent rounded-md border-none bg-transparent hover:bg-neutral-50 active:bg-neutral-100 border-none",
button_next:
"inline-flex items-center justify-center h-8 w-8 bg-transparent rounded-md border-none bg-transparent hover:bg-neutral-50 active:bg-neutral-100 border-none",
chevron: "text-[18px] font-[500] leading-[18px] text-neutral-600",
weeks: "flex flex-col gap-2",
weekdays: "flex pb-4",
weekday: "w-8 text-caption-bold font-caption-bold text-subtext-color",
week: "flex rounded-lg overflow-hidden",
day: "group flex p-0 cursor-pointer items-center justify-center text-body font-body text-default-font h-8 w-8",
day_button:
"flex h-8 w-8 cursor-pointer items-center justify-center gap-2 rounded-lg border-none hover:bg-neutral-100 group-[.selected]:bg-brand-600 group-[.selected]:text-white group-[.outside]:bg-transparent group-[.outside.selected]:bg-neutral-100 group-[.outside.range-start]:bg-neutral-100 group-[.outside.range-end]:bg-neutral-100 group-[.outside.range-start]:rounded-l-lg group-[.outside.range-start]:rounded-r-none group-[.outside.range-end]:rounded-l-none group-[.outside.range-end]:rounded-r-lg group-[.outside]:!text-neutral-400 group-[.outside]:hover:bg-neutral-100 group-[.range-middle.selected]:bg-neutral-100 group-[.range-middle.selected]:text-default-font group-[.range-middle.selected]:hover:bg-neutral-100 group-[.range-middle.selected]:rounded-none",
selected: "selected",
outside: "outside",
range_start: "range-start bg-neutral-100 rounded-l-lg",
range_middle: "range-middle",
range_end: "range-end bg-neutral-100 rounded-r-lg",
}}
{...otherProps}
/>
);
});
export const Calendar = CalendarRoot;
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more