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