Files
Dashboard/features/dashboard/pages/KundenSeite.tsx
T
dejanandClaude Opus 5 ffbe70aede Zeilen nur strecken, wenn die Karten dieselbe Bauart haben
Rueckbau des letzten Commits, soweit er zu weit ging. Er hat alle
Diagrammzeilen gestreckt; richtig ist das nur dort, wo die Karten von Natur
aus aehnlich hoch sind.

Gemessen bei 1680 px, Hoehenunterschied innerhalb einer Zeile:

  Trichter | Auslastung          (Liste | Liste)        0 px /   0 px
  Zahlungseingang | Bestand      (Chart | Chart)        0 px /   0 px
  Zahlungseingang | Gewerk       (Chart | Liste)      147 px / 315 px
  Vorgaenge Status | Gewerk      (Chart | Liste)      190 px / 358 px
                                            (volle Daten / duenne Daten)

Der Grund steckt in der Liste: Eine Balkenliste hat keine Mindesthoehe. Fuenf
Kategorien ergeben 280 px, eine einzige 112 px — daneben liegt ein
Zeitverlauf unveraendert bei 427 px, weil seine Diagrammflaeche fest ist.
Diese Zeile zu strecken heisst, eine Karte mit einem Balken auf 427 px
aufzublasen. Das ist schlimmer als die Luecke, die es vermeiden soll:
Weissraum auf der Seite liest sich als "hier ist gerade wenig", eine riesige
Karte mit einem Balken darin als Fehler.

items-start kehrt deshalb auf allen Zeilen zurueck, in denen ein Diagramm mit
fester Hoehe neben einer Liste steht. Gestreckt bleibt, wo beide Karten
dieselbe Bauart haben — Trichter neben Auslastung.

Das senkrechte Zentrieren im Kartenkoerper bleibt: Auf oben buendigen Zeilen
ist es wirkungslos, auf den gestreckten verteilt es den Ueberschuss oben und
unten statt ihn unten abzuladen. Das war die Verbesserung am Trichter.

Auf dem Dashboard bleibt die Zeile aus Zeitverlauf und Balkenliste gestreckt.
Derselbe Massstab, anderes Ergebnis: Dort gibt es keinen Filter, die Liste hat
immer ihre fuenf Gewerke, und es bleiben rund 90 px. Der Filter ist der
Unterschied, nicht die Kachel.

DESIGN-SYSTEM.md traegt jetzt die Regel mit den Messwerten statt der zu
weit gefassten Fassung von vorhin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 17:48:05 +02:00

215 lines
6.7 KiB
TypeScript

"use client";
import React from "react";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import {
berechneTrend,
formatDatum,
formatEuro,
formatEuroKompakt,
formatProzent,
formatZahl,
tageLabel,
} from "../utils/formatters";
import { STATUS_TEXT } from "../utils/chartTheme";
import type { Kunde } from "../data/types";
import { SeitenZustand } from "../components/layout/SeitenZustand";
import type { VMState } from "../view-models";
import type { KundenDaten } from "../view-models/kunden.vm";
export function KundenSeite({ vm }: { vm: VMState<KundenDaten> }) {
if (vm.status !== "ready") {
return <SeitenZustand vm={vm} />;
}
const d = vm.data;
const spalten: Spalte<Kunde>[] = [
{
key: "name",
titel: "Kunde",
breite: "26%",
zelle: (k) => (
<div className="flex flex-col">
<span className="font-medium">{k.name}</span>
<span className="text-[11px] text-text-neutral-tertiary">{k.typ}</span>
</div>
),
sortWert: (k) => k.name,
},
{
key: "quelle",
titel: "Quelle",
zelle: (k) => <span className="text-text-neutral-secondary">{k.quelle}</span>,
sortWert: (k) => k.quelle,
},
{
key: "auftraege",
titel: "Aufträge",
numerisch: true,
zelle: (k) => formatZahl(k.auftraegeGesamt),
sortWert: (k) => k.auftraegeGesamt,
},
{
key: "umsatz",
titel: "Umsatz",
numerisch: true,
zelle: (k) => <span className="font-medium">{formatEuro(k.umsatzGesamt)}</span>,
sortWert: (k) => k.umsatzGesamt,
},
{
key: "zahlung",
titel: "Ø Zahlung",
numerisch: true,
zelle: (k) => (
<span
className={k.zahlungsmoralTage > 30 ? "font-medium" : ""}
style={k.zahlungsmoralTage > 30 ? { color: STATUS_TEXT.warnung } : undefined}
>
{tageLabel(k.zahlungsmoralTage)}
</span>
),
sortWert: (k) => k.zahlungsmoralTage,
},
{
key: "letzter",
titel: "Letzter Auftrag",
zelle: (k) =>
k.letzterAuftragAm ? (
formatDatum(k.letzterAuftragAm)
) : (
<span className="text-text-neutral-subtle">—</span>
),
sortWert: (k) => k.letzterAuftragAm ?? "",
},
];
return (
<>
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Kunden gesamt"
wert={formatZahl(d.kundenGesamt)}
zusatz={`${formatZahl(d.kundenMitAuftrag)} mit mindestens einem Auftrag`}
icon="Users"
bezug="stichtag"
/>
<KPICard
label="Neue Kunden"
wert={formatZahl(d.neueKunden)}
zusatz="Nach Anlagedatum gezählt"
trend={berechneTrend(d.neueKunden, d.neueKundenVorher)}
icon="UserPlus"
verlauf={d.sparkline}
/>
<KPICard
label="Wiederkehrende Kunden"
wert={formatProzent(d.wiederkehrendProzent, 0)}
zusatz="Mehr als ein Auftrag"
icon="Repeat"
bezug="stichtag"
/>
<KPICard
label="Ø Kundenwert"
wert={formatEuro(d.kundenwert)}
zusatz="Je Kunde mit mindestens einem Auftrag"
icon="CircleDollarSign"
bezug="gesamt"
/>
<KPICard
label="Reaktivierbar"
wert={formatZahl(d.inaktive.length)}
zusatz="Über 12 Monate ohne Auftrag"
icon="Clock"
bezug="stichtag"
akzent={d.inaktive.length > 0 ? "warnung" : "neutral"}
/>
</Raster>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-3">
<ChartCard
id="kunden-entwicklung"
titel="Neukunden im Zeitverlauf"
untertitel="Anlagedatum des Kundendatensatzes"
className="xl:col-span-2"
>
<BalkenVertikal daten={d.entwicklung} formatWert={(w) => formatZahl(w)} hoehe={250} nurGanzzahlen />
</ChartCard>
<ChartCard
id="kunden-quelle"
titel="Kunden nach Quelle"
untertitel="Wie der Erstkontakt zustande kam"
hinweis="Empfehlungen und Bestandskunden zusammen zeigen, wie stark der Betrieb ohne Werbebudget wächst."
>
<Donut
daten={d.nachQuelle}
formatWert={(w) => formatZahl(w)}
hoehe={220}
mitteLabel="Kunden"
/>
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard id="kunden-typ" titel="Kundenstruktur" untertitel="Privat, Gewerbe, Verwaltung">
<Donut
daten={d.nachTyp}
formatWert={(w) => formatZahl(w)}
hoehe={200}
mitteLabel="Kunden"
/>
</ChartCard>
<ChartCard
id="kunden-top"
titel="Top-Kunden nach Umsatz"
untertitel="Bezahlter Gesamtumsatz"
>
<BalkenHorizontal daten={d.topKunden} formatWert={formatEuroKompakt} />
</ChartCard>
</div>
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
<ChartCard
id="kunden-inaktiv"
titel={`Reaktivierungspotenzial (${d.inaktive.length})`}
untertitel="Kunden mit Historie, aber über 12 Monate ohne Auftrag"
>
<DataTable
zeilen={d.inaktive}
spalten={spalten.filter((s) => s.key !== "zahlung")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort} ${k.quelle}`}
leerText="Alle Kunden waren zuletzt aktiv"
/>
</ChartCard>
<ChartCard
id="kunden-zahlung"
titel={`Auffällige Zahlungsmoral (${d.auffaellige.length})`}
untertitel="Durchschnittlich über 30 Tage bis zum Zahlungseingang"
>
<DataTable
zeilen={d.auffaellige}
spalten={spalten.filter((s) => s.key !== "letzter")}
proSeite={6}
suchFelder={(k) => `${k.name} ${k.ort}`}
leerText="Keine Auffälligkeiten"
/>
</ChartCard>
</div>
</PageContainer>
</>
);
}