Geviertstriche aus Text, Kommentaren und Doku entfernen
467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
This commit is contained in:
1 parent
e998231fed
commit
6a0b36ddcc
75 files changed
+471
-471
No files matched your search
@@ -63,7 +63,7 @@ export function AlertList({
|
||||
/**
|
||||
* Der Trennstrich sitzt oben, nicht unten.
|
||||
*
|
||||
* Unten liegend braucht er eine Ausnahme für die letzte Zeile — und
|
||||
* Unten liegend braucht er eine Ausnahme für die letzte Zeile, und
|
||||
* „die letzte Zeile" gibt es bei zwei Spalten zweimal, einmal je
|
||||
* Spalte, an einer Stelle, die von der Anzahl der Einträge abhängt.
|
||||
* Die rechte Spalte behielt dadurch einen Strich, unter dem nichts
|
||||
@@ -82,10 +82,10 @@ export function AlertList({
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
zeigeToast(eintrag.aktion, `${eintrag.text} — Absprung folgt in der Umsetzung`)
|
||||
zeigeToast(eintrag.aktion, `${eintrag.text}: Absprung folgt in der Umsetzung`)
|
||||
}
|
||||
/* -mx-2 und die zusätzliche Breite: Der Untergrund lief vorher
|
||||
genau bis zum Pfeil und schnitt dort hart ab — bei zwei Spalten
|
||||
genau bis zum Pfeil und schnitt dort hart ab, bei zwei Spalten
|
||||
besonders sichtbar, weil daneben 32 px Spaltenabstand ungefärbt
|
||||
blieben. Jetzt greift er acht Pixel über den Inhalt hinaus und
|
||||
hat runde Ecken; er liest sich als Fläche unter der Zeile statt
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ChartCard({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
/** Stabile ID — Schlüssel für Ausblenden/Einblenden. */
|
||||
/** Stabile ID: Schlüssel für Ausblenden/Einblenden. */
|
||||
id: string;
|
||||
titel: string;
|
||||
untertitel?: string;
|
||||
@@ -49,7 +49,7 @@ export function ChartCard({
|
||||
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
|
||||
// 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") {
|
||||
@@ -123,7 +123,7 @@ export function ChartCard({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
zeigeToast("Als Bild vorgemerkt", `${titel} — Export folgt in der Umsetzung`);
|
||||
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"
|
||||
@@ -141,7 +141,7 @@ export function ChartCard({
|
||||
{/* justify-center: Karten einer Zeile sind gleich hoch, aber nicht gleich
|
||||
voll. Eine Karte mit einem Balken neben einer mit einem Diagramm über
|
||||
400 px bekommt den Überschuss oben und unten je zur Hälfte, statt ihn
|
||||
unten abzuladen. Der Inhalt behält seine natürliche Höhe — die Karte
|
||||
unten abzuladen. Der Inhalt behält seine natürliche Höhe, die Karte
|
||||
wächst weiter mit, wenn mehr hineinkommt. */}
|
||||
<div className="flex min-h-0 grow flex-col justify-center px-4 pb-4">{children}</div>
|
||||
</section>
|
||||
|
||||
@@ -6,7 +6,7 @@ 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. */
|
||||
/** Zeitbezug einer Kennzahl: siehe docs/KPI-KATALOG.md. */
|
||||
export type Bezug = "zeitraum" | "stichtag" | "gesamt";
|
||||
|
||||
const BEZUG_LABEL: Record<Bezug, string> = {
|
||||
@@ -19,14 +19,14 @@ const BEZUG_LABEL: Record<Bezug, string> = {
|
||||
* 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.
|
||||
* 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.
|
||||
* (30 Tage gegen die 30 Tage davor): nicht mit dem Vorjahr.
|
||||
*/
|
||||
export function KPICard({
|
||||
label,
|
||||
@@ -38,7 +38,7 @@ export function KPICard({
|
||||
/**
|
||||
* Überschreibt die Fußzeile. Analytics hat einen seitenweiten Filter, deshalb
|
||||
* heißt es dort "im gewählten Zeitraum". Auf dem Dashboard gibt es den nicht
|
||||
* — jede Kachel trägt ihren eigenen Zeitraum und muss ihn benennen können.
|
||||
* jede Kachel trägt ihren eigenen Zeitraum und muss ihn benennen können.
|
||||
*/
|
||||
bezugText,
|
||||
icon,
|
||||
@@ -58,7 +58,7 @@ export function KPICard({
|
||||
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
|
||||
* 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";
|
||||
@@ -85,14 +85,14 @@ export function KPICard({
|
||||
return (
|
||||
/* h-full: Die Karte füllt ihre Rasterzelle. Ohne das ist die Karte mit
|
||||
Trendzeile und Verlaufskurve höher als ihre Nachbarn, und eine Reihe
|
||||
gleichartiger Kennzahlen bekommt eine ungleiche Unterkante — auf dem
|
||||
gleichartiger Kennzahlen bekommt eine ungleiche Unterkante: auf dem
|
||||
Dashboard sichtbar, weil dort ein Link zwischen Zelle und Karte steht
|
||||
und die Streckung des Rasters nicht mehr durchreicht. */
|
||||
<article className="flex h-full 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">
|
||||
{/* min-w-0 + break-words: Ohne das schiebt ein langes Label wie
|
||||
„Umsatz (Zahlungseingang)" das Icon aus der Karte und laeuft selbst
|
||||
ueber den Rand. Die Karte wird dadurch eine Zeile hoeher — das ist
|
||||
ueber den Rand. Die Karte wird dadurch eine Zeile hoeher, das ist
|
||||
der Preis dafuer, dass die Beschriftung vollstaendig lesbar bleibt
|
||||
(docs/DESIGN-SYSTEM.md §9). */}
|
||||
<span className="min-w-0 break-words text-[12px] font-medium leading-tight text-text-neutral-tertiary">
|
||||
@@ -150,7 +150,7 @@ export function KPICard({
|
||||
{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
|
||||
deshalb auch dieselbe Farbe, sonst zeigt die Karte zwei
|
||||
widersprüchliche Signale. */}
|
||||
<Sparkline werte={verlauf} farbe={trend ? trendFarbe : CHART.achse} />
|
||||
</span>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Kompakte Zusatzkennzahlen unter einem Chart —
|
||||
* 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.
|
||||
* mitten im Betrag um ("1,22 Mio." / "€"): dort gehoeren zwei hin.
|
||||
*/
|
||||
spalten = 4,
|
||||
}: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import type { ZeitreihePunkt } from "../../data/types";
|
||||
*
|
||||
* Bewusst `ComposedChart`, nicht `AreaChart`: Recharts sammelt in einem
|
||||
* AreaChart ausschließlich `Area`-Kinder ein (generateCategoricalChart mit
|
||||
* GraphicalChild = Area). Ein `<Line>` darin wird stillschweigend verworfen —
|
||||
* GraphicalChild = Area). Ein `<Line>` darin wird stillschweigend verworfen,
|
||||
* die Vergleichsreihe stand monatelang in der Legende, ohne je gezeichnet zu
|
||||
* werden. ComposedChart nimmt beide Typen.
|
||||
*
|
||||
|
||||
@@ -9,14 +9,14 @@ import { ChartLeer } from "./ChartLeer";
|
||||
*
|
||||
* 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.
|
||||
* 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 % Luft frei Kapazität ungenutzt
|
||||
* 70–95 % Zielkorridor
|
||||
* > 95 % ausgebucht — Überlastrisiko, Termine gefährdet
|
||||
* > 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.
|
||||
*/
|
||||
@@ -109,10 +109,10 @@ export function AuslastungsBalken({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Achse statt Farblegende — die Zonen erklären sich über das Band.
|
||||
{/* Achse statt Farblegende, die Zonen erklären sich über das Band.
|
||||
In sehr schmalen Karten bleibt für die Spur nur rund 40 px; dort
|
||||
stoßen selbst die beiden Randmarken aneinander. Eine Achse, die man
|
||||
nicht mehr lesen kann, ist kein Kompromiss, sondern Störung — sie
|
||||
nicht mehr lesen kann, ist kein Kompromiss, sondern Störung, sie
|
||||
entfällt dann, und die Erläuterung darunter trägt die Skala. */}
|
||||
<div className="hidden grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3 pt-1 @sm:grid">
|
||||
<span />
|
||||
@@ -120,7 +120,7 @@ export function AuslastungsBalken({
|
||||
{/* Beschriftungsdichte staffelt sich mit der Containerbreite: ganz
|
||||
schmal nur die Enden, dann die 100-%-Marke, dann jede zweite, dann
|
||||
alle. Container-Query, weil dieselbe Karte mal ein Drittel und mal
|
||||
die volle Seitenbreite einnimmt — der Viewport weiß davon nichts. */}
|
||||
die volle Seitenbreite einnimmt, der Viewport weiß davon nichts. */}
|
||||
{ticks.map((t, index) => {
|
||||
const stufe =
|
||||
t === min || t === max
|
||||
@@ -131,7 +131,7 @@ export function AuslastungsBalken({
|
||||
? "hidden @lg:inline"
|
||||
: "hidden @md:inline";
|
||||
// Zentriert man auch die Randmarken, ragt die Haelfte ihrer
|
||||
// Breite ueber die Spur hinaus — links wie rechts je rund 10 px.
|
||||
// Breite ueber die Spur hinaus: links wie rechts je rund 10 px.
|
||||
const rand =
|
||||
t === min ? "translate-x-0" : t === max ? "-translate-x-full" : "-translate-x-1/2";
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function BalkenHorizontal({
|
||||
einfarbig = true,
|
||||
farbeStart = 1,
|
||||
/**
|
||||
* Zweitwert rechts neben dem Hauptwert — z. B. Auftragswert neben der
|
||||
* Zweitwert rechts neben dem Hauptwert: z. B. Auftragswert neben der
|
||||
* Anzahl Abschlüsse. Erspart eine zweite Karte mit derselben Kodierung
|
||||
* auf derselben Personenachse.
|
||||
*/
|
||||
|
||||
@@ -52,7 +52,7 @@ export function BalkenVertikal({
|
||||
// Monatsnamen standen in einer Drittelkarte ohne Lücke aneinander.
|
||||
const intervall = achsenIntervall(punkte.length);
|
||||
|
||||
// Ganzzahlreihen brauchen ganzzahlige Ticks — sonst steht "1,5 Vorgänge".
|
||||
// Ganzzahlreihen brauchen ganzzahlige Ticks, sonst steht "1,5 Vorgänge".
|
||||
const maximum = Math.max(...punkte.map((p) => p.value));
|
||||
const raster = nurGanzzahlen
|
||||
? undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { FeatherBarChart2 } from "@subframe/core";
|
||||
|
||||
/** Leerzustand — wichtiger als er aussieht: Filter können jedes Chart leeren. */
|
||||
/** Leerzustand: wichtiger als er aussieht: Filter können jedes Chart leeren. */
|
||||
export function ChartLeer({
|
||||
text = "Keine Daten im gewählten Zeitraum",
|
||||
hoehe = 220,
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from "react";
|
||||
*/
|
||||
export function ChartLegende({
|
||||
eintraege,
|
||||
/** Ausgeblendete Reihen erscheinen ausgegraut — Klick schaltet um. */
|
||||
/** Ausgeblendete Reihen erscheinen ausgegraut: Klick schaltet um. */
|
||||
inaktiv = [],
|
||||
onToggle,
|
||||
}: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CHART, TOOLTIP_SCHATTEN } from "../../utils/chartTheme";
|
||||
*
|
||||
* Carbon-Vorgabe (@carbon/charts, .cds--cc--tooltip): helle Fläche
|
||||
* (--cds-layer-02), Text in --cds-text-primary, 12 px, abgesetzt allein durch
|
||||
* den Schatten — kein Rahmen, kein Doppelpunkt zwischen Name und Wert,
|
||||
* den Schatten, kein Rahmen, kein Doppelpunkt zwischen Name und Wert,
|
||||
* maximal 270 px breit.
|
||||
*
|
||||
* Bis Carbon v10 war der Tooltip dunkel (#393939). Viele Quellen im Netz
|
||||
|
||||
@@ -13,11 +13,11 @@ import { DONUT, farbeFuer, gruppiereRest } from "../../utils/chartTheme";
|
||||
* - 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 — Carbon: „positioned to the right of the chart
|
||||
* - Legende rechts mit Wert: Carbon: „positioned to the right of the chart
|
||||
* when space is plentiful". Ist der Platz nicht reichlich, rutscht sie unter
|
||||
* den Ring, statt Namen und Werte ineinanderlaufen zu lassen. Gesteuert über
|
||||
* eine Container-Query, weil dieselbe Karte mal ein Drittel und mal die halbe
|
||||
* Seitenbreite einnimmt — der Viewport weiß davon nichts.
|
||||
* Seitenbreite einnimmt, der Viewport weiß davon nichts.
|
||||
* - Klick auf einen Legendeneintrag hebt das Segment hervor
|
||||
*/
|
||||
export function Donut({
|
||||
@@ -29,7 +29,7 @@ export function Donut({
|
||||
/**
|
||||
* 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
|
||||
* gleich geschrieben sein: "285,3 T€" neben "285.318 €" liest sich wie
|
||||
* zwei verschiedene Zahlen.
|
||||
*/
|
||||
formatMitte,
|
||||
|
||||
@@ -12,28 +12,28 @@ import { ChartLeer } from "./ChartLeer";
|
||||
* Ü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 —
|
||||
* 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.
|
||||
* mit breitenkodierten Segmenten kennt Carbon bewusst nicht, es verzerrt.
|
||||
*
|
||||
* **Zum Verhalten beim Überfahren:** Die Zeile unter dem Zeiger bekommt einen
|
||||
* Untergrund, die übrigen bleiben unverändert. Das war vorher andersherum
|
||||
* gelöst — die nicht berührten Balken wurden auf 45 % Deckkraft gesetzt — und
|
||||
* gelöst, die nicht berührten Balken wurden auf 45 % Deckkraft gesetzt, und
|
||||
* das war aus drei Gründen falsch:
|
||||
*
|
||||
* 1. Es hat mehr bewegt als gemeint war. Ein Zeiger, der über eine Zeile
|
||||
* streift, ließ fünf von sechs Balken verblassen; das liest sich wie eine
|
||||
* Auswahl, nicht wie ein Überfahren.
|
||||
* 2. Es war in sich widersprüchlich. Die Balken verblassten, die Namen und
|
||||
* Zahlen daneben blieben schwarz — dieselbe Zeile sagte gleichzeitig
|
||||
* Zahlen daneben blieben schwarz: dieselbe Zeile sagte gleichzeitig
|
||||
* „unwichtig" und „unverändert".
|
||||
* 3. Deckkraft trägt in diesem Diagramm bereits eine Bedeutung: Der Balken
|
||||
* kodiert einen Anteil. Ihn heller zu machen, heißt in der Sprache des
|
||||
* Diagramms „weniger" — und nicht „gerade nicht berührt".
|
||||
* Diagramms „weniger": und nicht „gerade nicht berührt".
|
||||
*
|
||||
* Der Untergrund ist derselbe wie in der Handlungsbedarf-Liste und in den
|
||||
* Tabellen (`bg-neutral-50`): Überfahren sieht überall gleich aus.
|
||||
|
||||
@@ -5,16 +5,16 @@ import { FeatherAlertTriangle, FeatherCheckCircle } from "@subframe/core";
|
||||
import { STATUS_FARBEN } from "../../utils/chartTheme";
|
||||
|
||||
/**
|
||||
* Der Begrüßungskopf des Dashboards — in der Software das `hero`-Widget
|
||||
* Der Begrüßungskopf des Dashboards, in der Software das `hero`-Widget
|
||||
* (`DashboardWidgetKind = "hero"`, siehe `dashboard-page.vm.ts`).
|
||||
*
|
||||
* Er beantwortet die einzige Frage, die vor allen Zahlen kommt: „Muss ich
|
||||
* heute etwas tun?" Deshalb steht neben der Anrede ein Satz zur Lage und
|
||||
* nicht etwa eine weitere Kennzahl — die kommen darunter.
|
||||
* nicht etwa eine weitere Kennzahl, die kommen darunter.
|
||||
*
|
||||
* Bewusst ohne Rahmen und ohne Karte: Eine Karte behauptet, hier stünden
|
||||
* Daten, die man mit den Nachbarkarten vergleichen kann. Das ist nicht der
|
||||
* Fall — das ist eine Ansprache. Sie trägt die Seitenüberschrift, deshalb
|
||||
* Fall, das ist eine Ansprache. Sie trägt die Seitenüberschrift, deshalb
|
||||
* hat diese Seite keinen `PageHeader` mit „Dashboard" darüber: Zwei
|
||||
* Überschriften übereinander, von denen eine den Namen der Seite und die
|
||||
* andere den Namen des Lesers nennt, sind eine zu viel.
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ReservierterBereich } from "../../view-models/dashboard.vm";
|
||||
* Ein Platz, der noch kein Widget ist.
|
||||
*
|
||||
* Das Dashboard zeigt heute nur Analytics, wird aber mehr: eigene Aufgaben,
|
||||
* Termine des Tages, offene HR-Anträge — alle drei stehen im Designer-Brief
|
||||
* Termine des Tages, offene HR-Anträge, alle drei stehen im Designer-Brief
|
||||
* P11 und haben in der Software bereits ihre Widget-Domäne (`tasks`,
|
||||
* `schedule`, `hr`).
|
||||
*
|
||||
|
||||
@@ -9,8 +9,8 @@ import { ZEITRAUM_LABEL, type WidgetDomaene, type WidgetZeitraum } from "../../v
|
||||
/**
|
||||
* Rahmen einer Dashboard-Kachel.
|
||||
*
|
||||
* Sieht aus wie `ChartCard` aus Analytics — gleiche Kante, gleiche Abstände,
|
||||
* gleiche Schriftgrößen — trägt aber nicht deren Logik. Das ⋯-Menü und das
|
||||
* Sieht aus wie `ChartCard` aus Analytics: gleiche Kante, gleiche Abstände,
|
||||
* gleiche Schriftgrößen: trägt aber nicht deren Logik. Das ⋯-Menü und das
|
||||
* Ausblenden gehören zu Analytics; im Dashboard macht das die Widget-Registry
|
||||
* der Software (`docs/SCOPE-ANALYTICS-DASHBOARD.md`, Abschnitt 3.3).
|
||||
*
|
||||
@@ -23,7 +23,7 @@ import { ZEITRAUM_LABEL, type WidgetDomaene, type WidgetZeitraum } from "../../v
|
||||
*
|
||||
* 2. **Einen eigenen Zeitraum.** In Analytics gilt ein Filter für alle Karten.
|
||||
* Im Dashboard trägt jede Kachel ihren eigenen (P11: „Edit = nur
|
||||
* Widget-Filter/Zeitraum"). Deshalb steht er auf der Kachel — ohne das
|
||||
* Widget-Filter/Zeitraum"). Deshalb steht er auf der Kachel, ohne das
|
||||
* zeigt eine Kachel stillschweigend einen anderen Zeitraum als ihre
|
||||
* Nachbarin, und niemand merkt es.
|
||||
*/
|
||||
@@ -54,11 +54,11 @@ export function WidgetKachel({
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel?: string;
|
||||
/** Erklärt die Kennzahl; erscheint als Tooltip am (i) — wie in Analytics. */
|
||||
/** Erklärt die Kennzahl; erscheint als Tooltip am (i): wie in Analytics. */
|
||||
hinweis?: string;
|
||||
domaene: WidgetDomaene;
|
||||
zeitraum: WidgetZeitraum;
|
||||
/** Ziel des Absprungs in die Analyse — `widget.drilldownHref` in der Software. */
|
||||
/** Ziel des Absprungs in die Analyse: `widget.drilldownHref` in der Software. */
|
||||
absprung?: string;
|
||||
absprungText?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -67,7 +67,7 @@ export function WidgetKachel({
|
||||
return (
|
||||
<section
|
||||
/* @container: Die Kachelbreite hängt am Raster, nicht am Fenster. Eine
|
||||
Vier-Zwölftel-Kachel ist bei 1024 px rund 235 px breit — dieselbe
|
||||
Vier-Zwölftel-Kachel ist bei 1024 px rund 235 px breit: dieselbe
|
||||
Kachel bei 1920 px doppelt so viel. Was sich im Kopf einblendet, muss
|
||||
deshalb die Kachel messen und nicht den Viewport. */
|
||||
className={`@container flex h-full flex-col overflow-hidden rounded-lg border border-solid border-border-default bg-white shadow-sm ${className}`}
|
||||
@@ -112,7 +112,7 @@ export function WidgetKachel({
|
||||
{/* justify-center: Karten einer Zeile sind gleich hoch, aber nicht gleich
|
||||
voll. Eine Karte mit einem Balken neben einer mit einem Diagramm über
|
||||
400 px bekommt den Überschuss oben und unten je zur Hälfte, statt ihn
|
||||
unten abzuladen. Der Inhalt behält seine natürliche Höhe — die Karte
|
||||
unten abzuladen. Der Inhalt behält seine natürliche Höhe, die Karte
|
||||
wächst weiter mit, wenn mehr hineinkommt. */}
|
||||
<div className="flex min-h-0 grow flex-col justify-center px-4 pb-4">{children}</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
/**
|
||||
* Das Kachelraster des Dashboards.
|
||||
*
|
||||
* Übernommen aus `DashboardPage.tsx` der Software — dort steht wörtlich
|
||||
* Übernommen aus `DashboardPage.tsx` der Software, dort steht wörtlich
|
||||
*
|
||||
* <div className="grid grid-cols-1 gap-4 md:grid-cols-2
|
||||
* lg:grid-flow-row-dense lg:grid-cols-12">
|
||||
@@ -15,7 +15,7 @@ import React from "react";
|
||||
* `{ size: { w: 8 }, position: { x: 0 } }`.
|
||||
*
|
||||
* Der Unterschied ist die Herkunft der Zahlen, nicht ihre Bedeutung. Im
|
||||
* Prototyp sind sie geschrieben, in der Software gespeichert — dort kann der
|
||||
* Prototyp sind sie geschrieben, in der Software gespeichert, dort kann der
|
||||
* Benutzer das Layout bearbeiten.
|
||||
*/
|
||||
export function WidgetRaster({ children }: { children: React.ReactNode }) {
|
||||
@@ -28,7 +28,7 @@ export function WidgetRaster({ children }: { children: React.ReactNode }) {
|
||||
|
||||
/**
|
||||
* Eine Rasterzelle. Zeichengleich zu `dashboardWidgetGridClass` in der
|
||||
* Software — bis auf einen Zusatz: Dort fehlt der Fall `x === 9`, weil noch
|
||||
* Software, bis auf einen Zusatz: Dort fehlt der Fall `x === 9`, weil noch
|
||||
* keine Rolle vier Kacheln nebeneinander stellt. Unsere Kennzahlenreihe tut
|
||||
* das, deshalb steht er hier. Beim Einbau ist das die eine Zeile, die in
|
||||
* `dashboardWidgetGridClass` zu ergänzen ist; ohne sie fließt die vierte
|
||||
@@ -41,15 +41,15 @@ export function Widget({
|
||||
mittelVoll = false,
|
||||
children,
|
||||
}: {
|
||||
/** Spalten von zwölf — entspricht `widget.size.w`. */
|
||||
/** Spalten von zwölf: entspricht `widget.size.w`. */
|
||||
breite: 3 | 4 | 6 | 8 | 12;
|
||||
/** Erste Spalte, nullbasiert — entspricht `widget.position.x`. */
|
||||
/** Erste Spalte, nullbasiert: entspricht `widget.position.x`. */
|
||||
start?: 0 | 3 | 4 | 6 | 8 | 9;
|
||||
/**
|
||||
* Volle Breite schon im Zweispalter zwischen `md` und `lg`, obwohl die
|
||||
* Kachel schmaler als sechs Zwölftel ist. Für die Fälle, in denen die
|
||||
* Nachbarkachel derselben Zeile breit ist und die schmale sonst allein in
|
||||
* einer halben Zeile stünde — die andere Hälfte bliebe leer.
|
||||
* einer halben Zeile stünde, die andere Hälfte bliebe leer.
|
||||
*/
|
||||
mittelVoll?: boolean;
|
||||
children: React.ReactNode;
|
||||
@@ -85,7 +85,7 @@ export function Widget({
|
||||
* werden darf, entscheidet der Inhalt:
|
||||
*
|
||||
* Kennzahlen und Listen (drei bis vier Zwölftel) vertragen eine halbe Spalte
|
||||
* — bei 768 px sind das rund 200 px, und mehr braucht eine Zahl mit Etikett
|
||||
* bei 768 px sind das rund 200 px, und mehr braucht eine Zahl mit Etikett
|
||||
* nicht. Diagramme (sechs Zwölftel und mehr) vertragen es nicht: Auf 200 px
|
||||
* rücken die Beschriftungen der Zeitachse ineinander, und die Balken der
|
||||
* Auslastung schrumpfen auf Strichbreite. Sie nehmen deshalb schon hier die
|
||||
|
||||
@@ -21,7 +21,7 @@ const ZEITRAEUME: ZeitraumPreset[] = ["heute", "7_tage", "30_tage", "quartal", "
|
||||
* 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
|
||||
* (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).
|
||||
*/
|
||||
@@ -30,7 +30,7 @@ export function AnalyticsToolbar({
|
||||
optionen,
|
||||
}: {
|
||||
seite: SeitenId;
|
||||
/** Auswahllisten aus dem vollständigen Datensatz — in der Software aus dem Loader. */
|
||||
/** Auswahllisten aus dem vollständigen Datensatz, in der Software aus dem Loader. */
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
const { filter, setFilter, toggleWert, leereFeld, zuruecksetzen } = useFilter();
|
||||
@@ -70,7 +70,7 @@ export function AnalyticsToolbar({
|
||||
<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-4 py-3 backdrop-blur sm:px-6">
|
||||
{/* `relative` ist der Bezugsrahmen für die Filter-Panels: Unter `sm`
|
||||
hängen sie an dieser Zeile statt am einzelnen Knopf und nutzen die
|
||||
volle Breite — siehe MehrfachAuswahl. */}
|
||||
volle Breite: siehe MehrfachAuswahl. */}
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
{/* Zeitraum als Segment-Control: eine Auswahl, immer sichtbar.
|
||||
*
|
||||
@@ -79,7 +79,7 @@ export function AnalyticsToolbar({
|
||||
* Ein Segment-Control ist ein Bauteil mit einem Rahmen, und die
|
||||
* Stufen stehen in einer Reihenfolge (heute → alles). Über zwei
|
||||
* Zeilen gebrochen liest es sich als zwei Steuerelemente, und die
|
||||
* Reihenfolge geht verloren. Deshalb scrollt es waagerecht — ohne
|
||||
* Reihenfolge geht verloren. Deshalb scrollt es waagerecht, ohne
|
||||
* sichtbare Leiste, weil der angeschnittene letzte Knopf schon
|
||||
* anzeigt, dass es weitergeht.
|
||||
*
|
||||
|
||||
@@ -53,7 +53,7 @@ export function MehrfachAuswahl({
|
||||
|
||||
return (
|
||||
// Unter `sm` bewusst *nicht* `relative`: Das Panel ist 256 px breit, der
|
||||
// vierte Filterknopf steht bei 375 px aber schon bei x = 278 — an den Knopf
|
||||
// vierte Filterknopf steht bei 375 px aber schon bei x = 278: an den Knopf
|
||||
// gehängt liefe es weit aus dem Bild. Ohne eigenen Bezugsrahmen hängt es
|
||||
// stattdessen an der Filterleiste (AnalyticsToolbar, `relative` an der
|
||||
// Knopfzeile) und nimmt dort die volle Zeilenbreite ein.
|
||||
|
||||
@@ -11,20 +11,20 @@ import type { SeitenId } from "../../data/filtering";
|
||||
/**
|
||||
* Innere Navigation des Analytics-Bereichs.
|
||||
*
|
||||
* Nachbau von `SidebarIconsLayout` aus dem Zielsystem — der Leiste, die dort
|
||||
* Nachbau von `SidebarIconsLayout` aus dem Zielsystem, der Leiste, die dort
|
||||
* jeder Arbeitsbereich mit Geschwisterseiten benutzt (Leads, Termine, HR,
|
||||
* Einstellungen; 17 Aufrufstellen). Analytics mit fünf Analyse-Seiten ist
|
||||
* derselbe Fall.
|
||||
*
|
||||
* Verhalten wie dort: eine schmale Icon-Leiste, die bei Zeigerkontakt oder
|
||||
* Tastaturfokus auf Beschriftungen aufklappt — verzögert, damit sie beim
|
||||
* Tastaturfokus auf Beschriftungen aufklappt: verzögert, damit sie beim
|
||||
* bloßen Überstreichen nicht aufspringt. Unterhalb `sm` tritt an ihre Stelle
|
||||
* eine Reiterleiste; auch das ist aus dem Original übernommen.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* BEIM EINBAU: Dieser Nachbau wird NICHT portiert.
|
||||
*
|
||||
* Verbindlich ist die Verabredung — Analytics bekommt eine innere Leiste mit
|
||||
* Verbindlich ist die Verabredung: Analytics bekommt eine innere Leiste mit
|
||||
* den fünf Analyse-Seiten. Gebaut wird sie in HiveGrid mit dem dortigen
|
||||
* `SidebarIconsLayout`, das genau diesen Zweck hat (Leads, Termine, HR,
|
||||
* Einstellungen nutzen es bereits). Diese Datei beschreibt das Ziel, sie ist
|
||||
@@ -101,11 +101,11 @@ export function AnalyticsInnerNav({
|
||||
</SidebarInside>
|
||||
</div>
|
||||
|
||||
{/* Schmale Fenster: Reiter statt Leiste — wie im Original.
|
||||
{/* Schmale Fenster: Reiter statt Leiste, wie im Original.
|
||||
Waagerecht scrollend statt umbrechend: Fünf Reiter brauchen mindestens
|
||||
425 px, bei 375 px lief die ganze Seite dadurch seitlich über. Über
|
||||
zwei Zeilen gebrochen liest sich ein Reiterband als zwei Steuerelemente
|
||||
— dasselbe Mittel wie beim Zeitraum-Segment in der Filterleiste. */}
|
||||
dasselbe Mittel wie beim Zeitraum-Segment in der Filterleiste. */}
|
||||
<div className="dv-scroll-x w-full min-w-0 overflow-x-auto border-b border-solid border-border-default bg-white px-4 py-2 sm:hidden">
|
||||
<Tabs className="w-max min-w-full">
|
||||
{ANALYSE_SEITEN.map((seite) => (
|
||||
|
||||
@@ -13,12 +13,12 @@ import type { FilterOptionen } from "../../view-models/filter-optionen";
|
||||
*
|
||||
* „Analytics" ist in der Seitenleiste **ein** Menüpunkt. Die fünf Analyse-Seiten
|
||||
* sind darin über Reiter erreichbar und tauchen nicht als Unterpunkte in der
|
||||
* Navigation auf — sonst konkurrieren zwei Navigationsebenen um dieselbe Sache.
|
||||
* Navigation auf, sonst konkurrieren zwei Navigationsebenen um dieselbe Sache.
|
||||
*
|
||||
* Die Unter-Navigation ist eine **innere Leiste**, kein Reiterband: Das
|
||||
* Zielsystem benutzt für Arbeitsbereiche mit Geschwisterseiten durchgängig
|
||||
* `SidebarIconsLayout` (Leads, Termine, HR, Einstellungen). Reiter sind dort
|
||||
* für Abschnitte *innerhalb einer Seite* reserviert — und sind zugleich der
|
||||
* für Abschnitte *innerhalb einer Seite* reserviert, und sind zugleich der
|
||||
* Rückfall der inneren Leiste auf schmalen Fenstern. Begründung ausführlich
|
||||
* in docs/NAVIGATION.md.
|
||||
*
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatDatum, formatZahl } from "../../utils/formatters";
|
||||
import { HEUTE } from "../../utils/dateUtils";
|
||||
|
||||
/**
|
||||
* Rahmen der Anwendung — Seitenleiste nach dem Vorbild von HiveGrid Cloud.
|
||||
* Rahmen der Anwendung: Seitenleiste nach dem Vorbild von HiveGrid Cloud.
|
||||
*
|
||||
* Bewusst dieselbe Komponente wie dort (`~/ui/components/SidebarWithSections`)
|
||||
* mit denselben Gruppen und derselben Reihenfolge. Wer den Prototyp neben der
|
||||
@@ -22,7 +22,7 @@ import { HEUTE } from "../../utils/dateUtils";
|
||||
*
|
||||
* Jeder Eintrag führt auf eine echte Seite. „Dashboard" landet auf einer
|
||||
* Platzhalterseite, weil es ein eigenes Feature ist und hier nicht gestaltet
|
||||
* wird — aber ein Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob
|
||||
* wird, aber ein Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob
|
||||
* etwas fehlt oder etwas kaputt ist.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -31,7 +31,7 @@ import { HEUTE } from "../../utils/dateUtils";
|
||||
* Sie existiert, damit der Prototyp alleine lauffähig ist. In HiveGrid Cloud
|
||||
* übernehmen `SubframeAppShell` und `AppSidebar`. Verbindlich ist aus dieser
|
||||
* Datei allein die **Struktur**: zwei Einträge in der Seitenleiste, Dashboard
|
||||
* und Analytics — mehr nicht, weil alles Übrige dort schon steht.
|
||||
* und Analytics, mehr nicht, weil alles Übrige dort schon steht.
|
||||
*
|
||||
* Globale Regeln (Schrift, Größen, Abstände, Farben, Control-Höhen) kommen
|
||||
* ausschließlich aus HiveGrid und werden von hier aus nicht überschrieben.
|
||||
@@ -48,7 +48,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const istSchmal = useIstSchmal();
|
||||
const ausloeser = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Beim Seitenwechsel schließen — sonst bleibt die Leiste über dem Ziel
|
||||
// Beim Seitenwechsel schließen, sonst bleibt die Leiste über dem Ziel
|
||||
// liegen. Der Pfad wandert dafür in den State: Zustand beim Rendern
|
||||
// anzupassen ist hier richtig, ein Effekt würde einen zweiten Durchlauf
|
||||
// auslösen und die Leiste kurz über der neuen Seite zeigen.
|
||||
@@ -69,7 +69,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
* Nicht optional: Der Rahmen trägt `role="dialog"` und `aria-modal`, und
|
||||
* damit ist einem Screenreader zugesagt, dass nichts dahinter erreichbar ist.
|
||||
* Ohne Falle wandert der Tabulator hinter die Abdeckung in eine Seite, die
|
||||
* optisch verdeckt ist — die Zusage wäre eine Unwahrheit.
|
||||
* optisch verdeckt ist, die Zusage wäre eine Unwahrheit.
|
||||
*
|
||||
* Übernommen aus `SubframeAppShell` im Zielsystem: Tab und Umschalt+Tab
|
||||
* laufen zyklisch, Escape schließt und gibt den Fokus an den Auslöser
|
||||
@@ -78,7 +78,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
* Beim Öffnen bekommt der **Rahmen** den Fokus, nicht der erste Link. Das
|
||||
* ist das übliche Vorgehen für einen Dialog und hat hier einen sichtbaren
|
||||
* Grund: Ein per Skript fokussierter Link zeigt in Chrome seinen Fokusring,
|
||||
* auch wenn die Schublade mit der Maus geöffnet wurde — dann liegt ein
|
||||
* auch wenn die Schublade mit der Maus geöffnet wurde, dann liegt ein
|
||||
* blauer Rahmen um „Dashboard", ohne dass jemand dorthin getippt hat. Der
|
||||
* Rahmen selbst trägt `tabIndex={-1}` und keinen Ring; die Sprachausgabe
|
||||
* liest trotzdem den Dialog an, und der erste Tabulatorschritt landet auf
|
||||
@@ -112,7 +112,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const aktiv = document.activeElement;
|
||||
// `aktiv === leiste` gehört dazu: Direkt nach dem Öffnen liegt der Fokus
|
||||
// auf dem Rahmen selbst. Er steht im Dokument vor seinen Kindern, also
|
||||
// muss Umschalt+Tab von dort ans Ende springen — sonst verlässt der
|
||||
// muss Umschalt+Tab von dort ans Ende springen, sonst verlässt der
|
||||
// Tabulator den Dialog rückwärts, und die Zusage von `aria-modal` wäre
|
||||
// gebrochen.
|
||||
if (event.shiftKey && (aktiv === erstes || aktiv === leiste || !leiste.contains(aktiv))) {
|
||||
@@ -132,13 +132,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Zuletzt benutzte Eingabeart am Wurzelelement vermerken.
|
||||
*
|
||||
* `:focus-visible` allein reicht nicht: Wenn ein Skript den Fokus setzt —
|
||||
* beim Schließen der Schublade zurück auf den Hamburger — behandelt Chrome
|
||||
* `:focus-visible` allein reicht nicht: Wenn ein Skript den Fokus setzt,
|
||||
* beim Schließen der Schublade zurück auf den Hamburger: behandelt Chrome
|
||||
* das wie Tastaturbedienung und zeigt den Ring. Nach einem Mausklick steht
|
||||
* er dann sichtbar da, bis man woanders hinklickt.
|
||||
*
|
||||
* Die Regel dazu steht in `app/globals.css`. Sie unterdrückt den Ring nur,
|
||||
* solange die letzte Eingabe ein Zeiger war, und nur an Knöpfen und Links —
|
||||
* solange die letzte Eingabe ein Zeiger war, und nur an Knöpfen und Links,
|
||||
* Eingabefelder behalten ihn immer, dort gehört er zum Zustand „hier wird
|
||||
* gerade geschrieben". Der erste Tastendruck schaltet ihn sofort zurück.
|
||||
*/
|
||||
@@ -203,8 +203,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
{/* z-50 ist nötig, nicht dekorativ: `position: sticky` erzeugt einen
|
||||
eigenen Stapelkontext. Ohne z-Wert am Rahmen bleibt das z-index des
|
||||
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte — ein späteres
|
||||
Geschwisterelement — malt über den Teil, der aus der Leiste
|
||||
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte, ein späteres
|
||||
Geschwisterelement: malt über den Teil, der aus der Leiste
|
||||
herausragt. Die Software setzt an derselben Stelle ebenfalls z-50. */}
|
||||
<div
|
||||
ref={leisteRef}
|
||||
|
||||
@@ -2,11 +2,11 @@ import React from "react";
|
||||
|
||||
/**
|
||||
* Inhaltsspalte einer Seite.
|
||||
* 24 px Außenabstand, 16 px Rasterabstand — entspricht dem 8-px-System
|
||||
* 24 px Außenabstand, 16 px Rasterabstand: entspricht dem 8-px-System
|
||||
* aus den UI-Guidelines (internal ≤ external).
|
||||
*
|
||||
* Unter `sm` sind 24 px Rand ein Achtel der Fensterbreite: Der Rand kostet
|
||||
* dort mehr Platz, als er Ruhe bringt. Deshalb 16 px — der nächste Wert der
|
||||
* dort mehr Platz, als er Ruhe bringt. Deshalb 16 px, der nächste Wert der
|
||||
* Skala, nicht ein neuer.
|
||||
*/
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
@@ -32,7 +32,7 @@ export function Raster({
|
||||
}) {
|
||||
/**
|
||||
* Jede Leiter beginnt einspaltig. Zwei Spalten sind bei 375 px rund 155 px
|
||||
* je Karte — darin bricht ein Währungswert zwischen Betrag und Euro-Zeichen
|
||||
* je Karte: darin bricht ein Währungswert zwischen Betrag und Euro-Zeichen
|
||||
* um, und die Karte wird höher statt schmaler.
|
||||
*
|
||||
* Wann die erste Verdopplung kommt, hängt am Inhalt, nicht an der Zielzahl:
|
||||
@@ -42,7 +42,7 @@ export function Raster({
|
||||
* Diagramme und Tabellen, die halbiert erst ab `md` lesbar bleiben.
|
||||
*
|
||||
* Keine Leiter erhöht bei `md`: Dort kommt die Seitenleiste zurück und
|
||||
* nimmt 240 px weg — die Karten würden schmaler statt breiter.
|
||||
* nimmt 240 px weg, die Karten würden schmaler statt breiter.
|
||||
*
|
||||
* Ab 1024 px ist jede Leiter identisch zum abgenommenen Stand.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useToast } from "../../context/ToastContext";
|
||||
|
||||
/**
|
||||
* Kopf einer Analyse-Seite: Titel, Einordnung, Seitenaktionen.
|
||||
* Die Aktionen sind im Prototyp bewusst folgenlos (Toast statt Export) —
|
||||
* Die Aktionen sind im Prototyp bewusst folgenlos (Toast statt Export),
|
||||
* siehe HANDOVER.md, Abschnitt "Was noch Logik braucht".
|
||||
*/
|
||||
export function PageHeader({
|
||||
@@ -15,7 +15,7 @@ export function PageHeader({
|
||||
aktionen,
|
||||
/**
|
||||
* Aktualisieren, Export und Teilen. Auf Seiten ohne Inhalt gibt es nichts zu
|
||||
* aktualisieren oder zu exportieren — dort schaltet man sie ab, statt
|
||||
* aktualisieren oder zu exportieren, dort schaltet man sie ab, statt
|
||||
* Schaltflächen anzubieten, die ins Leere greifen.
|
||||
*/
|
||||
standardAktionen = true,
|
||||
@@ -29,8 +29,8 @@ export function PageHeader({
|
||||
|
||||
/**
|
||||
* Unter `sm` tragen die beiden beschrifteten Knöpfe nur ihr Icon.
|
||||
* Nebeneinander brauchen "Aktualisieren" und "Export" rund 240 px — mehr,
|
||||
* als bei 375 px neben dem Titel überhaupt vorhanden ist. Die Beschriftung
|
||||
* Nebeneinander brauchen "Aktualisieren" und "Export" rund 240 px. Das ist
|
||||
* mehr, als bei 375 px neben dem Titel überhaupt vorhanden ist. Die Beschriftung
|
||||
* entfällt, `aria-label` bleibt: Für die Sprachausgabe ändert sich nichts.
|
||||
*/
|
||||
const knopf =
|
||||
@@ -40,7 +40,7 @@ export function PageHeader({
|
||||
|
||||
return (
|
||||
// Unter `sm` untereinander: Titel und Aktionen nebeneinander lassen für den
|
||||
// Titel keine 130 px übrig — er zerfiele in vier Zeilen, während die Knöpfe
|
||||
// Titel keine 130 px übrig, er zerfiele in vier Zeilen, während die Knöpfe
|
||||
// aus dem Bild laufen.
|
||||
<div className="flex flex-col items-stretch gap-4 px-4 pb-6 pt-6 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:px-6">
|
||||
<div className="flex min-w-0 flex-col gap-0">
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SeitenId } from "../../data/filtering";
|
||||
* Navigationsstruktur des Prototyps.
|
||||
*
|
||||
* Hier stehen **nur unsere beiden Features**: Dashboard und Analytics. Alles
|
||||
* andere — Kontakte, Leads, Pipelines, Postfach, Termine, Einstellungen — ist
|
||||
* andere (Kontakte, Leads, Pipelines, Postfach, Termine, Einstellungen) ist
|
||||
* in der Seitenleiste der Software bereits vorhanden und wird hier nicht
|
||||
* verdoppelt. Der Prototyp zeigt, was dazukommt, nicht was schon da ist.
|
||||
*
|
||||
@@ -37,7 +37,7 @@ export interface NavGruppe {
|
||||
/** Einstieg des Analytics-Bereichs. */
|
||||
export const ANALYTICS_START = "/analytics";
|
||||
|
||||
/** Der Begrüßungsbildschirm — Ziel der Weiterleitung von „/", wie in der Software. */
|
||||
/** Der Begrüßungsbildschirm: Ziel der Weiterleitung von „/", wie in der Software. */
|
||||
export const DASHBOARD_START = "/dashboard";
|
||||
|
||||
export const NAV_GRUPPEN: NavGruppe[] = [
|
||||
@@ -46,7 +46,7 @@ export const NAV_GRUPPEN: NavGruppe[] = [
|
||||
titel: "",
|
||||
eintraege: [
|
||||
{
|
||||
// Der Begrüßungsbildschirm — eine einzelne Seite, deshalb ohne innere
|
||||
// Der Begrüßungsbildschirm, eine einzelne Seite, deshalb ohne innere
|
||||
// Leiste. Er ist das Ziel der Weiterleitung von „/".
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
@@ -66,10 +66,10 @@ export const NAV_GRUPPEN: NavGruppe[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Unterer Bereich, durch einen Trenner abgesetzt — wie in der Software.
|
||||
* Unterer Bereich, durch einen Trenner abgesetzt, wie in der Software.
|
||||
*
|
||||
* Hier steht nur, was zum Prototyp gehört: die Bausteingalerie. Sie ist für
|
||||
* die **Abstimmung** da, nicht für die Umsetzung — sie zeigt jeden Baustein in
|
||||
* die **Abstimmung** da, nicht für die Umsetzung, sie zeigt jeden Baustein in
|
||||
* allen Zuständen, auch in denen, die die Demo-Daten nie erzeugen (negativer
|
||||
* Trend, leer, Fehler, „kleiner ist besser"). Wer den Prototyp einbaut,
|
||||
* braucht sie nicht: Dafür sind die Datei-Pfade und `docs/DESIGN-SYSTEM.md`
|
||||
@@ -88,7 +88,7 @@ export const NAV_SEKUNDAER: NavEintrag[] = [
|
||||
|
||||
/**
|
||||
* Die fünf Analyse-Seiten. Sie erscheinen **nicht** in der Seitenleiste,
|
||||
* sondern als Reiter innerhalb des Menüpunkts „Analytics" — dem Hausmuster
|
||||
* sondern als Reiter innerhalb des Menüpunkts „Analytics": dem Hausmuster
|
||||
* für Unter-Navigation in HiveGrid (`SettingsHubShell.tsx`: ein `Link` um ein
|
||||
* `Tabs.Item`, damit jeder Reiter eine echte Navigation ist).
|
||||
*/
|
||||
@@ -107,21 +107,21 @@ export const ANALYSE_SEITEN: AnalyseSeite[] = [
|
||||
icon: "LayoutDashboard",
|
||||
label: "Übersicht",
|
||||
href: "/analytics",
|
||||
beschreibung: "Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf.",
|
||||
beschreibung: "Die Kennzahlen, nach denen der Betrieb gesteuert wird: Umsatz, Auslastung, offene Posten und Handlungsbedarf.",
|
||||
},
|
||||
{
|
||||
id: "finanzen",
|
||||
icon: "Euro",
|
||||
label: "Finanzen",
|
||||
href: "/analytics/finanzen",
|
||||
beschreibung: "Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb.",
|
||||
beschreibung: "Umsatz, Zahlungseingang und offene Forderungen: die Sicht der Buchhaltung auf den Betrieb.",
|
||||
},
|
||||
{
|
||||
id: "auftraege",
|
||||
icon: "ClipboardList",
|
||||
label: "Aufträge",
|
||||
href: "/analytics/auftraege",
|
||||
beschreibung: "Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft.",
|
||||
beschreibung: "Von der Anfrage bis zur Abnahme: wo die Vorgänge stehen, was gebunden ist und was über Termin läuft.",
|
||||
},
|
||||
{
|
||||
id: "kunden",
|
||||
@@ -135,6 +135,6 @@ export const ANALYSE_SEITEN: AnalyseSeite[] = [
|
||||
icon: "HardHat",
|
||||
label: "Team",
|
||||
href: "/analytics/team",
|
||||
beschreibung: "Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung.",
|
||||
beschreibung: "Auslastung, produktive Stunden und Abschlüsse der Monteure: die Kennzahl hinter der Terminplanung.",
|
||||
},
|
||||
];
|
||||
@@ -14,7 +14,7 @@ export interface Spalte<T> {
|
||||
titel: string;
|
||||
/** Zellinhalt; darf beliebige Knoten liefern (Badges, Links). */
|
||||
zelle: (zeile: T) => React.ReactNode;
|
||||
/** Sortierwert — ohne diesen ist die Spalte nicht sortierbar. */
|
||||
/** Sortierwert, ohne diesen ist die Spalte nicht sortierbar. */
|
||||
sortWert?: (zeile: T) => number | string;
|
||||
/** Zahlen gehören rechtsbündig und in Tabellenziffern. */
|
||||
numerisch?: boolean;
|
||||
@@ -26,7 +26,7 @@ export interface Spalte<T> {
|
||||
*
|
||||
* 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.
|
||||
* Alles clientseitig, bei echten Datenmengen gehört das auf den Server.
|
||||
*/
|
||||
export function DataTable<T extends { id: string }>({
|
||||
zeilen,
|
||||
@@ -105,7 +105,7 @@ export function DataTable<T extends { id: string }>({
|
||||
{/* Der waagerechte Scrollbereich der Tabelle. Ohne min-w-0 am Elternteil
|
||||
greift er nicht: Ein Flex-Kind wird nicht schmaler als sein Inhalt,
|
||||
die Tabelle bliebe also breit und schöbe stattdessen die Seite auf.
|
||||
Vorher trug der Bereich zusätzlich -mx-1/px-1 — das machte ihn 8 px
|
||||
Vorher trug der Bereich zusätzlich -mx-1/px-1: das machte ihn 8 px
|
||||
breiter als seine Spalte und ließ die Karte um 4 px überlaufen. Den
|
||||
Fokusring der Sortierknöpfe deckt bereits das px-2 der Kopfzelle. */}
|
||||
<div className="dv-scroll w-full min-w-0 overflow-x-auto">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { erstellePersistentenStore } from "./persistenterStore";
|
||||
|
||||
/**
|
||||
* Globaler Filter-State über alle Analytics-Seiten hinweg (INFRA-01),
|
||||
* persistiert in localStorage (INFRA-02) — ein Reload verwirft die Auswahl
|
||||
* persistiert in localStorage (INFRA-02): ein Reload verwirft die Auswahl
|
||||
* nicht, im Kundentermin ein spürbarer Unterschied.
|
||||
*/
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ 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,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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.
|
||||
* gespeicherten, ohne Mismatch und ohne Zusatzrender.
|
||||
*/
|
||||
|
||||
export interface PersistenterStore<T> {
|
||||
@@ -19,7 +19,7 @@ export interface PersistenterStore<T> {
|
||||
export function erstellePersistentenStore<T>(
|
||||
schluessel: string,
|
||||
standard: T,
|
||||
/** Gelesene Daten mit dem Standard verschmelzen — fängt alte Formate ab. */
|
||||
/** 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> {
|
||||
@@ -52,7 +52,7 @@ export function erstellePersistentenStore<T>(
|
||||
try {
|
||||
window.localStorage.setItem(schluessel, JSON.stringify(wert));
|
||||
} catch {
|
||||
// Privater Modus o. Ä. — Persistenz ist optional.
|
||||
// Privater Modus o. Ä.: Persistenz ist optional.
|
||||
}
|
||||
hoerer.forEach((h) => h());
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Kennzahlen-Berechnungen.
|
||||
*
|
||||
* Alle Funktionen sind pure functions über dem gefilterten Datensatz
|
||||
* (Anforderung INFRA-06) — die Entwicklung kann sie unverändert übernehmen
|
||||
* (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.
|
||||
@@ -46,7 +46,7 @@ export function umsatz(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number
|
||||
.reduce((summe, r) => summe + r.betrag, 0);
|
||||
}
|
||||
|
||||
/** Gleiche Berechnung für die Vorperiode — Basis der Trend-Badges. */
|
||||
/** Gleiche Berechnung für die Vorperiode: Basis der Trend-Badges. */
|
||||
export function umsatzVorperiode(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number {
|
||||
const fenster = vorperiode(zeitraum);
|
||||
return rechnungen
|
||||
@@ -54,7 +54,7 @@ export function umsatzVorperiode(rechnungen: Rechnung[], zeitraum: ZeitraumPrese
|
||||
.reduce((summe, r) => summe + r.betrag, 0);
|
||||
}
|
||||
|
||||
/** Beschriftung eines Buckets — eine Stelle fuer alle drei Zeitreihen. */
|
||||
/** 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);
|
||||
@@ -171,7 +171,7 @@ export function zahlungsdauer(rechnungen: Rechnung[], zeitraum: ZeitraumPreset):
|
||||
* Zusammensetzung des OFFENEN Bestands.
|
||||
*
|
||||
* Nicht zu verwechseln mit rechnungenNachStatus(): dort steht der kumulierte
|
||||
* Umsatz aus zwei Jahren gegen den heutigen Forderungsbestand — dabei entfallen
|
||||
* 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.
|
||||
*/
|
||||
@@ -205,13 +205,13 @@ export function auftraegeImZeitraum(auftraege: Auftrag[], zeitraum: ZeitraumPres
|
||||
return auftraege.filter((a) => imFenster(a.angelegtAm, fenster));
|
||||
}
|
||||
|
||||
/** "Aktiv" = beauftragt oder in Arbeit — das, was das Team gerade bindet. */
|
||||
/** "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
|
||||
// Einen Termin gibt es erst ab der Beauftragung, eine offene Anfrage kann
|
||||
// nicht "über Termin" sein.
|
||||
return auftraege
|
||||
.filter(
|
||||
@@ -264,7 +264,7 @@ export function umsatzNachGewerk(
|
||||
* Verlorene Aufträge zählen nur bis zu ihrer letzten erreichten Stufe.
|
||||
*/
|
||||
/**
|
||||
* Vertriebstrichter — bewusst nur über das Angebotsgeschäft.
|
||||
* 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
|
||||
@@ -335,7 +335,7 @@ export function neueKunden(kunden: Kunde[], zeitraum: ZeitraumPreset): number {
|
||||
return kunden.filter((k) => imFenster(k.angelegtAm, fenster)).length;
|
||||
}
|
||||
|
||||
/** Gleiches Fenster davor — Basis fuer den Trend an der Neukunden-Karte. */
|
||||
/** 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;
|
||||
@@ -455,7 +455,7 @@ export interface AlertEintrag {
|
||||
text: string;
|
||||
detail: string;
|
||||
art: "kritisch" | "warnung" | "hinweis";
|
||||
/** Betrag, um den es geht — macht die Zeilen untereinander vergleichbar. */
|
||||
/** Betrag, um den es geht: macht die Zeilen untereinander vergleichbar. */
|
||||
betrag?: number;
|
||||
/**
|
||||
* Die Handlung, nicht die Beobachtung. Eine Liste ohne Verb ist ein Bericht;
|
||||
@@ -538,7 +538,7 @@ export function handlungsbedarf(daten: Datensatz): AlertEintrag[] {
|
||||
*
|
||||
* 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.
|
||||
* einstelligen Bereich und wäre wertlos, eine Badwartung war nie förderfähig.
|
||||
*/
|
||||
export function foerderQuote(auftraege: Auftrag[]): {
|
||||
quote: number;
|
||||
|
||||
@@ -26,7 +26,7 @@ export const FILTER_RELEVANZ: Record<SeitenId, FilterFeld[]> = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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]);
|
||||
@@ -41,7 +41,7 @@ export function wirksameFilter(filter: FilterState, seite: SeitenId): FilterStat
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet die Dimensionsfilter an — bewusst OHNE Zeitfilter.
|
||||
* Wendet die Dimensionsfilter an: bewusst OHNE Zeitfilter.
|
||||
*
|
||||
* Grund: Kennzahlen brauchen unterschiedliche Zeitbezüge (Umsatz im Zeitraum,
|
||||
* offene Rechnungen dagegen stichtagsbezogen). Der Zeitraum wird deshalb
|
||||
@@ -77,7 +77,7 @@ export function wendeFilterAn(daten: Datensatz, filter: FilterState): Datensatz
|
||||
return { mitarbeiter, kunden, auftraege, rechnungen, zeiten };
|
||||
}
|
||||
|
||||
/** Anzahl aktiver Einschränkungen — für das Badge am Filter-Button. */
|
||||
/** 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 (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Der angemeldete Benutzer — im Prototyp eine Attrappe.
|
||||
* Der angemeldete Benutzer, im Prototyp eine Attrappe.
|
||||
*
|
||||
* In HiveGrid Cloud kommt das aus der Sitzung (`requireMember`) und steht dem
|
||||
* Loader zur Verfügung, bevor die Seite rendert. Hier ist es eine Konstante,
|
||||
@@ -16,7 +16,7 @@ export const NUTZER = {
|
||||
* Für die Anrede. In eckigen Klammern und nicht als erfundener Name:
|
||||
* „Guten Tag, Dejan" sieht in einer Abnahme aus wie ein fest verdrahteter
|
||||
* Wert, und irgendwann fragt jemand, warum dort immer derselbe Name steht.
|
||||
* „[Vorname]" sagt stattdessen, was an dieser Stelle eingesetzt wird — beim
|
||||
* „[Vorname]" sagt stattdessen, was an dieser Stelle eingesetzt wird: beim
|
||||
* Einbau kommt er aus der Sitzung.
|
||||
*/
|
||||
vorname: "[Vorname]",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 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`
|
||||
* diese Datei durch Entity-Builder-Queries ersetzt, das Interface `Datensatz`
|
||||
* bleibt gleich.
|
||||
*
|
||||
* Betriebsprofil (fiktiv):
|
||||
@@ -32,7 +32,7 @@ import { HEUTE, addTage, toISO } from "../utils/dateUtils";
|
||||
|
||||
/* ------------------------------------------------------ Zufall mit Seed */
|
||||
|
||||
/** mulberry32 — kleiner, schneller PRNG mit reproduzierbarer Sequenz. */
|
||||
/** mulberry32: kleiner, schneller PRNG mit reproduzierbarer Sequenz. */
|
||||
function prng(seed: number) {
|
||||
let a = seed;
|
||||
return function random(): number {
|
||||
@@ -177,7 +177,7 @@ function baueKunden(anzahl: number): Kunde[] {
|
||||
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,
|
||||
// Gleichverteilt wäre jeder zweite Kunde auffällig, das wäre kein Betrieb,
|
||||
// sondern ein Inkassofall.
|
||||
zahlungsmoralTage: gewichtet([
|
||||
{ wert: zahl(5, 14), gewicht: 42 },
|
||||
@@ -217,7 +217,7 @@ const WERT_SPANNEN: Record<string, [number, number]> = {
|
||||
"E-Check": [140, 390],
|
||||
};
|
||||
|
||||
/** Bezugsgröße der Förderquote — zentral in types.ts definiert. */
|
||||
/** Bezugsgröße der Förderquote: zentral in types.ts definiert. */
|
||||
const FOERDERFAEHIG = FOERDERFAEHIGE_KATEGORIEN;
|
||||
|
||||
/**
|
||||
@@ -225,12 +225,12 @@ const FOERDERFAEHIG = FOERDERFAEHIGE_KATEGORIEN;
|
||||
*
|
||||
* 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 —
|
||||
* 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. */
|
||||
/** Große Vorhaben werden angeboten und oft verglichen: kleine kaum. */
|
||||
grossauftrag: boolean
|
||||
): {
|
||||
status: AuftragStatus;
|
||||
@@ -297,7 +297,7 @@ function ergebnisFuerAlter(
|
||||
|
||||
/**
|
||||
* Leistungsart innerhalb eines Gewerks.
|
||||
* Wartung, Notdienst und E-Check sind im Alltag die häufigsten Aufträge —
|
||||
* 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 {
|
||||
@@ -451,7 +451,7 @@ function baueRechnungen(auftraege: Auftrag[]): Rechnung[] {
|
||||
let bezahltAm: string | null = null;
|
||||
|
||||
if (gestellt > HEUTE) {
|
||||
// Rechnung existiert noch nicht — z. B. Schlussrechnung eines laufenden Auftrags.
|
||||
// Rechnung existiert noch nicht: z. B. Schlussrechnung eines laufenden Auftrags.
|
||||
status = "entwurf";
|
||||
} else if (ueberfaelligTage < 0) {
|
||||
// Noch innerhalb des Zahlungsziels; ein Teil zahlt sofort.
|
||||
@@ -473,7 +473,7 @@ function baueRechnungen(auftraege: Auftrag[]): Rechnung[] {
|
||||
status = "ueberfaellig";
|
||||
}
|
||||
} else if (ueberfaelligTage <= 120) {
|
||||
// Mahnstufe erreicht — der Großteil zahlt spätestens jetzt.
|
||||
// Mahnstufe erreicht, der Großteil zahlt spätestens jetzt.
|
||||
if (rnd() < 0.94) {
|
||||
status = "bezahlt";
|
||||
bezahltAm = toISO(addTage(faellig, zahl(-8, 70)));
|
||||
@@ -489,7 +489,7 @@ function baueRechnungen(auftraege: Auftrag[]): Rechnung[] {
|
||||
status = "ueberfaellig";
|
||||
}
|
||||
} else {
|
||||
// Älter als sieben Monate: eingebracht oder ausgebucht — in beiden
|
||||
// Ä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)));
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GEWERKE = [
|
||||
] as const;
|
||||
export type Gewerk = (typeof GEWERKE)[number];
|
||||
|
||||
/** Leistungsarten je Gewerk — Grundlage für "Umsatz nach Kategorie". */
|
||||
/** 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"],
|
||||
@@ -65,7 +65,7 @@ export interface Kunde {
|
||||
ort: string;
|
||||
angelegtAm: string;
|
||||
letzterAuftragAm: string | null;
|
||||
/** Aggregiert aus Rechnungen — wird in seed.ts nachberechnet. */
|
||||
/** Aggregiert aus Rechnungen: wird in seed.ts nachberechnet. */
|
||||
umsatzGesamt: number;
|
||||
auftraegeGesamt: number;
|
||||
zahlungsmoralTage: number;
|
||||
@@ -94,7 +94,7 @@ export const AUFTRAG_STATUS_LABEL: Record<AuftragStatus, string> = {
|
||||
|
||||
/**
|
||||
* Leistungsarten, für die BAFA-/KfW-Förderung in Frage kommt.
|
||||
* Bezugsgröße der Förderquote — sonst rechnet man Wartungsaufträge
|
||||
* 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([
|
||||
@@ -137,7 +137,7 @@ export interface Auftrag {
|
||||
/**
|
||||
* 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.
|
||||
* ist, und würde die oberen Stufen systematisch zu klein zeigen.
|
||||
*/
|
||||
verlorenAb: AuftragStatus | null;
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,8 @@ 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)
|
||||
* `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
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from "react";
|
||||
* Ist das Fenster schmaler als der `md`-Breakpoint (768 px)?
|
||||
*
|
||||
* Nötig, weil unterhalb `md` die Seitenleiste über den Inhalt fährt statt ihn
|
||||
* zu verdrängen — und eine geschlossene Schublade muss dann auch für
|
||||
* zu verdrängen, und eine geschlossene Schublade muss dann auch für
|
||||
* Screenreader und Tastatur verschwinden (`aria-hidden`, `inert`). Über reine
|
||||
* CSS-Klassen ist das nicht zu haben: `max-md:` verändert die Darstellung,
|
||||
* nicht den Zugänglichkeitsbaum. Ohne Breitenprüfung wäre die Leiste auch auf
|
||||
@@ -14,7 +14,7 @@ import React from "react";
|
||||
*
|
||||
* `useSyncExternalStore` statt Effekt plus State: `matchMedia` ist genau so
|
||||
* ein externer Speicher, und beim Server-Rendering liefert die Momentaufnahme
|
||||
* `false` — die Leiste ist dort also sichtbar, was ohne JavaScript richtig ist.
|
||||
* `false`, die Leiste ist dort also sichtbar, was ohne JavaScript richtig ist.
|
||||
*/
|
||||
const ABFRAGE = "(max-width: 767px)";
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export function AuftraegeSeite({ vm }: { vm: VMState<AuftraegeDaten> }) {
|
||||
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."
|
||||
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={d.trichter} formatWert={(w) => formatZahl(w)} />
|
||||
</ChartCard>
|
||||
@@ -176,7 +176,7 @@ export function AuftraegeSeite({ vm }: { vm: VMState<AuftraegeDaten> }) {
|
||||
<ChartCard
|
||||
id="auftraege-status"
|
||||
titel="Vorgänge nach Status"
|
||||
untertitel="Alle Vorgänge seit Beginn — unabhängig vom Zeitraumfilter"
|
||||
untertitel="Alle Vorgänge seit Beginn, unabhängig vom Zeitraumfilter"
|
||||
>
|
||||
<Donut
|
||||
daten={d.statusVerteilung}
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { VMState } from "../view-models";
|
||||
import { ZEITRAUM_LABEL, type DashboardDaten, type KennzahlKachel } from "../view-models/dashboard.vm";
|
||||
|
||||
/**
|
||||
* Das Dashboard — der Begrüßungsbildschirm nach der Anmeldung.
|
||||
* Das Dashboard, der Begrüßungsbildschirm nach der Anmeldung.
|
||||
*
|
||||
* ## Was hier steht und warum
|
||||
*
|
||||
@@ -33,14 +33,14 @@ import { ZEITRAUM_LABEL, type DashboardDaten, type KennzahlKachel } from "../vie
|
||||
* „Warum ist das so?" (`docs/SCOPE-ANALYTICS-DASHBOARD.md`). Daraus folgt die
|
||||
* Reihenfolge auf dieser Seite, und zwar in dieser Richtung:
|
||||
*
|
||||
* 1. **Ansprache und Lage** — muss ich heute überhaupt etwas tun?
|
||||
* 2. **Vier Kennzahlen** — die Lage in Zahlen. Jede ist ein Link in die
|
||||
* 1. **Ansprache und Lage**: muss ich heute überhaupt etwas tun?
|
||||
* 2. **Vier Kennzahlen**, die Lage in Zahlen. Jede ist ein Link in die
|
||||
* Analyse, die sie erklärt.
|
||||
* 3. **Handlungsbedarf** — was zu tun ist. Laut Scope-Papier (3.2) gehört
|
||||
* 3. **Handlungsbedarf**, was zu tun ist. Laut Scope-Papier (3.2) gehört
|
||||
* diese Liste hierher und nicht auf die Analytics-Übersicht; das ist
|
||||
* hiermit umgesetzt.
|
||||
* 4. **Vier Diagramme** — die Zusammenfassung aus Analytics.
|
||||
* 5. **Reservierte Plätze** — was das Dashboard noch wird.
|
||||
* 4. **Vier Diagramme**, die Zusammenfassung aus Analytics.
|
||||
* 5. **Reservierte Plätze**, was das Dashboard noch wird.
|
||||
*
|
||||
* Absteigend nach Dringlichkeit, nicht nach Fachgebiet. Ein Dashboard, das
|
||||
* nach Fachgebieten sortiert ist, ist ein Inhaltsverzeichnis.
|
||||
@@ -49,7 +49,7 @@ import { ZEITRAUM_LABEL, type DashboardDaten, type KennzahlKachel } from "../vie
|
||||
*
|
||||
* Jede Kachel steht in `<Widget breite={…} start={…}>`. Diese beiden Zahlen
|
||||
* sind in der Software `widget.size.w` und `widget.position.x` und kommen dort
|
||||
* aus dem gespeicherten Layout — der Benutzer kann es bearbeiten. Hier sind
|
||||
* aus dem gespeicherten Layout, der Benutzer kann es bearbeiten. Hier sind
|
||||
* sie geschrieben. Das ist der einzige Unterschied; das Raster darunter ist
|
||||
* dasselbe (siehe `WidgetRaster.tsx`).
|
||||
*
|
||||
@@ -58,7 +58,7 @@ import { ZEITRAUM_LABEL, type DashboardDaten, type KennzahlKachel } from "../vie
|
||||
* Kein seitenweiter Filter. In Analytics wirkt eine Filterleiste auf alle
|
||||
* Karten; im Dashboard trägt jede Kachel ihren eigenen Zeitraum und sagt ihn
|
||||
* an (P11: „Edit = nur Widget-Filter/Zeitraum"). Eine Filterleiste über
|
||||
* dieser Seite wäre die bequemere Lösung und der falsche Weg — sie würde die
|
||||
* dieser Seite wäre die bequemere Lösung und der falsche Weg, sie würde die
|
||||
* Kacheln beim Einbau alle wieder auseinandernehmen.
|
||||
*
|
||||
* Auch keine Rollenumschaltung. Der Prototyp gestaltet die Sicht der
|
||||
@@ -98,7 +98,7 @@ export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
|
||||
<Widget breite={12} start={0}>
|
||||
<WidgetKachel
|
||||
titel="Handlungsbedarf"
|
||||
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
|
||||
untertitel="Sortiert nach Dringlichkeit, jede Zeile führt in die zugehörige Arbeitsliste"
|
||||
domaene="general"
|
||||
zeitraum="stichtag"
|
||||
>
|
||||
@@ -139,7 +139,7 @@ export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
|
||||
<WidgetKachel
|
||||
titel="Umsatz nach Gewerk"
|
||||
untertitel="Zahlungseingang der letzten 30 Tage"
|
||||
hinweis="Fünf Gewerke in fester Rangfolge — dieselbe Kodierung wie auf der Finanzseite."
|
||||
hinweis="Fünf Gewerke in fester Rangfolge: dieselbe Kodierung wie auf der Finanzseite."
|
||||
domaene="finance"
|
||||
zeitraum="30_tage"
|
||||
absprung="/analytics/finanzen"
|
||||
@@ -181,12 +181,12 @@ export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
|
||||
</Widget>
|
||||
|
||||
{/* ────────────────────────────────────────────────────────────────
|
||||
HIER GEHT ES WEITER — an dieser Stelle, nicht woanders.
|
||||
HIER GEHT ES WEITER: an dieser Stelle, nicht woanders.
|
||||
|
||||
Das Dashboard zeigt heute nur Auswertungen. Es wird mehr: eigene
|
||||
Aufgaben, Termine des Tages, offene HR-Anträge. Alle drei stehen
|
||||
im Designer-Brief P11 und haben in HiveGrid bereits ihre
|
||||
Widget-Domäne (`tasks`, `schedule`, `hr`) — sie brauchen kein
|
||||
Widget-Domäne (`tasks`, `schedule`, `hr`): sie brauchen kein
|
||||
neues Raster, nur Daten.
|
||||
|
||||
Konkret kommen sie als drei weitere `<Widget breite={4}>` mit
|
||||
@@ -213,7 +213,7 @@ export function DashboardSeite({ vm }: { vm: VMState<DashboardDaten> }) {
|
||||
/**
|
||||
* Eine Kennzahl als Kachel.
|
||||
*
|
||||
* Es ist dieselbe `KPICard` wie in Analytics — dieselbe Anatomie, dieselben
|
||||
* Es ist dieselbe `KPICard` wie in Analytics: dieselbe Anatomie, dieselben
|
||||
* Regeln zu Trend und Farbe. Neu ist nur, dass sie ein Link ist: Auf dem
|
||||
* Dashboard ist jede Zahl ein Absprung in die Analyse, die sie erklärt. Genau
|
||||
* so macht es die Software (`widget.href` legt den Link um die `StatsCard`).
|
||||
@@ -236,7 +236,7 @@ function KennzahlWidget({
|
||||
<Link
|
||||
href={kachel.href}
|
||||
className="block h-full rounded-lg no-underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
aria-label={`${kachel.titel}: ${wert} — zur Analyse`}
|
||||
aria-label={`${kachel.titel}: ${wert}. Zur Analyse`}
|
||||
>
|
||||
<KPICard
|
||||
label={kachel.titel}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function FinanzenSeite({ vm }: { vm: VMState<FinanzenDaten> }) {
|
||||
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."
|
||||
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={d.offenNachStatus}
|
||||
@@ -239,7 +239,7 @@ export function FinanzenSeite({ vm }: { vm: VMState<FinanzenDaten> }) {
|
||||
<ChartCard
|
||||
id="finanzen-offene-liste"
|
||||
titel={`Offene Rechnungen (${d.posten.anzahl})`}
|
||||
untertitel="Sortiert nach Fälligkeit — die Arbeitsliste für den Mahnlauf"
|
||||
untertitel="Sortiert nach Fälligkeit, die Arbeitsliste für den Mahnlauf"
|
||||
>
|
||||
<DataTable
|
||||
zeilen={d.posten.liste}
|
||||
|
||||
@@ -60,20 +60,20 @@ export function KomponentenSeite() {
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Komponenten & Regeln"
|
||||
beschreibung="Jeder Baustein einzeln und in allen Zuständen — die Seite zum Draufzeigen in der Abstimmung."
|
||||
beschreibung="Jeder Baustein einzeln und in allen Zuständen: die Seite zum Draufzeigen in der Abstimmung."
|
||||
/>
|
||||
|
||||
<PageContainer>
|
||||
<Abschnitt
|
||||
titel="Warum diese Seite existiert"
|
||||
text="Auf den Analyse-Seiten sieht man von jedem Baustein immer nur den einen Zustand, den die Daten gerade hergeben — die Kennzahlenkarte mit steigendem Trend, das Diagramm mit Werten darin. Die anderen Zustände existieren trotzdem und müssen mitgestaltet werden. Diese Seite zeigt sie alle nebeneinander, damit man in der Abstimmung darauf zeigen kann, ohne erst einen Filter zu suchen, der den passenden Fall herstellt."
|
||||
text="Auf den Analyse-Seiten sieht man von jedem Baustein immer nur den einen Zustand, den die Daten gerade hergeben: die Kennzahlenkarte mit steigendem Trend, das Diagramm mit Werten darin. Die anderen Zustände existieren trotzdem und müssen mitgestaltet werden. Diese Seite zeigt sie alle nebeneinander, damit man in der Abstimmung darauf zeigen kann, ohne erst einen Filter zu suchen, der den passenden Fall herstellt."
|
||||
/>
|
||||
<Hinweis text="Diese Seite ist kein Produktfeature. Sie gehört weder zu Dashboard noch zu Analytics, steht in der Seitenleiste unter dem Trenner und wandert beim Einbau nicht mit. Für die Umsetzung selbst sind die Datei-Pfade unter jedem Abschnitt und docs/DESIGN-SYSTEM.md maßgeblich — beides steht im Repo und braucht diese Seite nicht." />
|
||||
<Hinweis text="Diese Seite ist kein Produktfeature. Sie gehört weder zu Dashboard noch zu Analytics, steht in der Seitenleiste unter dem Trenner und wandert beim Einbau nicht mit. Für die Umsetzung selbst sind die Datei-Pfade unter jedem Abschnitt und docs/DESIGN-SYSTEM.md maßgeblich. Beides steht im Repo und braucht diese Seite nicht." />
|
||||
|
||||
<ChartCard
|
||||
id="komp-farben"
|
||||
titel="Farbpalette"
|
||||
untertitel="IBM Carbon, kategoriale Palette (14 Farben) — Reihenfolge nie umsortieren"
|
||||
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">
|
||||
@@ -146,7 +146,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-area"
|
||||
titel="Flächendiagramm mit Vergleich"
|
||||
untertitel="components/charts/AreaVerlauf.tsx — für Zeitverläufe"
|
||||
untertitel="components/charts/AreaVerlauf.tsx: für Zeitverläufe"
|
||||
className="xl:col-span-2"
|
||||
legende={
|
||||
<ChartLegende
|
||||
@@ -169,7 +169,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-donut"
|
||||
titel="Ringdiagramm"
|
||||
untertitel="components/charts/Donut.tsx — für Anteile"
|
||||
untertitel="components/charts/Donut.tsx: für Anteile"
|
||||
>
|
||||
<Donut daten={BEISPIEL_KATEGORIEN} formatWert={formatEuroKompakt} hoehe={200} />
|
||||
</ChartCard>
|
||||
@@ -179,7 +179,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-balken"
|
||||
titel="Säulendiagramm"
|
||||
untertitel="components/charts/BalkenVertikal.tsx — Mengen mit Nulllinie"
|
||||
untertitel="components/charts/BalkenVertikal.tsx: Mengen mit Nulllinie"
|
||||
>
|
||||
<BalkenVertikal
|
||||
daten={BEISPIEL_REIHE.map((p) => ({ label: p.label, value: p.value / 1000 }))}
|
||||
@@ -192,7 +192,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-hbalken"
|
||||
titel="Balkendiagramm (liegend)"
|
||||
untertitel="components/charts/BalkenHorizontal.tsx — Rangfolgen"
|
||||
untertitel="components/charts/BalkenHorizontal.tsx: Rangfolgen"
|
||||
>
|
||||
<BalkenHorizontal
|
||||
daten={BEISPIEL_KATEGORIEN}
|
||||
@@ -204,7 +204,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-trichter"
|
||||
titel="Trichter"
|
||||
untertitel="components/charts/Trichter.tsx — Stufen mit Übergangsquote"
|
||||
untertitel="components/charts/Trichter.tsx: Stufen mit Übergangsquote"
|
||||
>
|
||||
<Trichter
|
||||
stufen={[
|
||||
@@ -223,7 +223,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-auslastung"
|
||||
titel="Auslastungsbalken"
|
||||
untertitel="components/charts/AuslastungsBalken.tsx — mit Ampel und 100-%-Marke"
|
||||
untertitel="components/charts/AuslastungsBalken.tsx: mit Ampel und 100-%-Marke"
|
||||
>
|
||||
<AuslastungsBalken
|
||||
zeilen={[
|
||||
@@ -238,7 +238,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-alerts"
|
||||
titel="Handlungsbedarf"
|
||||
untertitel="components/cards/AlertList.tsx — nach Dringlichkeit sortiert"
|
||||
untertitel="components/cards/AlertList.tsx: nach Dringlichkeit sortiert"
|
||||
>
|
||||
<AlertList
|
||||
eintraege={[
|
||||
@@ -273,7 +273,7 @@ export function KomponentenSeite() {
|
||||
<ChartCard
|
||||
id="komp-leer"
|
||||
titel="Leerzustand"
|
||||
untertitel="components/charts/ChartLeer.tsx — greift, wenn Filter alles wegschneiden"
|
||||
untertitel="components/charts/ChartLeer.tsx: greift, wenn Filter alles wegschneiden"
|
||||
>
|
||||
<ChartLeer hoehe={180} />
|
||||
</ChartCard>
|
||||
@@ -281,12 +281,12 @@ export function KomponentenSeite() {
|
||||
|
||||
<Abschnitt
|
||||
titel="Seitenzustände"
|
||||
text="features/dashboard/components/layout/SeitenZustand.tsx — die vier Zustände außer „fertig“. Sie sind in HiveGrid Pflicht (VMState: idle · loading · ready · empty · error) und im laufenden Prototyp nie zu sehen, weil die Demo-Daten immer liefern. Gestaltet werden müssen sie trotzdem: „lädt“, „nichts gefunden“ und „kaputt“ sind drei verschiedene Aussagen und dürfen nicht gleich aussehen."
|
||||
text="features/dashboard/components/layout/SeitenZustand.tsx: die vier Zustände außer „fertig“. Sie sind in HiveGrid Pflicht (VMState: idle · loading · ready · empty · error) und im laufenden Prototyp nie zu sehen, weil die Demo-Daten immer liefern. Gestaltet werden müssen sie trotzdem: „lädt“, „nichts gefunden“ und „kaputt“ sind drei verschiedene Aussagen und dürfen nicht gleich aussehen."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{([
|
||||
["Lädt", { status: "loading" } as VMState<unknown>],
|
||||
["Leer — Filter schneiden alles weg", { status: "empty" } as VMState<unknown>],
|
||||
["Leer: Filter schneiden alles weg", { status: "empty" } as VMState<unknown>],
|
||||
[
|
||||
"Fehler",
|
||||
{
|
||||
@@ -314,7 +314,7 @@ export function KomponentenSeite() {
|
||||
|
||||
<Abschnitt
|
||||
titel="Dashboard-Kacheln"
|
||||
text="features/dashboard/components/dashboard/ — das Dashboard benutzt nicht ChartCard, sondern einen eigenen Kachelrahmen. Gleiche Kante, gleiche Abstände, aber ohne das ⋯-Menü von Analytics; dafür mit Domänen-Icon, eigenem Zeitraum und Absprung. Der Grund steht in docs/SCOPE-ANALYTICS-DASHBOARD.md: In Analytics gilt ein Filter für alle Karten, im Dashboard trägt jede Kachel ihren eigenen."
|
||||
text="features/dashboard/components/dashboard/: das Dashboard benutzt nicht ChartCard, sondern einen eigenen Kachelrahmen. Gleiche Kante, gleiche Abstände, aber ohne das ⋯-Menü von Analytics; dafür mit Domänen-Icon, eigenem Zeitraum und Absprung. Der Grund steht in docs/SCOPE-ANALYTICS-DASHBOARD.md: In Analytics gilt ein Filter für alle Karten, im Dashboard trägt jede Kachel ihren eigenen."
|
||||
/>
|
||||
{/* Wie bei den Seitenzuständen: Die Begrüßung trägt ihren eigenen
|
||||
Seitenrand, weil sie im Dashboard direkt auf der Seite steht. In der
|
||||
@@ -351,16 +351,16 @@ export function KomponentenSeite() {
|
||||
</div>
|
||||
|
||||
<Abschnitt
|
||||
titel="Reservierte Kacheln — was noch ins Dashboard kommt"
|
||||
text="features/dashboard/components/dashboard/ReservierteKachel.tsx — das Dashboard zeigt heute nur Auswertungen. Diese drei Bereiche kommen dazu; alle drei stehen im Designer-Brief P11 und haben in HiveGrid bereits ihre Widget-Domäne (tasks, schedule, hr). Sie brauchen kein neues Raster, nur Daten."
|
||||
titel="Reservierte Kacheln: was noch ins Dashboard kommt"
|
||||
text="features/dashboard/components/dashboard/ReservierteKachel.tsx: das Dashboard zeigt heute nur Auswertungen. Diese drei Bereiche kommen dazu; alle drei stehen im Designer-Brief P11 und haben in HiveGrid bereits ihre Widget-Domäne (tasks, schedule, hr). Sie brauchen kein neues Raster, nur Daten."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{RESERVIERTE_BEREICHE.map((bereich) => (
|
||||
<ReservierteKachel key={bereich.key} bereich={bereich} />
|
||||
))}
|
||||
</div>
|
||||
<Hinweis text="Diese Kacheln stehen hier und nicht auf dem Dashboard. Ein Begrüßungsbildschirm ist kein Ort für Ankündigungen — wer sich morgens anmeldet, will seine Lage sehen und nicht lesen, was später einmal kommt. Für den Einbau ist die Stelle im Raster als Kommentar in DashboardSeite.tsx markiert; der Zuschnitt steht in docs/SCOPE-ANALYTICS-DASHBOARD.md, Abschnitt 3a." />
|
||||
<Hinweis text="Sie zeigen bewusst keine Zahl. „2 neue Anträge“ aus erfundenen Daten liest sich in einer Abnahme wie eine fertige Funktion — und irgendwann fragt jemand, warum die Zahl nie hochzählt. Die gestrichelte Kante sagt dasselbe ehrlicher: Hier kommt etwas hin, die Fläche ist dafür da."/>
|
||||
<Hinweis text="Diese Kacheln stehen hier und nicht auf dem Dashboard. Ein Begrüßungsbildschirm ist kein Ort für Ankündigungen. Wer sich morgens anmeldet, will seine Lage sehen und nicht lesen, was später einmal kommt. Für den Einbau ist die Stelle im Raster als Kommentar in DashboardSeite.tsx markiert; der Zuschnitt steht in docs/SCOPE-ANALYTICS-DASHBOARD.md, Abschnitt 3a." />
|
||||
<Hinweis text="Sie zeigen bewusst keine Zahl. „2 neue Anträge“ aus erfundenen Daten liest sich in einer Abnahme wie eine fertige Funktion, und irgendwann fragt jemand, warum die Zahl nie hochzählt. Die gestrichelte Kante sagt dasselbe ehrlicher: Hier kommt etwas hin, die Fläche ist dafür da."/>
|
||||
|
||||
<ChartCard
|
||||
id="komp-badges"
|
||||
@@ -405,13 +405,13 @@ export function KomponentenSeite() {
|
||||
{[
|
||||
"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: weiße Fläche #ffffff, Text #161616, Schatten statt Rahmen — seit Carbon v11 hell, nicht mehr dunkel.",
|
||||
"Ein Tooltip für alle Charts: weiße Fläche #ffffff, Text #161616, Schatten statt Rahmen. Seit Carbon v11 ist er hell, nicht mehr dunkel.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Karten einer Zeile nur strecken, wenn sie dieselbe Bauart haben. Ein Diagramm mit fester Höhe neben einer Balkenliste bleibt oben bündig — sonst wird aus einer Karte mit einem Balken eine 427 px hohe Fläche.",
|
||||
"Karten einer Zeile nur strecken, wenn sie dieselbe Bauart haben. Ein Diagramm mit fester Höhe neben einer Balkenliste bleibt oben bündig, sonst wird aus einer Karte mit einem Balken eine 427 px hohe Fläche.",
|
||||
].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" />
|
||||
|
||||
@@ -164,7 +164,7 @@ export function TeamSeite({ vm }: { vm: VMState<TeamDaten> }) {
|
||||
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."
|
||||
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={d.aktive.map((k) => ({
|
||||
|
||||
@@ -126,7 +126,7 @@ export function UebersichtSeite({ vm }: { vm: VMState<UebersichtDaten> }) {
|
||||
<ChartCard
|
||||
id="uebersicht-handlungsbedarf"
|
||||
titel="Handlungsbedarf"
|
||||
untertitel="Sortiert nach Dringlichkeit — jede Zeile führt in die zugehörige Arbeitsliste"
|
||||
untertitel="Sortiert nach Dringlichkeit, jede Zeile führt in die zugehörige Arbeitsliste"
|
||||
>
|
||||
<AlertList eintraege={d.alerts} spalten={2} />
|
||||
</ChartCard>
|
||||
@@ -171,7 +171,7 @@ export function UebersichtSeite({ vm }: { vm: VMState<UebersichtDaten> }) {
|
||||
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."
|
||||
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={d.gewerkUmsatz}
|
||||
@@ -218,7 +218,7 @@ export function UebersichtSeite({ vm }: { vm: VMState<UebersichtDaten> }) {
|
||||
spalten={auftragSpalten}
|
||||
proSeite={6}
|
||||
suchFelder={(a) => `${a.titel} ${a.nummer} ${a.gewerk}`}
|
||||
leerText="Kein Auftrag ist über Termin — sehr gut."
|
||||
leerText="Kein Auftrag ist über Termin: sehr gut."
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AuftraegeSeite } from "../pages/AuftraegeSeite";
|
||||
* }
|
||||
*
|
||||
* Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei
|
||||
* ersetzt, nicht angepasst — die Seite darunter bleibt unberührt.
|
||||
* ersetzt, nicht angepasst, die Seite darunter bleibt unberührt.
|
||||
*/
|
||||
export function AuftraegeRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("auftraege");
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DashboardSeite } from "../pages/DashboardSeite";
|
||||
*
|
||||
* `HEUTE` statt `new Date()`: Der Prototyp rechnet gegen einen festen
|
||||
* Stichtag, damit die Zahlen über Reloads und Screenshots hinweg gleich
|
||||
* bleiben — und damit Server- und Client-Rendering dieselbe Begrüßung
|
||||
* bleiben, und damit Server- und Client-Rendering dieselbe Begrüßung
|
||||
* ergeben. In HiveGrid Cloud steht an dieser Stelle die echte Uhr, und der
|
||||
* Benutzer kommt aus `requireMember`:
|
||||
*
|
||||
@@ -24,7 +24,7 @@ import { DashboardSeite } from "../pages/DashboardSeite";
|
||||
* return { vm: buildDashboardVM(await loadData(), member, new Date()) };
|
||||
* }
|
||||
*
|
||||
* Beim Einbau wird diese Datei ersetzt, nicht angepasst — die Seite darunter
|
||||
* Beim Einbau wird diese Datei ersetzt, nicht angepasst, die Seite darunter
|
||||
* bleibt unberührt.
|
||||
*/
|
||||
export function DashboardRoute() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { FinanzenSeite } from "../pages/FinanzenSeite";
|
||||
* }
|
||||
*
|
||||
* Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei
|
||||
* ersetzt, nicht angepasst — die Seite darunter bleibt unberührt.
|
||||
* ersetzt, nicht angepasst, die Seite darunter bleibt unberührt.
|
||||
*/
|
||||
export function FinanzenRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("finanzen");
|
||||
|
||||
@@ -18,7 +18,7 @@ import { KundenSeite } from "../pages/KundenSeite";
|
||||
* }
|
||||
*
|
||||
* Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei
|
||||
* ersetzt, nicht angepasst — die Seite darunter bleibt unberührt.
|
||||
* ersetzt, nicht angepasst, die Seite darunter bleibt unberührt.
|
||||
*/
|
||||
export function KundenRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("kunden");
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TeamSeite } from "../pages/TeamSeite";
|
||||
* }
|
||||
*
|
||||
* Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei
|
||||
* ersetzt, nicht angepasst — die Seite darunter bleibt unberührt.
|
||||
* ersetzt, nicht angepasst, die Seite darunter bleibt unberührt.
|
||||
*/
|
||||
export function TeamRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("team");
|
||||
|
||||
@@ -18,7 +18,7 @@ import { UebersichtSeite } from "../pages/UebersichtSeite";
|
||||
* }
|
||||
*
|
||||
* Hier steht statt des Loaders der Filter-Store. Beim Einbau wird diese Datei
|
||||
* ersetzt, nicht angepasst — die Seite darunter bleibt unberührt.
|
||||
* ersetzt, nicht angepasst, die Seite darunter bleibt unberührt.
|
||||
*/
|
||||
export function UebersichtRoute() {
|
||||
const { daten, filter, gesamt } = useAnalyseDaten("uebersicht");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Zentrales Chart-Theme — abgeleitet aus IBM Carbon Data Visualization.
|
||||
* Zentrales Chart-Theme: abgeleitet aus IBM Carbon Data Visualization.
|
||||
*
|
||||
* Die Werte stammen aus dem ausgelieferten Stylesheet von @carbon/charts
|
||||
* (dist/styles.css, Light Theme):
|
||||
@@ -52,7 +52,7 @@ export const STATUS_FARBEN = {
|
||||
/**
|
||||
* Feste Farbe je Gewerk.
|
||||
*
|
||||
* Eine Kategorie behält ihre Farbe über alle Charts und Seiten hinweg —
|
||||
* 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.
|
||||
*/
|
||||
@@ -118,15 +118,15 @@ export const STATUS_TEXT = {
|
||||
* 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
|
||||
"#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
|
||||
* 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 = {
|
||||
@@ -138,9 +138,9 @@ export const BADGE_TON = {
|
||||
negativ: "bg-[#fff1f1] text-[#a2191f]",
|
||||
} as const;
|
||||
|
||||
/** Einzelserie (Umsatzverlauf o. Ä.) — Carbon Cyan 50. */
|
||||
/** Einzelserie (Umsatzverlauf o. Ä.): Carbon Cyan 50. */
|
||||
export const PRIMAER_FARBE = "#1192e8";
|
||||
/** Vergleichsserie (Vorjahr) — bewusst neutral, damit sie zurücktritt. */
|
||||
/** Vergleichsserie (Vorjahr): bewusst neutral, damit sie zurücktritt. */
|
||||
export const VERGLEICH_FARBE = "#8d8d8d";
|
||||
|
||||
export const CHART = {
|
||||
@@ -150,7 +150,7 @@ export const CHART = {
|
||||
textSekundaer: "#525252",
|
||||
// Seit Carbon v11 ist der Tooltip hell: --cds-layer-02 auf --cds-text-primary,
|
||||
// getrennt vom Untergrund allein durch den Schatten. Bis v10 war er dunkel
|
||||
// (#393939) — diese Angabe steht in vielen älteren Quellen noch so.
|
||||
// (#393939): diese Angabe steht in vielen älteren Quellen noch so.
|
||||
tooltipBg: "#ffffff",
|
||||
tooltipFg: "#161616",
|
||||
} as const;
|
||||
@@ -193,7 +193,7 @@ 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€".
|
||||
// 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;
|
||||
@@ -216,7 +216,7 @@ export const DONUT = {
|
||||
|
||||
/**
|
||||
* Reduziert eine Kategorienliste auf MAX_KATEGORIEN und fasst den Rest
|
||||
* zu "Sonstige" zusammen — verhindert unlesbare Donuts mit 12 Segmenten.
|
||||
* zu "Sonstige" zusammen: verhindert unlesbare Donuts mit 12 Segmenten.
|
||||
*/
|
||||
export function gruppiereRest<T extends { name: string; value: number }>(
|
||||
daten: T[],
|
||||
@@ -235,7 +235,7 @@ export function gruppiereRest<T extends { name: string; value: number }>(
|
||||
/**
|
||||
* Rundes Achsenraster.
|
||||
*
|
||||
* Recharts leitet Ticks aus max/(n-1) ab und rundet den Rohschritt auf — bei
|
||||
* 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.
|
||||
@@ -283,15 +283,15 @@ export const MIN_TICK_ABSTAND = 12;
|
||||
*
|
||||
* **Viele Punkte.** Ueber MAX_ACHSENLABELS Werten wird fest jede n-te
|
||||
* Beschriftung gezeigt. Das ist eine Entscheidung ueber die Lesbarkeit der
|
||||
* Reihe und darf nicht von der Kartenbreite abhaengen — sonst zeigt dieselbe
|
||||
* Reihe und darf nicht von der Kartenbreite abhaengen, sonst zeigt dieselbe
|
||||
* Reihe auf zwei Bildschirmen verschiedene Stuetzstellen.
|
||||
*
|
||||
* **Wenige Punkte in einer schmalen Karte.** Zwoelf Monatsnamen brauchen rund
|
||||
* 340 px. Eine Drittelkarte hat bei 1400 px knapp 300 — dort standen "Aug",
|
||||
* 340 px. Eine Drittelkarte hat bei 1400 px knapp 300: dort standen "Aug",
|
||||
* "Sep" und "Okt" ohne Luecke aneinander. Diese Faelle haengen an der Breite
|
||||
* und nicht an der Datenmenge, deshalb entscheidet sie Recharts zur Laufzeit:
|
||||
* `preserveStartEnd` laesst weg, was nicht passt, und haelt dabei die erste
|
||||
* und letzte Beschriftung — ohne die waere der Zeitraum nicht mehr ablesbar.
|
||||
* und letzte Beschriftung, ohne die waere der Zeitraum nicht mehr ablesbar.
|
||||
*/
|
||||
export function achsenIntervall(anzahlPunkte: number): number | "preserveStartEnd" {
|
||||
return anzahlPunkte > MAX_ACHSENLABELS
|
||||
@@ -312,7 +312,7 @@ 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. */
|
||||
/** 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.
|
||||
|
||||
@@ -55,7 +55,7 @@ export function zeitfenster(preset: ZeitraumPreset): Zeitfenster {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gleich langes Fenster direkt davor — Basis fuer Trend-Vergleiche. */
|
||||
/** 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();
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEuroAchse(value: number): string {
|
||||
/**
|
||||
* Achsenformat, einmal aus dem Maximum gewaehlt.
|
||||
*
|
||||
* formatEuroAchse entscheidet die Einheit pro Wert — dadurch steht "0 €"
|
||||
* 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 {
|
||||
@@ -90,7 +90,7 @@ export function formatStunden(value: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ganze Tage mit korrektem Numerus — "1 Tag", "3 Tage".
|
||||
* 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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* View-Model der Auftragsseite.
|
||||
*
|
||||
* Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein
|
||||
* Reine Funktion über typisierten Eingangsdaten, kein React, kein Store, kein
|
||||
* Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der
|
||||
* Software ruft der Loader denselben Builder mit echten Daten auf, die
|
||||
* Screen-Komponente bleibt unverändert.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Das Dashboard ist **kein zweiter Analytics-Bereich**, sondern der
|
||||
* Begrüßungsbildschirm: „Wie ist meine Lage?" statt „Warum ist das so?".
|
||||
* Es zeigt heute ausschließlich Analytics-Kacheln, ist aber als Widget-Fläche
|
||||
* gebaut — andere Bereiche (Aufgaben, Termine, HR-Anträge) kommen später als
|
||||
* gebaut: andere Bereiche (Aufgaben, Termine, HR-Anträge) kommen später als
|
||||
* weitere Kacheln dazu, nicht als Umbau.
|
||||
*
|
||||
* Die Typen unten spiegeln `DashboardWidgetDomain`, `DashboardWidgetKind` und
|
||||
@@ -59,7 +59,7 @@ export interface KennzahlKachel {
|
||||
readonly domaene: WidgetDomaene;
|
||||
readonly titel: string;
|
||||
readonly wert: number;
|
||||
/** Wie der Wert zu schreiben ist — Formatierung ist Darstellung, nicht Modell. */
|
||||
/** Wie der Wert zu schreiben ist: Formatierung ist Darstellung, nicht Modell. */
|
||||
readonly formatAls: "euro" | "zahl" | "prozent";
|
||||
readonly zusatz: string;
|
||||
readonly zeitraum: WidgetZeitraum;
|
||||
@@ -89,7 +89,7 @@ export interface ReservierterBereich {
|
||||
readonly titel: string;
|
||||
readonly beschreibung: string;
|
||||
readonly icon: string;
|
||||
/** Woher die Daten kommen werden — als Verabredung, nicht als Versprechen. */
|
||||
/** Woher die Daten kommen werden, als Verabredung, nicht als Versprechen. */
|
||||
readonly quelle: string;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export interface DashboardDaten {
|
||||
readonly rolle: string;
|
||||
readonly datum: string;
|
||||
};
|
||||
/** Ein Satz zur Lage — was der Betrachter wissen muss, bevor er weiterliest. */
|
||||
/** Ein Satz zur Lage, was der Betrachter wissen muss, bevor er weiterliest. */
|
||||
readonly lage: string;
|
||||
readonly kennzahlen: KennzahlKachel[];
|
||||
readonly handlungsbedarf: AlertEintrag[];
|
||||
@@ -109,14 +109,14 @@ export interface DashboardDaten {
|
||||
readonly umsatzVorher: number;
|
||||
readonly gewerkUmsatz: ChartPunkt[];
|
||||
readonly trichter: ChartPunkt[];
|
||||
/** Sparkline unter der Umsatzkachel — dieselbe Reihe wie in Analytics. */
|
||||
/** Sparkline unter der Umsatzkachel: dieselbe Reihe wie in Analytics. */
|
||||
readonly sparkline: number[];
|
||||
readonly auslastung: { name: string; wert: number; zusatz: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tageszeit-Anrede. Die Uhrzeit kommt von außen, damit diese Funktion rein
|
||||
* bleibt — der Prototyp reicht seinen festen Stichtag herein, die Software
|
||||
* bleibt, der Prototyp reicht seinen festen Stichtag herein, die Software
|
||||
* die echte Uhr.
|
||||
*/
|
||||
function anredeFuer(stunde: number): string {
|
||||
@@ -125,7 +125,7 @@ function anredeFuer(stunde: number): string {
|
||||
return "Guten Abend";
|
||||
}
|
||||
|
||||
/** Der angemeldete Benutzer — in der Software aus der Sitzung, hier eine Attrappe. */
|
||||
/** Der angemeldete Benutzer, in der Software aus der Sitzung, hier eine Attrappe. */
|
||||
export interface Nutzer {
|
||||
readonly vorname: string;
|
||||
readonly rolle: string;
|
||||
@@ -151,7 +151,7 @@ export function baueDashboardVM(
|
||||
const alerts = handlungsbedarf(daten);
|
||||
|
||||
// Die Anwendung duzt. Das ist eine Festlegung fuer den ganzen Prototyp und
|
||||
// nicht nur fuer diesen Satz — halb geduzt liest sich schlechter als
|
||||
// nicht nur fuer diesen Satz: halb geduzt liest sich schlechter als
|
||||
// durchgehend gesiezt.
|
||||
const kritisch = alerts.filter((a) => a.art === "kritisch").length;
|
||||
const lage = kritisch
|
||||
@@ -228,7 +228,7 @@ export function baueDashboardVM(
|
||||
sparkline: verlauf.slice(-10).map((p) => p.value),
|
||||
// Sechs Zeilen: mehr passt nicht in eine halbe Kachel, ohne dass die
|
||||
// Balken auf Haarbreite schrumpfen. Die vollständige Liste steht in
|
||||
// Analytics — deshalb führt die Kachel dorthin.
|
||||
// Analytics, deshalb führt die Kachel dorthin.
|
||||
auslastung: monteure.slice(0, 6).map((t) => ({
|
||||
name: t.mitarbeiter.name,
|
||||
wert: t.auslastungProzent,
|
||||
@@ -243,7 +243,7 @@ export function baueDashboardVM(
|
||||
* Alle drei sind im Designer-Brief P11 für die Rollen-Dashboards benannt
|
||||
* („Büro: … meine Tasks, Anrufe heute, HR-Anträge") und haben in HiveGrid
|
||||
* bereits ihre Widget-Domäne. Sie stehen hier, damit beim Einbau feststeht,
|
||||
* was noch dazukommt und wo — und damit niemand das Raster dafür umbaut.
|
||||
* was noch dazukommt und wo, und damit niemand das Raster dafür umbaut.
|
||||
*
|
||||
* Gezeigt werden sie in der Bausteingalerie unter `/komponenten`.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Auswahlmöglichkeiten der Filterleiste.
|
||||
*
|
||||
* Reine Funktion über dem vollständigen Datensatz — bewusst **nicht** über den
|
||||
* Reine Funktion über dem vollständigen Datensatz: bewusst **nicht** über den
|
||||
* gefilterten. Sonst verschwindet eine Option, sobald man sie abwählt, und der
|
||||
* Filter lässt sich nicht mehr zurücknehmen.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* View-Model der Finanzseite.
|
||||
*
|
||||
* Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein
|
||||
* Reine Funktion über typisierten Eingangsdaten, kein React, kein Store, kein
|
||||
* Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der
|
||||
* Software ruft der Loader denselben Builder mit echten Daten auf, die
|
||||
* Screen-Komponente bleibt unverändert.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*
|
||||
* Wortgleicher Spiegel von `VMState` aus `@hivegrid/view-models` in HiveGrid
|
||||
* Cloud (`packages/view-models/src/index.ts`). Beim Einbau wird dieser Import
|
||||
* gegen das echte Paket getauscht — die Seiten selbst ändern sich dabei nicht.
|
||||
* gegen das echte Paket getauscht, die Seiten selbst ändern sich dabei nicht.
|
||||
*
|
||||
* Warum das hier überhaupt steht: Die Cloud trennt strikt zwischen Route und
|
||||
* Screen. Die Route besitzt Loader, Actions und Realtime; die Screen-Komponente
|
||||
* ist rein und bekommt alles als Prop (`<XPage vm={} actions={} />`). Eine
|
||||
* Komponente, die sich ihre Daten selbst aus einem Store holt, muss beim Einbau
|
||||
* auseinandergenommen werden — genau das soll dieser Prototyp ersparen.
|
||||
* auseinandergenommen werden, genau das soll dieser Prototyp ersparen.
|
||||
*/
|
||||
|
||||
export type VMStatus = "idle" | "loading" | "ready" | "empty" | "error";
|
||||
@@ -26,7 +26,7 @@ export function bereit<T>(data: T): VMState<T> {
|
||||
return { status: "ready", data };
|
||||
}
|
||||
|
||||
/** Kein Datensatz für die aktuelle Auswahl — nicht dasselbe wie ein Fehler. */
|
||||
/** Kein Datensatz für die aktuelle Auswahl, nicht dasselbe wie ein Fehler. */
|
||||
export function leer<T>(): VMState<T> {
|
||||
return { status: "empty" };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* View-Model der Kundenseite.
|
||||
*
|
||||
* Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein
|
||||
* Reine Funktion über typisierten Eingangsdaten, kein React, kein Store, kein
|
||||
* Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der
|
||||
* Software ruft der Loader denselben Builder mit echten Daten auf, die
|
||||
* Screen-Komponente bleibt unverändert.
|
||||
@@ -52,7 +52,7 @@ export function baueKundenVM(daten: Datensatz, filter: FilterState): VMState<Kun
|
||||
entwicklung,
|
||||
sparkline: entwicklung.slice(-10).map((p) => p.value),
|
||||
wiederkehrendProzent: wiederkehrquote(daten.kunden),
|
||||
// Bezugsgrösse ist der Kunde mit Historie — Karteileichen würden den
|
||||
// Bezugsgrösse ist der Kunde mit Historie: Karteileichen würden den
|
||||
// Durchschnitt nach unten ziehen.
|
||||
kundenwert: mitAuftrag.length
|
||||
? mitAuftrag.reduce((s, k) => s + k.umsatzGesamt, 0) / mitAuftrag.length
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* View-Model der Teamseite.
|
||||
*
|
||||
* Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein
|
||||
* Reine Funktion über typisierten Eingangsdaten, kein React, kein Store, kein
|
||||
* Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der
|
||||
* Software ruft der Loader denselben Builder mit echten Daten auf, die
|
||||
* Screen-Komponente bleibt unverändert.
|
||||
@@ -16,7 +16,7 @@ import { bereit, leer, type VMState } from "./index";
|
||||
* Tabellenzeile der Mitarbeiterübersicht.
|
||||
*
|
||||
* Die `id` hängt hier und nicht in der Seite, weil die Tabelle einen stabilen
|
||||
* Schlüssel braucht — das ist eine Eigenschaft des Datensatzes, keine der
|
||||
* Schlüssel braucht, das ist eine Eigenschaft des Datensatzes, keine der
|
||||
* Darstellung.
|
||||
*/
|
||||
export type TeamZeile = MitarbeiterKennzahl & { readonly id: string };
|
||||
@@ -33,7 +33,7 @@ export interface TeamDaten {
|
||||
readonly zeilen: TeamZeile[];
|
||||
}
|
||||
|
||||
/** Anzeigenamen der Abwesenheitsgründe — die Rohwerte sind Kleinschreibung. */
|
||||
/** Anzeigenamen der Abwesenheitsgründe, die Rohwerte sind Kleinschreibung. */
|
||||
const ABWESENHEIT_LABEL: Record<string, string> = {
|
||||
urlaub: "Urlaub",
|
||||
krank: "Krank",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* View-Model der Übersichtsseite.
|
||||
*
|
||||
* Reine Funktion über typisierten Eingangsdaten — kein React, kein Store, kein
|
||||
* Reine Funktion über typisierten Eingangsdaten, kein React, kein Store, kein
|
||||
* Zugriff auf `DATENSATZ`. Genau diese Grenze macht den Einbau billig: In der
|
||||
* Software ruft der Loader denselben Builder mit echten Daten auf, die
|
||||
* Screen-Komponente bleibt unverändert.
|
||||
|
||||
Reference in new issue
Block a user