Zwei Fehler aus dem Sparring, beide an der Diagrammkarte. **Das Optionsmenue war abgeschnitten.** Die Karte trug `overflow-hidden`, das Menue klappt aus dem Kopf heraus, und der zweite Eintrag war nur zur Haelfte zu sehen. Das Beschneiden ist entfallen; die runden Ecken brauchen es nicht, weil Kopf und Koerper 16 px Innenabstand haben und nichts an der Kante anliegt. **Nach dem Ausblenden blieb eine Luecke.** Die Zeilen waren ein Raster mit festen Spalten. Blendet man eine Karte aus, gibt ChartCard `null` zurueck, die Spalte bleibt aber stehen und die Nachbarkarte behaelt ihre zwei Drittel. Neu ist `ChartReihe` in PageContainer.tsx: eine Flexzeile statt eines Rasters. Die verbliebenen Karten verteilen den Platz unter sich neu, ohne dass jemand mitzaehlen muesste, wie viele gerade sichtbar sind. Nachgemessen auf /analytics: Der Zahlungsverlauf geht von 574 px auf 1164 px, sobald die Nachbarkachel verschwindet. Zehn Zeilen auf allen fuenf Analyse-Seiten umgestellt. `gleichHoch` haelt die Unterscheidung aus dem letzten Sparring fest: gestreckt nur, wo die Karten dieselbe Bauart haben. Beim Umbau eine Regression gebaut und durch Nachmessen gefunden: Aus 885/443 wurde 664/664. `[&>*]:flex-1` erzeugt den Selektor `.zeile > *`, und der ist spezifischer als eine Klasse am Kind, also verlor `xl:flex-[2]`. Jetzt `xl:flex-[2]!`, mit dem Grund als Kommentar an der Komponente. Nachgemessen nach der Aenderung, alle Zeilen: 885/443 wo zwei zu eins gewollt ist, 389/389 und 409/409 und 595/595 wo gleiche Hoehe gewollt ist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
257 lines
8.3 KiB
TypeScript
257 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import React from "react";
|
||
import { ChartReihe, PageContainer, Raster } from "../components/layout/PageContainer";
|
||
import { KPICard } from "../components/cards/KPICard";
|
||
import { ChartCard } from "../components/cards/ChartCard";
|
||
import { StatZeile } from "../components/cards/StatZeile";
|
||
import { AreaVerlauf } from "../components/charts/AreaVerlauf";
|
||
import { BalkenVertikal } from "../components/charts/BalkenVertikal";
|
||
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
|
||
import { Donut } from "../components/charts/Donut";
|
||
import { ChartLegende } from "../components/charts/ChartLegende";
|
||
import { DataTable, type Spalte } from "../components/tables/DataTable";
|
||
import { RechnungBadge } from "../components/tables/StatusBadge";
|
||
import {
|
||
berechneTrend,
|
||
formatDatum,
|
||
formatEuro,
|
||
formatEuroAchse,
|
||
formatEuroKompakt,
|
||
formatProzent,
|
||
formatTage,
|
||
tageSeit,
|
||
tageLabel,
|
||
} from "../utils/formatters";
|
||
import { HEUTE } from "../utils/dateUtils";
|
||
import {
|
||
PRIMAER_FARBE,
|
||
RISIKO_RAMPE,
|
||
STATUS_TEXT,
|
||
VERGLEICH_FARBE,
|
||
} from "../utils/chartTheme";
|
||
import type { Rechnung } from "../data/types";
|
||
import { SeitenZustand } from "../components/layout/SeitenZustand";
|
||
import type { VMState } from "../view-models";
|
||
import type { FinanzenDaten } from "../view-models/finanzen.vm";
|
||
|
||
/** Ampelfarben für die Altersstruktur: je älter die Forderung, desto kritischer. */
|
||
const ALTER_FARBEN = [...RISIKO_RAMPE];
|
||
|
||
export function FinanzenSeite({ vm }: { vm: VMState<FinanzenDaten> }) {
|
||
if (vm.status !== "ready") {
|
||
return <SeitenZustand vm={vm} />;
|
||
}
|
||
const d = vm.data;
|
||
|
||
const spalten: Spalte<Rechnung>[] = [
|
||
{
|
||
key: "nummer",
|
||
titel: "Rechnung",
|
||
zelle: (r) => (
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{r.nummer}</span>
|
||
<span className="text-[11px] text-text-neutral-tertiary">
|
||
{d.kundeName.get(r.kundeId) ?? "Unbekannt"}
|
||
</span>
|
||
</div>
|
||
),
|
||
sortWert: (r) => r.nummer,
|
||
breite: "30%",
|
||
},
|
||
{ key: "status", titel: "Status", zelle: (r) => <RechnungBadge status={r.status} /> },
|
||
{
|
||
key: "gestellt",
|
||
titel: "Gestellt",
|
||
zelle: (r) => formatDatum(r.gestelltAm),
|
||
sortWert: (r) => r.gestelltAm,
|
||
},
|
||
{
|
||
key: "faellig",
|
||
titel: "Überfällig",
|
||
numerisch: true,
|
||
zelle: (r) => {
|
||
const tage = tageSeit(r.faelligAm, HEUTE);
|
||
return tage > 0 ? (
|
||
<span
|
||
className={tage > 30 ? "font-medium" : ""}
|
||
style={{ color: tage > 30 ? STATUS_TEXT.negativ : STATUS_TEXT.warnung }}
|
||
>
|
||
{tageLabel(tage)}
|
||
</span>
|
||
) : (
|
||
<span className="text-text-neutral-tertiary">
|
||
in {Math.abs(tage) === 1 ? "1 Tag" : `${Math.abs(tage)} Tagen`}
|
||
</span>
|
||
);
|
||
},
|
||
sortWert: (r) => tageSeit(r.faelligAm, HEUTE),
|
||
},
|
||
{
|
||
key: "betrag",
|
||
titel: "Betrag",
|
||
numerisch: true,
|
||
zelle: (r) => <span className="font-medium">{formatEuro(r.betrag)}</span>,
|
||
sortWert: (r) => r.betrag,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
|
||
<PageContainer>
|
||
<Raster spalten={5}>
|
||
<KPICard
|
||
label="Umsatz (bezahlt)"
|
||
wert={formatEuro(d.umsatzAktuell)}
|
||
zusatz="Zahlungseingang im Zeitraum"
|
||
trend={berechneTrend(d.umsatzAktuell, d.umsatzVorher)}
|
||
icon="Euro"
|
||
verlauf={d.sparkline}
|
||
/>
|
||
<KPICard
|
||
label="Offene Forderungen"
|
||
wert={formatEuro(d.posten.gesamt)}
|
||
zusatz={`${d.posten.anzahl} Rechnungen`}
|
||
icon="FileText"
|
||
bezug="stichtag"
|
||
/>
|
||
<KPICard
|
||
label="Überfällig"
|
||
wert={formatEuro(d.posten.ueberfaelligBetrag)}
|
||
zusatz={`${d.posten.ueberfaelligAnzahl} Rechnungen im Mahnlauf`}
|
||
icon="AlertTriangle"
|
||
bezug="stichtag"
|
||
akzent={d.posten.ueberfaelligBetrag > 0 ? "negativ" : "positiv"}
|
||
/>
|
||
<KPICard
|
||
label="Ø Zahlungsdauer"
|
||
wert={formatTage(d.zahlungsdauerTage)}
|
||
zusatz="Rechnung bis Zahlungseingang"
|
||
icon="Clock"
|
||
akzent={d.zahlungsdauerTage > 30 ? "warnung" : "positiv"}
|
||
richtungGut="negativ"
|
||
/>
|
||
<KPICard
|
||
label="Deckungsbeitrag"
|
||
wert={formatProzent(d.margeProzent)}
|
||
zusatz="Gewichtet über Abschlüsse"
|
||
icon="Percent"
|
||
/>
|
||
</Raster>
|
||
|
||
<ChartReihe umbruch="xl" gleichHoch>
|
||
<ChartCard
|
||
id="finanzen-verlauf"
|
||
titel="Zahlungseingang im Zeitverlauf"
|
||
untertitel="Aktueller Zeitraum gegen Vorjahr"
|
||
hinweis="Grundlage sind bezahlte Ausgangsrechnungen zum Zahlungsdatum."
|
||
className="xl:flex-[2]!"
|
||
legende={
|
||
<ChartLegende
|
||
eintraege={[
|
||
{ name: "Aktueller Zeitraum", farbe: PRIMAER_FARBE },
|
||
{ name: "Vorjahr", farbe: VERGLEICH_FARBE },
|
||
]}
|
||
/>
|
||
}
|
||
>
|
||
<AreaVerlauf
|
||
daten={d.verlauf}
|
||
formatWert={formatEuroKompakt}
|
||
formatAchse={formatEuroAchse}
|
||
zeigeVergleich
|
||
hoehe={250}
|
||
/>
|
||
<StatZeile
|
||
spalten={2}
|
||
eintraege={[
|
||
{ label: "Vorperiode", wert: formatEuro(d.umsatzVorher) },
|
||
{
|
||
label: "Veränderung",
|
||
wert: `${d.umsatzAktuell >= d.umsatzVorher ? "+" : "−"}${formatEuro(Math.abs(d.umsatzAktuell - d.umsatzVorher))}`,
|
||
},
|
||
]}
|
||
/>
|
||
</ChartCard>
|
||
|
||
<ChartCard
|
||
id="finanzen-status"
|
||
titel="Offener Bestand nach Status"
|
||
untertitel="Wie viel des offenen Bestands bereits überfällig ist"
|
||
hinweis="Bezugsgröße ist ausschließlich der offene Bestand zum Stichtag. Bezahlte Rechnungen sind nicht enthalten, sie würden über 90 % des Rings belegen und die Aussage erdrücken."
|
||
>
|
||
<Donut
|
||
daten={d.offenNachStatus}
|
||
formatWert={formatEuroKompakt}
|
||
hoehe={200}
|
||
mitteLabel="Offen"
|
||
formatMitte={formatEuro}
|
||
/>
|
||
</ChartCard>
|
||
</ChartReihe>
|
||
|
||
<ChartReihe umbruch="lg">
|
||
<ChartCard
|
||
className="lg:flex-[2]!"
|
||
id="finanzen-alter"
|
||
titel="Offene Forderungen nach Alter"
|
||
untertitel={`Gesamt ${formatEuro(d.posten.gesamt)}`}
|
||
hinweis="Alter ab Fälligkeitsdatum. Ab 60 Tagen sinkt die Einbringungsquote erfahrungsgemäß deutlich."
|
||
>
|
||
<BalkenVertikal
|
||
daten={d.posten.nachAlter}
|
||
formatWert={formatEuroKompakt}
|
||
formatAchse={formatEuroAchse}
|
||
hoehe={240}
|
||
farbenProBalken={ALTER_FARBEN}
|
||
/>
|
||
<StatZeile
|
||
spalten={2}
|
||
eintraege={[
|
||
{
|
||
label: "Älter als 90 Tage",
|
||
wert: formatEuro(
|
||
d.posten.nachAlter.find((b) => b.name === "> 90 Tage")?.value ?? 0
|
||
),
|
||
hinweis: "Abschreibungsrisiko",
|
||
},
|
||
{ label: "Überfällig gesamt", wert: formatEuro(d.posten.ueberfaelligBetrag) },
|
||
]}
|
||
/>
|
||
</ChartCard>
|
||
|
||
<ChartCard
|
||
id="finanzen-gewerke"
|
||
titel="Umsatz nach Gewerk"
|
||
untertitel="Zahlungseingang im Zeitraum"
|
||
>
|
||
<BalkenHorizontal
|
||
daten={d.gewerkUmsatz}
|
||
formatWert={formatEuroKompakt}
|
||
einfarbig={false}
|
||
maxBalken={5}
|
||
/>
|
||
</ChartCard>
|
||
|
||
</ChartReihe>
|
||
|
||
<ChartCard
|
||
id="finanzen-offene-liste"
|
||
titel={`Offene Rechnungen (${d.posten.anzahl})`}
|
||
untertitel="Sortiert nach Fälligkeit, die Arbeitsliste für den Mahnlauf"
|
||
>
|
||
<DataTable
|
||
zeilen={d.posten.liste}
|
||
spalten={spalten}
|
||
proSeite={8}
|
||
suchFelder={(r) => `${r.nummer} ${d.kundeName.get(r.kundeId) ?? ""}`}
|
||
leerText="Keine offenen Rechnungen"
|
||
/>
|
||
</ChartCard>
|
||
|
||
</PageContainer>
|
||
</>
|
||
);
|
||
}
|