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:
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user