feat(reports): Auswertungen, Kalender, Budgets und Export

Backend:
- Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und
  Ist bleiben dabei getrennt
- Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich,
  Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard
- Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen
  Unterkategorien ein
- Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit
  typisierten Beträgen

Frontend:
- Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel-
  und Auswertungsseite
- Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit
  geprüft; Grün/Rot als Serienpaar verworfen
- Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf
  zwei Größenachsen
- Recharts in einen eigenen Chunk ausgelagert

27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
@@ -0,0 +1,77 @@
/** Tests der Budget-Ampel. */
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import type { BudgetStatus } from "@/types/api";
function budget(overrides: Partial<BudgetStatus> = {}): BudgetStatus {
return {
category_id: 11,
category_name: "Lebensmittel",
color: "#84cc16",
period_month: "2026-03-01",
limit_amount: "400.00",
carried_over: "0.00",
available: "400.00",
spent: "100.00",
remaining: "300.00",
ratio: 0.25,
state: "ok",
rollover: false,
is_template: false,
...overrides,
};
}
function rendern(status: BudgetStatus) {
return render(
<ul>
<BudgetMeter budget={status} />
</ul>,
);
}
describe("BudgetMeter", () => {
it("zeigt Verbrauch, Rest und Prozentwert", () => {
rendern(budget());
expect(screen.getByText("Lebensmittel")).toBeInTheDocument();
expect(screen.getByText(/100,00/)).toBeInTheDocument();
expect(screen.getByText(/400,00/)).toBeInTheDocument();
expect(screen.getByText(/300,00.*übrig/)).toBeInTheDocument();
});
it("trägt den Zustand nicht allein über die Farbe", () => {
rendern(budget({ ratio: 0.9, state: "warning", spent: "360.00", remaining: "40.00" }));
// Prozentwert und Wortlaut stehen neben dem Symbol.
expect(screen.getByText(/90 %/)).toBeInTheDocument();
expect(screen.getByText(/fast ausgeschöpft/)).toBeInTheDocument();
});
it("meldet eine Überschreitung mit dem Fehlbetrag", () => {
rendern(
budget({ ratio: 1.15, state: "exceeded", spent: "460.00", remaining: "-60.00" }),
);
expect(screen.getByText(/überschritten/)).toBeInTheDocument();
expect(screen.getByText(/60,00.*zu viel/)).toBeInTheDocument();
});
it("stellt den Fortschritt auch für Screenreader bereit", () => {
rendern(budget({ ratio: 0.25 }));
const messgeraet = screen.getByRole("meter");
expect(messgeraet).toHaveAttribute("aria-valuenow", "25");
expect(messgeraet).toHaveAccessibleName(/Lebensmittel: 25 Prozent verbraucht/);
});
it("nennt Übertrag und Herkunft aus einer Vorlage", () => {
rendern(budget({ carried_over: "50.00", available: "450.00", is_template: true }));
expect(screen.getByText(/Übertrag aus Vormonaten: 50,00/)).toBeInTheDocument();
expect(screen.getByText(/Aus einer Vorlage/)).toBeInTheDocument();
});
});
@@ -0,0 +1,92 @@
/**
* Budget-Ampel.
*
* Der Zustand wird nie allein über die Farbe getragen: jede Zeile führt ein
* Symbol, einen Wortlaut und den Prozentwert mit.
*/
import { AlertTriangle, CheckCircle2, CircleAlert } from "lucide-react";
import { STATUS } from "@/components/charts/chartTheme";
import { formatMoney, toNumber } from "@/lib/format";
import { cn } from "@/lib/cn";
import type { BudgetState, BudgetStatus } from "@/types/api";
const ZUSTAND: Record<
BudgetState,
{ label: string; color: string; icon: typeof CheckCircle2; klasse: string }
> = {
ok: { label: "im Rahmen", color: STATUS.good, icon: CheckCircle2, klasse: "text-positive" },
warning: {
label: "fast ausgeschöpft",
color: STATUS.warning,
icon: AlertTriangle,
klasse: "text-warning",
},
exceeded: {
label: "überschritten",
color: STATUS.critical,
icon: CircleAlert,
klasse: "text-negative",
},
};
export function BudgetMeter({ budget }: { budget: BudgetStatus }) {
const zustand = ZUSTAND[budget.state];
const Symbol = zustand.icon;
const anteil = Math.min(budget.ratio, 1);
const ueberzogen = budget.ratio > 1;
return (
<li className="card p-3">
<div className="flex items-baseline justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: budget.color }}
/>
<span className="truncate text-sm font-medium text-ink">{budget.category_name}</span>
</div>
<span className="shrink-0 tabular text-sm text-ink">
{formatMoney(budget.spent)}
<span className="text-faint"> / {formatMoney(budget.available)}</span>
</span>
</div>
<div
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
role="meter"
aria-valuenow={Math.round(budget.ratio * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${budget.category_name}: ${Math.round(budget.ratio * 100)} Prozent verbraucht`}
>
<div
className="h-full rounded-full transition-all"
style={{ width: `${anteil * 100}%`, backgroundColor: zustand.color }}
/>
</div>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs">
<span className={cn("flex items-center gap-1", zustand.klasse)}>
<Symbol aria-hidden className="h-3.5 w-3.5" />
{Math.round(budget.ratio * 100)} % · {zustand.label}
</span>
<span className={ueberzogen ? "text-negative" : "text-muted"}>
{ueberzogen
? `${formatMoney(Math.abs(toNumber(budget.remaining)))} zu viel`
: `${formatMoney(budget.remaining)} übrig`}
</span>
</div>
{(budget.carried_over !== "0.00" || budget.is_template) && (
<p className="mt-1 text-[11px] text-faint">
{budget.carried_over !== "0.00" &&
`Übertrag aus Vormonaten: ${formatMoney(budget.carried_over)}. `}
{budget.is_template && "Aus einer Vorlage."}
</p>
)}
</li>
);
}
@@ -0,0 +1,82 @@
/** Tests des Kategorien-Rings samt Drilldown. */
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { renderWithProviders } from "@/test/utils";
import type { CategorySlice } from "@/types/api";
function slice(
id: number,
name: string,
amount: string,
children: CategorySlice[] = [],
): CategorySlice {
return {
category_id: id,
name,
color: "#84cc16",
icon: "circle",
amount,
count: 1,
children,
};
}
const KATEGORIEN: CategorySlice[] = [
slice(1, "Wohnen", "950.00", [slice(11, "Miete", "900.00"), slice(12, "Strom", "50.00")]),
slice(2, "Lebenshaltung", "320.00", [slice(21, "Lebensmittel", "320.00")]),
slice(3, "Mobilität", "130.00"),
];
describe("CategoryDonut", () => {
it("zeigt Summe, Anteile und Beträge im Klartext", () => {
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
// Die Zuordnung hängt nie allein an der Farbe.
expect(screen.getByText("Wohnen")).toBeInTheDocument();
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
expect(screen.getByText(/1.400,00/)).toBeInTheDocument();
expect(screen.getByText("68 %")).toBeInTheDocument();
});
it("blättert per Klick in die Unterkategorien und wieder zurück", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CategoryDonut categories={KATEGORIEN} title="Ausgaben" />);
await nutzer.click(screen.getByRole("button", { name: /Wohnen/ }));
expect(screen.getByText("Ausgaben · Wohnen")).toBeInTheDocument();
expect(screen.getByText("Miete")).toBeInTheDocument();
expect(screen.getByText("Strom")).toBeInTheDocument();
await nutzer.click(screen.getByRole("button", { name: "Zurück" }));
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
});
it("bietet keinen Drilldown bei nur einer Unterkategorie", () => {
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
// Lebenshaltung hat nur ein Kind ein Aufklappen brächte nichts.
expect(screen.queryByRole("button", { name: /Lebenshaltung/ })).not.toBeInTheDocument();
});
it("fasst mehr als sechs Kategorien zusammen", () => {
const viele = Array.from({ length: 9 }, (_, index) =>
slice(index + 1, `Kategorie ${index + 1}`, String((9 - index) * 10)),
);
renderWithProviders(<CategoryDonut categories={viele} />);
expect(screen.getByText("Weitere (3)")).toBeInTheDocument();
expect(screen.queryByText("Kategorie 8")).not.toBeInTheDocument();
});
it("meldet einen leeren Zeitraum verständlich", () => {
renderWithProviders(<CategoryDonut categories={[]} />);
expect(screen.getByText(/keine Bewegungen/)).toBeInTheDocument();
});
});
@@ -0,0 +1,188 @@
/**
* Aufteilung nach Kategorien mit Drilldown auf die Unterkategorien.
*
* Die Farben stammen aus den Kategorien selbst sie sind dort die Identität
* und tauchen in der ganzen Oberfläche auf. Damit die Zuordnung nicht allein an
* der Farbe hängt, trägt jede Zeile daneben Name und Betrag.
*/
import { useState } from "react";
import { ChevronLeft } from "lucide-react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { ChartCard, ChartTooltip } from "@/components/charts/ChartFrame";
import { Button } from "@/components/ui/Button";
import { formatMoney, toNumber } from "@/lib/format";
import type { CategorySlice } from "@/types/api";
/** Mehr Segmente lassen sich im Ring nicht mehr auseinanderhalten. */
const MAX_SEGMENTE = 6;
const REST_FARBE = "#94a3b8";
interface Segment {
id: number;
name: string;
color: string;
amount: number;
hasChildren: boolean;
}
function zuSegmenten(slices: CategorySlice[]): Segment[] {
const sortiert = [...slices].sort((links, rechts) => toNumber(rechts.amount) - toNumber(links.amount));
const sichtbar = sortiert.slice(0, MAX_SEGMENTE);
const rest = sortiert.slice(MAX_SEGMENTE);
const segmente: Segment[] = sichtbar.map((slice) => ({
id: slice.category_id,
name: slice.name,
color: slice.color,
amount: toNumber(slice.amount),
hasChildren: slice.children.length > 1,
}));
if (rest.length > 0) {
segmente.push({
id: -1,
name: `Weitere (${rest.length})`,
color: REST_FARBE,
amount: rest.reduce((summe, slice) => summe + toNumber(slice.amount), 0),
hasChildren: false,
});
}
return segmente;
}
export function CategoryDonut({
categories,
title = "Nach Kategorien",
description,
}: {
categories: CategorySlice[];
title?: string;
description?: string;
}) {
const [drilldown, setDrilldown] = useState<CategorySlice | null>(null);
const quelle = drilldown ? drilldown.children : categories;
const segmente = zuSegmenten(quelle);
const gesamt = segmente.reduce((summe, segment) => summe + segment.amount, 0);
if (segmente.length === 0) {
return (
<ChartCard title={title} description={description}>
<p className="py-10 text-center text-xs text-muted">
Für diesen Zeitraum gibt es keine Bewegungen.
</p>
</ChartCard>
);
}
return (
<ChartCard
title={drilldown ? `${title} · ${drilldown.name}` : title}
description={description}
actions={
drilldown && (
<Button size="sm" variant="ghost" onClick={() => setDrilldown(null)}>
<ChevronLeft aria-hidden className="h-3.5 w-3.5" />
Zurück
</Button>
)
}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative h-48 w-full sm:w-48 sm:shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segmente}
dataKey="amount"
nameKey="name"
innerRadius="62%"
outerRadius="98%"
// Zwei Pixel Fläche zwischen den Segmenten statt eines Rahmens.
paddingAngle={2}
stroke="none"
onClick={(eintrag: unknown) => {
const segment = eintrag as Segment;
const passend = quelle.find((slice) => slice.category_id === segment.id);
if (passend && passend.children.length > 1) setDrilldown(passend);
}}
>
{segmente.map((segment) => (
<Cell
key={segment.id}
fill={segment.color}
className={segment.hasChildren ? "cursor-pointer" : undefined}
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const segment = payload[0]?.payload as Segment;
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
return (
<ChartTooltip
title={segment.name}
rows={[
{ label: "Betrag", value: formatMoney(segment.amount), color: segment.color },
{ label: "Anteil", value: `${anteil.toFixed(1)} %` },
]}
/>
);
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Die Summe steht in der Mitte dort sucht man sie. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] text-muted">Gesamt</span>
<span className="tabular text-sm font-semibold text-ink">{formatMoney(gesamt)}</span>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-1">
{segmente.map((segment) => {
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
const passend = quelle.find((slice) => slice.category_id === segment.id);
const aufklappbar = Boolean(passend && passend.children.length > 1);
const inhalt = (
<>
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: segment.color }}
/>
<span className="min-w-0 flex-1 truncate text-ink">{segment.name}</span>
<span className="shrink-0 tabular text-muted">{anteil.toFixed(0)} %</span>
<span className="w-24 shrink-0 text-right tabular font-medium text-ink">
{formatMoney(segment.amount)}
</span>
</>
);
return (
<li key={segment.id}>
{aufklappbar ? (
<button
type="button"
onClick={() => passend && setDrilldown(passend)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs transition hover:bg-raised"
>
{inhalt}
</button>
) : (
<div className="flex items-center gap-2 px-2 py-1 text-xs">{inhalt}</div>
)}
</li>
);
})}
</ul>
</div>
</ChartCard>
);
}
@@ -0,0 +1,196 @@
/** Rahmen, Legende und Tooltip für alle Diagramme gleich. */
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
import { formatMoney } from "@/lib/format";
export interface LegendItem {
label: string;
color: string;
}
export function ChartCard({
title,
description,
legend,
actions,
children,
className,
}: {
title: string;
description?: string;
legend?: LegendItem[];
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("card p-4", className)}>
<header className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink">{title}</h2>
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
</div>
{actions}
</header>
{/* Ab zwei Serien ist die Legende immer vorhanden Farbe allein trägt nie. */}
{legend && legend.length > 1 && (
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1">
{legend.map((eintrag) => (
<li key={eintrag.label} className="flex items-center gap-1.5 text-xs text-muted">
<span
aria-hidden
className="h-2 w-2 rounded-full"
style={{ backgroundColor: eintrag.color }}
/>
{eintrag.label}
</li>
))}
</ul>
)}
{children}
</section>
);
}
export interface TooltipRow {
label: string;
value: string;
color?: string;
}
/** Einheitlicher Tooltip; die Werte stehen in Textfarbe, nicht in der Serienfarbe. */
export function ChartTooltip({ title, rows }: { title: string; rows: TooltipRow[] }) {
return (
<div className="rounded-lg border border-line bg-surface px-3 py-2 shadow-xl">
<p className="mb-1 text-xs font-medium text-ink">{title}</p>
<ul className="space-y-0.5">
{rows.map((zeile) => (
<li key={zeile.label} className="flex items-center gap-2 text-xs">
{zeile.color && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: zeile.color }}
/>
)}
<span className="text-muted">{zeile.label}</span>
<span className="ml-auto tabular text-ink">{zeile.value}</span>
</li>
))}
</ul>
</div>
);
}
/** Aufklappbare Wertetabelle die Alternative zum Ablesen aus der Grafik. */
export function DataTable({
columns,
rows,
caption,
}: {
columns: string[];
rows: (string | number)[][];
caption: string;
}) {
return (
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-muted transition hover:text-ink">
Werte als Tabelle
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full">
<caption className="sr-only">{caption}</caption>
<thead className="text-left text-muted">
<tr>
{columns.map((spalte, index) => (
<th
key={spalte}
scope="col"
className={cn("py-1 font-medium", index > 0 && "text-right")}
>
{spalte}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{rows.map((zeile, zeilenIndex) => (
<tr key={zeilenIndex}>
{zeile.map((zelle, spaltenIndex) => (
<td
key={spaltenIndex}
className={cn(
"py-1",
spaltenIndex === 0 ? "text-ink" : "text-right tabular text-muted",
)}
>
{zelle}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</details>
);
}
/** Kennzahl für Fälle, in denen eine Zahl mehr sagt als ein Diagramm. */
export function StatTile({
label,
value,
hint,
tone = "default",
large = false,
}: {
label: string;
value: string;
hint?: ReactNode;
tone?: "default" | "positive" | "negative";
large?: boolean;
}) {
return (
<div className="card p-4">
<p className="text-xs text-muted">{label}</p>
<p
className={cn(
"mt-1 font-semibold",
large ? "text-3xl" : "text-xl",
tone === "positive" && "text-positive",
tone === "negative" && "text-negative",
tone === "default" && "text-ink",
)}
>
{value}
</p>
{hint && <div className="mt-1 text-xs text-muted">{hint}</div>}
</div>
);
}
/** Veränderung gegenüber einem Vergleichswert, mit Vorzeichen und Wortlaut. */
export function DeltaHint({
value,
goodWhenPositive = true,
suffix = "ggü. Vormonat",
}: {
value: string;
goodWhenPositive?: boolean;
suffix?: string;
}) {
const zahl = Number.parseFloat(value);
if (!Number.isFinite(zahl) || zahl === 0) {
return <span className="text-faint">unverändert {suffix}</span>;
}
const gut = zahl > 0 === goodWhenPositive;
return (
<span className={gut ? "text-positive" : "text-negative"}>
{zahl > 0 ? "▲" : "▼"} {formatMoney(Math.abs(zahl))} {suffix}
</span>
);
}
@@ -0,0 +1,181 @@
/**
* Zwölf-Monats-Vorschau.
*
* Bewusst zwei Diagramme untereinander statt zweier Größenachsen in einem Bild:
* Monatsbeträge und kumulierter Kontostand haben verschiedene Größenordnungen,
* eine gemeinsame Achse würde einen Zusammenhang vortäuschen.
*/
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
import {
AXIS_PROPS,
BAR_GAP,
BAR_RADIUS,
GRID_PROPS,
SERIES,
} from "@/components/charts/chartTheme";
import { formatMoney, toNumber } from "@/lib/format";
import type { ForecastMonth } from "@/types/api";
const MONATSKUERZEL = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
interface Punkt {
label: string;
monat: string;
einkuenfte: number;
ausgaben: number;
saldo: number;
kumuliert: number;
}
function kompakt(wert: number): string {
return new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert);
}
export function ForecastChart({ months }: { months: ForecastMonth[] }) {
const punkte: Punkt[] = months.map((monat) => {
const datum = new Date(monat.month);
return {
label: `${MONATSKUERZEL[datum.getMonth()]} ${String(datum.getFullYear()).slice(2)}`,
monat: monat.month,
einkuenfte: toNumber(monat.income),
ausgaben: toNumber(monat.expenses),
saldo: toNumber(monat.balance),
kumuliert: toNumber(monat.cumulative_balance),
};
});
return (
<div className="space-y-3">
<ChartCard
title="Einnahmen und Ausgaben"
description="Jährliche Posten stehen in ihrem echten Fälligkeitsmonat."
legend={[
{ label: "Einkünfte", color: SERIES.one },
{ label: "Ausgaben", color: SERIES.two },
]}
>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={punkte} barGap={BAR_GAP} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
<Tooltip
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: "Einkünfte",
value: formatMoney(payload[0]?.payload.einkuenfte ?? 0),
color: SERIES.one,
},
{
label: "Ausgaben",
value: formatMoney(payload[0]?.payload.ausgaben ?? 0),
color: SERIES.two,
},
{
label: "Saldo",
value: formatMoney(payload[0]?.payload.saldo ?? 0),
},
]}
/>
) : null
}
/>
<Bar dataKey="einkuenfte" name="Einkünfte" fill={SERIES.one} radius={BAR_RADIUS} />
<Bar dataKey="ausgaben" name="Ausgaben" fill={SERIES.two} radius={BAR_RADIUS} />
</BarChart>
</ResponsiveContainer>
</div>
<DataTable
caption="Einnahmen und Ausgaben je Monat"
columns={["Monat", "Einkünfte", "Ausgaben", "Saldo"]}
rows={punkte.map((punkt) => [
punkt.label,
formatMoney(punkt.einkuenfte),
formatMoney(punkt.ausgaben),
formatMoney(punkt.saldo),
])}
/>
</ChartCard>
<ChartCard
title="Kumulierter Kontostand"
description="Fortgeschrieben aus dem heutigen Saldo aller Konten."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={punkte} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="label" {...AXIS_PROPS} />
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
{/* Die Nulllinie ist die Grenze, auf die es ankommt. */}
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
<Tooltip
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: "Kontostand",
value: formatMoney(payload[0]?.payload.kumuliert ?? 0),
color: SERIES.one,
},
]}
/>
) : null
}
/>
<Line
type="monotone"
dataKey="kumuliert"
name="Kontostand"
stroke={SERIES.one}
strokeWidth={2}
dot={{ r: 3, strokeWidth: 0, fill: SERIES.one }}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
);
}
@@ -0,0 +1,119 @@
/** Jahresvergleich je Oberkategorie als gruppierte Balken. */
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
import {
AXIS_PROPS,
BAR_GAP,
GRID_PROPS,
SERIES,
} from "@/components/charts/chartTheme";
import { formatMoney, formatSignedMoney, toNumber } from "@/lib/format";
import type { YearComparison } from "@/types/api";
function kompakt(wert: number): string {
return new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert);
}
export function YearComparisonChart({ data }: { data: YearComparison }) {
const punkte = data.rows.map((zeile) => ({
name: zeile.name,
aktuell: toNumber(zeile.current),
vorjahr: toNumber(zeile.previous),
delta: toNumber(zeile.delta),
}));
if (punkte.length === 0) {
return (
<ChartCard title={`Jahresvergleich ${data.year}`}>
<p className="py-10 text-center text-xs text-muted">
Für {data.year} und {data.year - 1} liegen keine Ausgaben vor.
</p>
</ChartCard>
);
}
return (
<ChartCard
title={`Jahresvergleich ${data.year}`}
description={`${formatMoney(data.current_total)} gegenüber ${formatMoney(
data.previous_total,
)} im Vorjahr.`}
legend={[
{ label: String(data.year), color: SERIES.one },
{ label: String(data.year - 1), color: SERIES.two },
]}
>
<div style={{ height: Math.max(200, punkte.length * 44) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={punkte}
layout="vertical"
barGap={BAR_GAP}
margin={{ top: 4, right: 12, bottom: 0, left: 4 }}
>
<CartesianGrid {...GRID_PROPS} horizontal={false} vertical />
<XAxis type="number" {...AXIS_PROPS} tickFormatter={kompakt} />
<YAxis type="category" dataKey="name" {...AXIS_PROPS} width={110} />
<Tooltip
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<ChartTooltip
title={String(label)}
rows={[
{
label: String(data.year),
value: formatMoney(payload[0]?.payload.aktuell ?? 0),
color: SERIES.one,
},
{
label: String(data.year - 1),
value: formatMoney(payload[0]?.payload.vorjahr ?? 0),
color: SERIES.two,
},
{
label: "Veränderung",
value: formatSignedMoney(payload[0]?.payload.delta ?? 0),
},
]}
/>
) : null
}
/>
<Bar dataKey="aktuell" name={String(data.year)} fill={SERIES.one} radius={[0, 4, 4, 0]} />
<Bar
dataKey="vorjahr"
name={String(data.year - 1)}
fill={SERIES.two}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<DataTable
caption={`Ausgaben ${data.year} gegenüber ${data.year - 1}`}
columns={["Kategorie", String(data.year), String(data.year - 1), "Veränderung"]}
rows={punkte.map((punkt) => [
punkt.name,
formatMoney(punkt.aktuell),
formatMoney(punkt.vorjahr),
formatSignedMoney(punkt.delta),
])}
/>
</ChartCard>
);
}
@@ -0,0 +1,48 @@
/**
* Gemeinsame Bausteine der Diagramme.
*
* Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
* Flächen dieser Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde.
* Die Reihenfolge ist Teil dieser Absicherung und darf nicht umsortiert werden.
*/
export const SERIES = {
/** Einkünfte, laufendes Jahr, kumulierter Saldo. */
one: "var(--viz-series-1)",
/** Ausgaben, Vorjahr. */
two: "var(--viz-series-2)",
three: "var(--viz-series-3)",
} as const;
export const CHROME = {
grid: "var(--viz-grid)",
axis: "var(--viz-axis)",
label: "var(--viz-label)",
surface: "var(--viz-surface)",
} as const;
export const STATUS = {
good: "var(--viz-good)",
warning: "var(--viz-warning)",
critical: "var(--viz-critical)",
} as const;
/** Achsen und Raster bleiben zurückhaltend: dünne, durchgezogene Linien. */
export const AXIS_PROPS = {
stroke: CHROME.axis,
tickLine: false,
axisLine: false,
tick: { fill: CHROME.label, fontSize: 11 },
} as const;
export const GRID_PROPS = {
stroke: CHROME.grid,
strokeWidth: 1,
vertical: false,
} as const;
/** Abgerundete Balkenenden an der Grundlinie. */
export const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0];
/** Zwei Pixel Fläche zwischen benachbarten Balken statt eines Rahmens. */
export const BAR_GAP = 2;
+18 -1
View File
@@ -1,6 +1,17 @@
import { NavLink } from "react-router-dom";
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
import {
BarChart3,
Building2,
CalendarDays,
LayoutDashboard,
type LucideIcon,
PiggyBank,
Receipt,
Repeat,
Settings,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/cn";
@@ -11,9 +22,14 @@ interface NavEintrag {
}
const NAVIGATION: NavEintrag[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/calendar", label: "Kalender", icon: CalendarDays },
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
{ to: "/transactions", label: "Buchungen", icon: Receipt },
{ to: "/merchants", label: "Firmen", icon: Building2 },
{ to: "/budgets", label: "Budgets", icon: Wallet },
{ to: "/goals", label: "Sparziele", icon: PiggyBank },
{ to: "/reports", label: "Auswertungen", icon: BarChart3 },
{ to: "/settings", label: "Einstellungen", icon: Settings },
];
@@ -35,6 +51,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
key={to}
to={to}
onClick={onNavigate}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",