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
+11 -2
View File
@@ -5,8 +5,13 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
import { useMe } from "@/hooks/useAuth";
import { BudgetsPage } from "@/pages/BudgetsPage";
import { CalendarPage } from "@/pages/CalendarPage";
import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { LoginPage } from "@/pages/LoginPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
import { SettingsPage } from "@/pages/SettingsPage";
@@ -31,12 +36,16 @@ export function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/recurrences" replace />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/recurrences" element={<RecurrencesPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/merchants" element={<MerchantsPage />} />
<Route path="/budgets" element={<BudgetsPage />} />
<Route path="/goals" element={<GoalsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/recurrences" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
@@ -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",
+128
View File
@@ -0,0 +1,128 @@
/** Budgets, Vorlagen und Sparziele als Stammdaten. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "@/store/toast";
import type {
Budget,
BudgetInput,
BudgetTemplate,
MessageResponse,
SavingsGoal,
SavingsGoalInput,
} from "@/types/api";
function invalidate(client: ReturnType<typeof useQueryClient>): void {
void client.invalidateQueries({ queryKey: ["budgets"] });
void client.invalidateQueries({ queryKey: ["budget-templates"] });
void client.invalidateQueries({ queryKey: ["reports"] });
}
export function useBudgets(month?: string) {
return useQuery({
queryKey: ["budgets", month ?? ""],
queryFn: () => api.get<Budget[]>("/budgets", month ? { month } : undefined),
});
}
export function useSaveBudget() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: BudgetInput | Partial<BudgetInput> }) =>
id ? api.patch<Budget>(`/budgets/${id}`, daten) : api.post<Budget>("/budgets", daten),
onSuccess: (_budget, variablen) => {
invalidate(client);
toast.success(variablen.id ? "Budget gespeichert." : "Budget angelegt.");
},
});
}
export function useDeleteBudget() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/budgets/${id}`),
onSuccess: () => {
invalidate(client);
toast.success("Budget gelöscht.");
},
});
}
export function useBudgetTemplates() {
return useQuery({
queryKey: ["budget-templates"],
queryFn: () => api.get<BudgetTemplate[]>("/budget-templates"),
});
}
export function useSaveBudgetTemplate() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: {
category_id: number;
valid_from: string;
limit_amount: string;
rollover?: boolean;
}) => api.post<BudgetTemplate>("/budget-templates", daten),
onSuccess: () => {
invalidate(client);
toast.success("Budgetvorlage angelegt.");
},
});
}
export function useDeleteBudgetTemplate() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/budget-templates/${id}`),
onSuccess: () => {
invalidate(client);
toast.success("Budgetvorlage gelöscht.");
},
});
}
export function useSavingsGoals(includeArchived = false) {
return useQuery({
queryKey: ["savings-goals", includeArchived],
queryFn: () =>
api.get<SavingsGoal[]>(
"/savings-goals",
includeArchived ? { include_archived: true } : undefined,
),
});
}
export function useSaveGoal() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: SavingsGoalInput | Partial<SavingsGoalInput>;
}) =>
id
? api.patch<SavingsGoal>(`/savings-goals/${id}`, daten)
: api.post<SavingsGoal>("/savings-goals", daten),
onSuccess: (_ziel, variablen) => {
void client.invalidateQueries({ queryKey: ["savings-goals"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success(variablen.id ? "Sparziel gespeichert." : "Sparziel angelegt.");
},
});
}
export function useDeleteGoal() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/savings-goals/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["savings-goals"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Sparziel gelöscht.");
},
});
}
+93
View File
@@ -0,0 +1,93 @@
/** Abfragen der Auswertungen. */
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
BudgetStatus,
CalendarMonth,
CategoryReport,
Dashboard,
EntryKind,
Forecast,
MonthReport,
SavingsGoalProgress,
SubscriptionReport,
YearComparison,
} from "@/types/api";
export function useDashboard(month: string) {
return useQuery({
queryKey: ["reports", "dashboard", month],
queryFn: () => api.get<Dashboard>("/reports/dashboard", { month }),
});
}
export function useMonthReport(month: string) {
return useQuery({
queryKey: ["reports", "month", month],
queryFn: () => api.get<MonthReport>("/reports/month", { month }),
});
}
export function useForecast(months = 12, start?: string) {
return useQuery({
queryKey: ["reports", "forecast", months, start ?? ""],
queryFn: () => api.get<Forecast>("/reports/forecast", { months, start }),
});
}
export function useCategoryReport(from: string, to: string, kind: EntryKind = "expense") {
return useQuery({
queryKey: ["reports", "categories", from, to, kind],
queryFn: () => api.get<CategoryReport>("/reports/categories", { from, to, kind }),
});
}
export function useSubscriptions() {
return useQuery({
queryKey: ["reports", "subscriptions"],
queryFn: () => api.get<SubscriptionReport>("/reports/subscriptions"),
});
}
export function useYearComparison(year: number, kind: EntryKind = "expense") {
return useQuery({
queryKey: ["reports", "year-comparison", year, kind],
queryFn: () => api.get<YearComparison>("/reports/year-comparison", { year, kind }),
});
}
export function useCalendar(month: string) {
return useQuery({
queryKey: ["reports", "calendar", month],
queryFn: () => api.get<CalendarMonth>("/reports/calendar", { month }),
});
}
export function useBudgetStatus(month: string) {
return useQuery({
queryKey: ["reports", "budgets", month],
queryFn: () => api.get<BudgetStatus[]>("/reports/budgets", { month }),
});
}
export function useGoalProgress() {
return useQuery({
queryKey: ["reports", "savings-goals"],
queryFn: () => api.get<SavingsGoalProgress[]>("/reports/savings-goals"),
});
}
/** Baut die URL eines Exports; der Browser lädt sie direkt herunter. */
export function exportUrl(
what: "transactions" | "recurrences" | "month",
format: "csv" | "xlsx",
params: Record<string, string | number | undefined> = {},
): string {
const suchparameter = new URLSearchParams({ format });
for (const [schluessel, wert] of Object.entries(params)) {
if (wert !== undefined && wert !== "") suchparameter.set(schluessel, String(wert));
}
return `/api/export/${what}?${suchparameter.toString()}`;
}
+30
View File
@@ -19,6 +19,24 @@
--color-warning: 180 83 9;
}
/* Diagrammfarben, heller Modus.
Die Reihenfolge der Serienfarben ist die Absicherung gegen Farbfehlsichtigkeit
und wurde gegen die Flächen dieser Anwendung geprüft nicht umsortieren.
Grün/Rot wäre als Serienpaar bei Deuteranopie nicht unterscheidbar (ΔE 7,9)
und bleibt deshalb den Vorzeichen im Text vorbehalten. */
:root {
--viz-series-1: #2a78d6;
--viz-series-2: #eb6834;
--viz-series-3: #1baf7a;
--viz-grid: #e2e8f0;
--viz-axis: #94a3b8;
--viz-label: #64748b;
--viz-surface: #ffffff;
--viz-good: #0ca30c;
--viz-warning: #fab219;
--viz-critical: #d03b3b;
}
/* Dunkler Modus die Vorgabe der Anwendung */
.dark {
--color-ground: 15 17 21;
@@ -33,6 +51,18 @@
--color-positive: 74 222 128;
--color-negative: 248 113 113;
--color-warning: 251 191 36;
/* Eigene Stufen für die dunkle Fläche, kein automatisches Umdrehen. */
--viz-series-1: #3987e5;
--viz-series-2: #d95926;
--viz-series-3: #199e70;
--viz-grid: #2b3038;
--viz-axis: #4b5563;
--viz-label: #94a3b8;
--viz-surface: #181b21;
--viz-good: #0ca30c;
--viz-warning: #fab219;
--viz-critical: #d03b3b;
}
html {
+307
View File
@@ -0,0 +1,307 @@
/** Budgets je Monat samt Ampel und dauerhaften Vorlagen. */
import { type FormEvent, useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Wallet } from "lucide-react";
import { CategorySelect } from "@/components/EntitySelects";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
useBudgetTemplates,
useBudgets,
useDeleteBudget,
useDeleteBudgetTemplate,
useSaveBudget,
useSaveBudgetTemplate,
} from "@/hooks/useBudgets";
import { useBudgetStatus } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
import type { Budget, BudgetTemplate } from "@/types/api";
export function BudgetsPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
const [loeschen, setLoeschen] = useState<Budget | null>(null);
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
const { data: status = [], isLoading } = useBudgetStatus(monat);
const { data: budgets = [] } = useBudgets(monat);
const { data: vorlagen = [] } = useBudgetTemplates();
const entfernen = useDeleteBudget();
const vorlageEntfernen = useDeleteBudgetTemplate();
const kategorieName = useCategoryLookup();
return (
<>
<PageHeader
title="Budgets"
description={formatMonth(monat)}
actions={
<>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
<Button onClick={() => setFormular("template")}>Vorlage</Button>
<Button variant="primary" onClick={() => setFormular("budget")}>
<Plus aria-hidden className="h-4 w-4" />
Budget
</Button>
</>
}
/>
{isLoading ? (
<Skeleton className="h-64" />
) : status.length === 0 ? (
<EmptyState
icon={Wallet}
title="Keine Budgets in diesem Monat"
description="Ein Budget zeigt, wie viel einer Kategorie noch bleibt. Mit einer Vorlage gilt es dauerhaft ab einem Monat."
action={
<Button variant="primary" onClick={() => setFormular("budget")}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Budget anlegen
</Button>
}
/>
) : (
<ul className="grid gap-2 lg:grid-cols-2">
{status.map((eintrag) => (
<BudgetMeter key={eintrag.category_id} budget={eintrag} />
))}
</ul>
)}
{budgets.length > 0 && (
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold text-ink">
Einzelbudgets in diesem Monat
</h2>
<ul className="divide-y divide-line rounded-card border border-line">
{budgets.map((budget) => (
<li key={budget.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate text-ink">
{kategorieName(budget.category_id)}
</span>
{budget.rollover && <Badge>Übertrag</Badge>}
<span className="tabular text-muted">{formatMoney(budget.limit_amount)}</span>
<Button
size="sm"
variant="ghost"
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
onClick={() => setLoeschen(budget)}
aria-label="Budget löschen"
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</section>
)}
{vorlagen.length > 0 && (
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold text-ink">Dauerhafte Vorlagen</h2>
<ul className="divide-y divide-line rounded-card border border-line">
{vorlagen.map((vorlage) => (
<li key={vorlage.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate text-ink">
{kategorieName(vorlage.category_id)}
</span>
<span className="text-xs text-muted">
ab {formatDate(vorlage.valid_from)}
{vorlage.valid_until && ` bis ${formatDate(vorlage.valid_until)}`}
</span>
{vorlage.rollover && <Badge>Übertrag</Badge>}
<span className="tabular text-muted">{formatMoney(vorlage.limit_amount)}</span>
<Button
size="sm"
variant="ghost"
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
onClick={() => setVorlageLoeschen(vorlage)}
aria-label="Vorlage löschen"
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</section>
)}
<BudgetDialog
kind={formular}
month={monat}
open={formular !== null}
onClose={() => setFormular(null)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Budget löschen"
description="Das Budget dieses Monats wird entfernt."
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
<ConfirmDialog
open={vorlageLoeschen !== null}
title="Vorlage löschen"
description="Die Vorlage gilt danach in keinem Monat mehr."
loading={vorlageEntfernen.isPending}
onCancel={() => setVorlageLoeschen(null)}
onConfirm={() => {
if (vorlageLoeschen)
vorlageEntfernen.mutate(vorlageLoeschen.id, {
onSuccess: () => setVorlageLoeschen(null),
});
}}
/>
</>
);
}
function BudgetDialog({
kind,
month,
open,
onClose,
}: {
kind: "budget" | "template" | null;
month: string;
open: boolean;
onClose: () => void;
}) {
const [kategorie, setKategorie] = useState<number | null>(null);
const [betrag, setBetrag] = useState("");
const [uebertrag, setUebertrag] = useState(false);
const [gueltigAb, setGueltigAb] = useState(month);
const budgetSpeichern = useSaveBudget();
const vorlageSpeichern = useSaveBudgetTemplate();
const laeuft = budgetSpeichern.isPending || vorlageSpeichern.isPending;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (kategorie === null || !betrag) return;
const fertig = {
onSuccess: () => {
setKategorie(null);
setBetrag("");
setUebertrag(false);
onClose();
},
};
if (kind === "template") {
vorlageSpeichern.mutate(
{
category_id: kategorie,
valid_from: gueltigAb,
limit_amount: betrag,
rollover: uebertrag,
},
fertig,
);
} else {
budgetSpeichern.mutate(
{
daten: {
category_id: kategorie,
period_month: month,
limit_amount: betrag,
rollover: uebertrag,
},
},
fertig,
);
}
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={kind === "template" ? "Neue Budgetvorlage" : "Neues Budget"}
description={
kind === "template"
? "Gilt ab dem gewählten Monat dauerhaft, bis eine neue Vorlage sie ablöst."
: `Gilt nur für ${formatMonth(month)}.`
}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={laeuft}
disabled={kategorie === null || !betrag}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Kategorie" required hint="Budgets auf einer Oberkategorie umfassen deren Unterkategorien.">
{(id) => (
<CategorySelect id={id} required kind="expense" value={kategorie} onChange={setKategorie} />
)}
</Field>
{kind === "template" && (
<Field label="Gültig ab" required>
{(id) => (
<Input
id={id}
type="month"
value={gueltigAb.slice(0, 7)}
onChange={(ereignis) => setGueltigAb(`${ereignis.target.value}-01`)}
/>
)}
</Field>
)}
<Field label="Betrag" required>
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
</Field>
<Checkbox
label="Rest in den Folgemonat übertragen"
hint="Nicht verbrauchtes Budget erhöht das Budget des nächsten Monats."
checked={uebertrag}
onChange={(ereignis) => setUebertrag(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
+202
View File
@@ -0,0 +1,202 @@
/** Tests des Cashflow-Kalenders. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CalendarPage } from "@/pages/CalendarPage";
import { renderWithProviders } from "@/test/utils";
import type { CalendarDay, CalendarEntry } from "@/types/api";
function eintrag(overrides: Partial<CalendarEntry> = {}): CalendarEntry {
return {
title: "Miete",
kind: "expense",
amount: "950.00",
category_id: 11,
merchant_id: null,
account_id: 1,
source: "recurrence",
recurrence_id: 7,
occurrence_date: "2026-03-01",
status: "planned",
is_variable: false,
...overrides,
};
}
/** Baut einen vollständigen März 2026 mit zwei belegten Tagen. */
function maerz(): CalendarDay[] {
return Array.from({ length: 31 }, (_, index) => {
const tag = index + 1;
const datum = `2026-03-${String(tag).padStart(2, "0")}`;
const wochentag = new Date(datum).getDay();
if (tag === 2) {
return {
date: datum,
entries: [eintrag({ occurrence_date: "2026-03-01" })],
net: "-950.00",
running_balance: "50.00",
is_business_day: true,
};
}
if (tag === 28) {
return {
date: datum,
entries: [
eintrag({
title: "Gehalt",
kind: "income",
amount: "3200.00",
recurrence_id: 8,
occurrence_date: "2026-03-28",
}),
],
net: "3200.00",
running_balance: "3250.00",
is_business_day: true,
};
}
return {
date: datum,
entries: [],
net: "0.00",
running_balance: tag < 2 ? "1000.00" : tag < 28 ? "50.00" : "3250.00",
is_business_day: wochentag !== 0 && wochentag !== 6,
};
});
}
function mockApi() {
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown) =>
new Response(JSON.stringify(daten), {
status: 200,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
anfragen.push({
url,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
});
if (url.includes("/api/reports/calendar")) {
return json({
month: "2026-03-01",
days: maerz(),
opening_balance: "1000.00",
closing_balance: "3250.00",
lowest_balance: "50.00",
lowest_balance_on: "2026-03-02",
});
}
if (url.includes("/api/merchants")) {
return json({ items: [], total: 0, limit: 200, offset: 0 });
}
if (url.includes("/api/occurrences/")) {
return json({});
}
return json({});
}),
);
return anfragen;
}
describe("Cashflow-Kalender", () => {
beforeEach(() => {
mockApi();
});
it("zeigt die Kennzahlen des Monats", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
});
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
expect(screen.getByText(/am 02\.03\.2026/)).toBeInTheDocument();
});
it("rendert ein vollständiges Monatsraster", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument();
});
// 31 Tage im März.
expect(screen.getAllByRole("button", { name: /\d{2}\.03\.2026/ })).toHaveLength(31);
});
it("öffnet die Tagesansicht mit den Posten", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
const detail = await screen.findByRole("heading", { name: "02.03.2026" });
const bereich = detail.closest("section")!;
// Der Betrag steht sowohl in der Zeile als auch im Tagessaldo hier zählt die Zeile.
const zeile = within(bereich).getByRole("listitem");
expect(within(zeile).getByText("Miete")).toBeInTheDocument();
expect(within(zeile).getByText(/950,00/)).toBeInTheDocument();
expect(within(zeile).getByText("Geplant")).toBeInTheDocument();
});
it("bestätigt eine Fälligkeit über das nominale Datum", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
await nutzer.click(await screen.findByRole("button", { name: "Miete bestätigen" }));
await waitFor(() => {
const bestaetigung = anfragen.find((e) => e.url.includes("/occurrences/confirm"));
expect(bestaetigung).toBeDefined();
// Der Zahltag ist der 02.03., der Schlüssel bleibt der 01.03.
expect(bestaetigung?.body).toEqual({
recurrence_id: 7,
occurrence_date: "2026-03-01",
});
});
});
it("meldet leere Tage verständlich", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/05\.03\.2026/)).toBeInTheDocument());
await nutzer.click(screen.getByLabelText(/05\.03\.2026/));
expect(await screen.findByText("Nichts fällig")).toBeInTheDocument();
});
it("blättert in den Vormonat", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<CalendarPage />);
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
const vorher = anfragen.filter((e) => e.url.includes("/reports/calendar")).length;
await nutzer.click(screen.getByRole("button", { name: "Vorheriger Monat" }));
await waitFor(() => {
expect(anfragen.filter((e) => e.url.includes("/reports/calendar")).length).toBeGreaterThan(
vorher,
);
});
});
});
+441
View File
@@ -0,0 +1,441 @@
/**
* Cashflow-Kalender.
*
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
* Kontostands über den Monat.
*/
import { useState } from "react";
import { CalendarDays, Check, ChevronLeft, ChevronRight, SkipForward } from "lucide-react";
import {
Area,
AreaChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChartCard, ChartTooltip, StatTile } from "@/components/charts/ChartFrame";
import { AXIS_PROPS, GRID_PROPS, SERIES } from "@/components/charts/chartTheme";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
import { useCalendar } from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
function fuehrendeLeerfelder(erster: string): number {
const datum = new Date(erster);
return (datum.getDay() + 6) % 7;
}
export function CalendarPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
const { data, isLoading } = useCalendar(monat);
const { data: firmenSeite } = useMerchants();
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const tage = data?.days ?? [];
const heute = todayIso();
const detailTag = tage.find((tag) => tag.date === gewaehlterTag) ?? null;
return (
<>
<PageHeader
title="Cashflow-Kalender"
description={formatMonth(monat)}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, -1));
setGewaehlterTag(null);
}}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => {
setMonat(firstOfMonth());
setGewaehlterTag(null);
}}
>
Heute
</Button>
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, 1));
setGewaehlterTag(null);
}}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
}
/>
{isLoading || !data ? (
<div className="space-y-3">
<Skeleton className="h-96" />
<Skeleton className="h-48" />
</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
<StatTile
label="Stand zum Monatsende"
value={formatMoney(data.closing_balance)}
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
/>
<StatTile
label="Tiefster Stand"
value={formatMoney(data.lowest_balance)}
tone={toNumber(data.lowest_balance) < 0 ? "negative" : "default"}
hint={data.lowest_balance_on ? `am ${formatDate(data.lowest_balance_on)}` : undefined}
/>
</div>
<section className="card p-3">
<div className="mb-1 grid grid-cols-7 gap-1.5">
{WOCHENTAGE.map((tag) => (
<div key={tag} className="py-1 text-center text-[11px] font-medium text-muted">
{tag}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
<div key={`leer-${index}`} aria-hidden />
))}
{tage.map((tag) => (
<TagZelle
key={tag.date}
tag={tag}
istHeute={tag.date === heute}
istGewaehlt={tag.date === gewaehlterTag}
firmen={firmen}
onSelect={() => setGewaehlterTag(tag.date === gewaehlterTag ? null : tag.date)}
/>
))}
</div>
</section>
{detailTag && (
<TagDetail
tag={detailTag}
firmen={firmen}
onClose={() => setGewaehlterTag(null)}
/>
)}
<ChartCard
title="Verlauf des Kontostands"
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={tage.map((tag) => ({
tag: new Date(tag.date).getDate(),
stand: toNumber(tag.running_balance),
}))}
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
>
<defs>
<linearGradient id="verlauf" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES.one} stopOpacity={0.28} />
<stop offset="100%" stopColor={SERIES.one} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid {...GRID_PROPS} />
<XAxis dataKey="tag" {...AXIS_PROPS} />
<YAxis
{...AXIS_PROPS}
width={52}
tickFormatter={(wert: number) =>
new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 1,
}).format(wert)
}
/>
<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={`${label}. ${formatMonth(monat)}`}
rows={[
{
label: "Kontostand",
value: formatMoney(payload[0]?.payload.stand ?? 0),
color: SERIES.one,
},
]}
/>
) : null
}
/>
<Area
type="monotone"
dataKey="stand"
stroke={SERIES.one}
strokeWidth={2}
fill="url(#verlauf)"
dot={false}
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</ChartCard>
</div>
)}
</>
);
}
function TagZelle({
tag,
istHeute,
istGewaehlt,
firmen,
onSelect,
}: {
tag: CalendarDay;
istHeute: boolean;
istGewaehlt: boolean;
firmen: Map<number, Merchant>;
onSelect: () => void;
}) {
const netto = toNumber(tag.net);
const tagesZahl = new Date(tag.date).getDate();
return (
<button
type="button"
onClick={onSelect}
aria-pressed={istGewaehlt}
aria-label={`${formatDate(tag.date)}, ${tag.entries.length} Posten, Saldo ${formatMoney(tag.net)}`}
className={cn(
"flex min-h-[5.5rem] flex-col rounded-lg border p-1.5 text-left transition",
istGewaehlt ? "border-accent bg-accent/10" : "border-line hover:border-faint",
!tag.is_business_day && "bg-raised/60",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"text-xs tabular",
istHeute
? "flex h-5 w-5 items-center justify-center rounded-full bg-accent font-semibold text-accent-ink"
: tag.is_business_day
? "text-muted"
: "text-faint",
)}
>
{tagesZahl}
</span>
{netto !== 0 && (
<span
className={cn("tabular text-[10px]", netto < 0 ? "text-negative" : "text-positive")}
>
{netto > 0 ? "+" : ""}
{new Intl.NumberFormat("de-DE", {
notation: "compact",
maximumFractionDigits: 0,
}).format(Math.abs(netto))}
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-0.5">
{tag.entries.slice(0, 4).map((eintrag, index) => {
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
return (
<MerchantLogo
key={`${eintrag.title}-${index}`}
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={18}
/>
);
})}
{tag.entries.length > 4 && (
<span className="self-center text-[10px] text-faint">+{tag.entries.length - 4}</span>
)}
</div>
</button>
);
}
function TagDetail({
tag,
firmen,
onClose,
}: {
tag: CalendarDay;
firmen: Map<number, Merchant>;
onClose: () => void;
}) {
const bestaetigen = useConfirmOccurrence();
const auslassen = useSkipOccurrence();
return (
<section className="card p-4">
<header className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ink">{formatDate(tag.date)}</h2>
<div className="flex items-center gap-3 text-xs">
<span className="text-muted">
Saldo{" "}
<span className={cn("tabular", toNumber(tag.net) < 0 ? "text-negative" : "text-positive")}>
{formatMoney(tag.net)}
</span>
</span>
<Button size="sm" variant="ghost" onClick={onClose}>
Schließen
</Button>
</div>
</header>
{tag.entries.length === 0 ? (
<EmptyState
icon={CalendarDays}
title="Nichts fällig"
description="An diesem Tag steht keine Zahlung an."
/>
) : (
<ul className="divide-y divide-line">
{tag.entries.map((eintrag, index) => (
<EintragZeile
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
eintrag={eintrag}
firma={eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined}
onConfirm={() =>
eintrag.recurrence_id &&
eintrag.occurrence_date &&
bestaetigen.mutate({
recurrence_id: eintrag.recurrence_id,
occurrence_date: eintrag.occurrence_date,
})
}
onSkip={() =>
eintrag.recurrence_id &&
eintrag.occurrence_date &&
auslassen.mutate({
recurrence_id: eintrag.recurrence_id,
occurrence_date: eintrag.occurrence_date,
})
}
busy={bestaetigen.isPending || auslassen.isPending}
/>
))}
</ul>
)}
</section>
);
}
function EintragZeile({
eintrag,
firma,
onConfirm,
onSkip,
busy,
}: {
eintrag: CalendarEntry;
firma: Merchant | undefined;
onConfirm: () => void;
onSkip: () => void;
busy: boolean;
}) {
const bestaetigbar = eintrag.source === "recurrence" && eintrag.status === "planned";
return (
<li className="flex items-center gap-3 py-2">
<MerchantLogo
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={32}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{eintrag.title}</p>
<p className="text-xs text-faint">
{eintrag.source === "transaction"
? "Einmalige Buchung"
: eintrag.status === "confirmed"
? "Bestätigt"
: "Geplant"}
{eintrag.is_variable && " · geschätzt"}
</p>
</div>
<span
className={cn(
"shrink-0 tabular text-sm font-medium",
eintrag.kind === "income" ? "text-positive" : "text-ink",
)}
>
{eintrag.kind === "income" ? "+" : ""}
{formatMoney(eintrag.amount)}
</span>
{bestaetigbar && (
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={onConfirm}
disabled={busy}
aria-label={`${eintrag.title} bestätigen`}
>
<Check aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onSkip}
disabled={busy}
aria-label={`${eintrag.title} auslassen`}
>
<SkipForward aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
)}
</li>
);
}
+262
View File
@@ -0,0 +1,262 @@
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
import { useState } from "react";
import { Link } from "react-router-dom";
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { BudgetMeter } from "@/components/charts/BudgetMeter";
import { ChartCard, DeltaHint, StatTile } from "@/components/charts/ChartFrame";
import { ForecastChart } from "@/components/charts/ForecastChart";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { useMerchants } from "@/hooks/useEntities";
import { useDashboard } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
export function DashboardPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { data, isLoading } = useDashboard(monat);
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
return (
<>
<PageHeader
title="Dashboard"
description={formatMonth(monat)}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
</Button>
</div>
}
/>
{isLoading || !data ? (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-24" />
))}
</div>
<Skeleton className="h-72" />
</div>
) : (
<div className="space-y-4">
{/* Die große Kennzahl steht allein sie ist die Antwort auf die
häufigste Frage und braucht kein Diagramm. */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatTile
label="Verfügbar nach Fixkosten"
value={formatMoney(data.month.available_after_fixed)}
large
tone={
Number.parseFloat(data.month.available_after_fixed) < 0 ? "negative" : "positive"
}
hint={`Einkünfte ${formatMoney(data.month.planned.income)} abzüglich Fixkosten ${formatMoney(
data.month.fixed_costs,
)}${
data.month.reserves !== "0.00"
? ` und Rücklagen ${formatMoney(data.month.reserves)}`
: ""
}`}
/>
<StatTile
label="Einkünfte im Monat"
value={formatMoney(data.month.planned.income)}
hint={<DeltaHint value={data.month.delta_to_previous.income} />}
/>
<StatTile
label="Ausgaben im Monat"
value={formatMoney(data.month.planned.expenses)}
hint={
<DeltaHint value={data.month.delta_to_previous.expenses} goodWhenPositive={false} />
}
/>
<StatTile
label="Kontostand heute"
value={formatMoney(data.total_balance)}
hint={`${data.month.open_count} offen, ${data.month.confirmed_count} bestätigt`}
/>
</div>
{data.upcoming_deadlines.length > 0 && (
<div className="card border-warning/40 p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
<AlertTriangle aria-hidden className="h-4 w-4" />
Kündigungsfristen laufen ab
</h2>
<ul className="mt-2 space-y-1">
{data.upcoming_deadlines.map((eintrag) => (
<li key={eintrag.recurrence_id} className="flex flex-wrap gap-x-2 text-xs">
<span className="font-medium text-ink">{eintrag.title}</span>
<span className="text-muted">
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
</span>
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
</li>
))}
</ul>
</div>
)}
<div className="grid gap-3 lg:grid-cols-2">
<CategoryDonut
categories={data.categories}
description={`Ausgaben im ${formatMonth(monat)}. Klick auf eine Kategorie zeigt die Unterkategorien.`}
/>
<ChartCard
title="Nächste Fälligkeiten"
description="Die kommenden 14 Tage."
>
{data.upcoming.length === 0 ? (
<p className="py-8 text-center text-xs text-muted">
In den nächsten zwei Wochen steht nichts an.
</p>
) : (
<ul className="divide-y divide-line">
{data.upcoming.slice(0, 8).map((eintrag, index) => {
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
return (
<li
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
className="flex items-center gap-2.5 py-2"
>
<MerchantLogo
merchant={
firma ?? {
name: eintrag.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={28}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{eintrag.title}</p>
<p className="truncate text-xs text-faint">
{kategorieName(eintrag.category_id)}
</p>
</div>
<span
className={`shrink-0 tabular text-sm ${
eintrag.kind === "income" ? "text-positive" : "text-ink"
}`}
>
{eintrag.kind === "income" ? "+" : ""}
{formatMoney(eintrag.amount)}
</span>
</li>
);
})}
</ul>
)}
<Link
to="/calendar"
className="mt-2 inline-block text-xs text-accent transition hover:underline"
>
Zum Cashflow-Kalender
</Link>
</ChartCard>
</div>
<ForecastChart months={data.forecast} />
<div className="grid gap-3 lg:grid-cols-2">
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Budgets</h2>
{data.budgets.length === 0 ? (
<EmptyState
icon={Wallet}
title="Keine Budgets für diesen Monat"
description="Lege ein Budget an, um den Verbrauch je Kategorie im Blick zu behalten."
action={
<Link to="/budgets">
<Button variant="primary">Budgets pflegen</Button>
</Link>
}
/>
) : (
<ul className="space-y-2">
{data.budgets.map((budget) => (
<BudgetMeter key={budget.category_id} budget={budget} />
))}
</ul>
)}
</section>
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Sparziele</h2>
{data.goals.length === 0 ? (
<EmptyState
icon={PiggyBank}
title="Keine Sparziele"
description="Lege ein Ziel an moneyfy rechnet die nötige Monatsrate aus."
action={
<Link to="/goals">
<Button variant="primary">Sparziel anlegen</Button>
</Link>
}
/>
) : (
<ul className="space-y-2">
{data.goals.map((ziel) => (
<li key={ziel.goal_id} className="card p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-ink">{ziel.name}</span>
<span className="shrink-0 tabular text-sm text-ink">
{formatMoney(ziel.current_amount)}
<span className="text-faint"> / {formatMoney(ziel.target_amount)}</span>
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-raised">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(ziel.ratio, 1) * 100}%`,
backgroundColor: ziel.color,
}}
/>
</div>
<p className="mt-1.5 text-xs text-muted">
{ziel.target_date
? `Bis ${formatDate(ziel.target_date)} (${relativeDays(ziel.target_date)}) · ${formatMoney(
ziel.required_monthly,
)} pro Monat nötig`
: "Ohne Zieldatum"}
</p>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)}
</>
);
}
+306
View File
@@ -0,0 +1,306 @@
/** Sparziele mit Fortschritt und nötiger Monatsrate. */
import { type FormEvent, useState } from "react";
import { AlertTriangle, CheckCircle2, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
import { AccountSelect } from "@/components/EntitySelects";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Field, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useDeleteGoal, useSaveGoal, useSavingsGoals } from "@/hooks/useBudgets";
import { useGoalProgress } from "@/hooks/useReports";
import { formatDate, formatMoney, relativeDays } from "@/lib/format";
import type { SavingsGoal, SavingsGoalProgress } from "@/types/api";
export function GoalsPage() {
const [bearbeiten, setBearbeiten] = useState<SavingsGoal | null | undefined>(undefined);
const [loeschen, setLoeschen] = useState<SavingsGoal | null>(null);
const { data: ziele = [], isLoading } = useSavingsGoals();
const { data: fortschritt = [] } = useGoalProgress();
const entfernen = useDeleteGoal();
const nachId = new Map(fortschritt.map((eintrag) => [eintrag.goal_id, eintrag]));
return (
<>
<PageHeader
title="Sparziele"
description="Fortschritt und die Rate, die bis zum Zieldatum nötig ist."
actions={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Sparziel
</Button>
}
/>
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} className="h-40" />
))}
</div>
) : ziele.length === 0 ? (
<EmptyState
icon={PiggyBank}
title="Noch keine Sparziele"
description="Ob neues Fahrrad oder Rücklage fürs Auto moneyfy rechnet aus, was du monatlich zurücklegen musst."
action={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Sparziel anlegen
</Button>
}
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{ziele.map((ziel) => (
<GoalCard
key={ziel.id}
goal={ziel}
progress={nachId.get(ziel.id)}
onEdit={() => setBearbeiten(ziel)}
onDelete={() => setLoeschen(ziel)}
/>
))}
</ul>
)}
<GoalDialog
goal={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Sparziel löschen"
description={`${loeschen?.name}“ wird endgültig gelöscht.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</>
);
}
function GoalCard({
goal,
progress,
onEdit,
onDelete,
}: {
goal: SavingsGoal;
progress: SavingsGoalProgress | undefined;
onEdit: () => void;
onDelete: () => void;
}) {
const anteil = Math.min(progress?.ratio ?? 0, 1);
const erreicht = anteil >= 1;
return (
<li className="card group p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-ink">{goal.name}</h3>
<p className="text-xs text-muted">
{goal.target_date
? `Ziel bis ${formatDate(goal.target_date)} · ${relativeDays(goal.target_date)}`
: "Ohne Zieldatum"}
</p>
</div>
<div className="flex shrink-0 gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${goal.name} bearbeiten`}>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${goal.name} löschen`}>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="mt-3 tabular text-2xl font-semibold text-ink">
{formatMoney(goal.current_amount)}
<span className="text-base font-normal text-faint"> / {formatMoney(goal.target_amount)}</span>
</p>
<div
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
role="meter"
aria-valuenow={Math.round(anteil * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${goal.name}: ${Math.round(anteil * 100)} Prozent erreicht`}
>
<div
className="h-full rounded-full transition-all"
style={{ width: `${anteil * 100}%`, backgroundColor: goal.color }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="text-muted">{Math.round(anteil * 100)} % erreicht</span>
{erreicht ? (
<Badge tone="positive">
<CheckCircle2 aria-hidden className="h-3 w-3" />
Ziel erreicht
</Badge>
) : (
progress?.required_monthly && (
<span className="text-muted">
{formatMoney(progress.required_monthly)} pro Monat nötig
{progress.months_left !== null && ` (${progress.months_left} Monate)`}
</span>
)
)}
</div>
{progress?.is_on_track === false && (
<p className="mt-2 flex items-center gap-1.5 rounded-lg bg-warning/10 px-2 py-1.5 text-xs text-warning">
<AlertTriangle aria-hidden className="h-3.5 w-3.5 shrink-0" />
Die geplante Rate von {formatMoney(goal.monthly_contribution)} reicht nicht bis zum
Zieldatum.
</p>
)}
</li>
);
}
function GoalDialog({
goal,
open,
onClose,
}: {
goal: SavingsGoal | null | undefined;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveGoal();
const [name, setName] = useState("");
const [ziel, setZiel] = useState("");
const [stand, setStand] = useState("0.00");
const [zieldatum, setZieldatum] = useState("");
const [rate, setRate] = useState("");
const [konto, setKonto] = useState<number | null>(null);
const [farbe, setFarbe] = useState("#10b981");
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (goal?.id ?? null)) {
setName(goal?.name ?? "");
setZiel(goal?.target_amount ?? "");
setStand(goal?.current_amount ?? "0.00");
setZieldatum(goal?.target_date ?? "");
setRate(goal?.monthly_contribution ?? "");
setKonto(goal?.account_id ?? null);
setFarbe(goal?.color ?? "#10b981");
setInitialisiert(goal?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!name.trim() || !ziel) return;
speichern.mutate(
{
id: goal?.id,
daten: {
name: name.trim(),
target_amount: ziel,
current_amount: stand || "0.00",
target_date: zieldatum || null,
monthly_contribution: rate || null,
account_id: konto,
color: farbe,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={goal ? "Sparziel bearbeiten" : "Neues Sparziel"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim() || !ziel}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
<Field label="Name" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
placeholder="Neues Fahrrad"
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Zielbetrag" required>
{(id) => <MoneyInput id={id} value={ziel} onValueChange={setZiel} />}
</Field>
<Field label="Bereits gespart">
{(id) => <MoneyInput id={id} value={stand} onValueChange={setStand} />}
</Field>
<Field label="Zieldatum" hint="Ohne Datum wird keine Rate berechnet.">
{(id) => (
<Input
id={id}
type="date"
value={zieldatum}
onChange={(ereignis) => setZieldatum(ereignis.target.value)}
/>
)}
</Field>
<Field label="Geplante Monatsrate" hint="moneyfy prüft, ob sie ausreicht.">
{(id) => <MoneyInput id={id} value={rate} onValueChange={setRate} />}
</Field>
<Field label="Konto">
{(id) => <AccountSelect id={id} value={konto} onChange={setKonto} />}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
</form>
</Modal>
);
}
+371
View File
@@ -0,0 +1,371 @@
/** Auswertungen: Abos, Kategorien, Jahresvergleich und Export. */
import { useState } from "react";
import { AlertTriangle, Download, Repeat } from "lucide-react";
import { CategoryDonut } from "@/components/charts/CategoryDonut";
import { ChartCard, StatTile } from "@/components/charts/ChartFrame";
import { YearComparisonChart } from "@/components/charts/YearComparisonChart";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Select } from "@/components/ui/Field";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
exportUrl,
useCategoryReport,
useSubscriptions,
useYearComparison,
} from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
import { describeRRule } from "@/lib/rrule";
import type { Subscription } from "@/types/api";
type Reiter = "subscriptions" | "categories" | "year" | "export";
const REITER: { id: Reiter; label: string }[] = [
{ id: "subscriptions", label: "Abos" },
{ id: "categories", label: "Kategorien" },
{ id: "year", label: "Jahresvergleich" },
{ id: "export", label: "Export" },
];
export function ReportsPage() {
const [reiter, setReiter] = useState<Reiter>("subscriptions");
return (
<>
<PageHeader title="Auswertungen" />
<div className="mb-5 flex gap-1 overflow-x-auto border-b border-line" role="tablist">
{REITER.map((eintrag) => (
<button
key={eintrag.id}
type="button"
role="tab"
aria-selected={reiter === eintrag.id}
onClick={() => setReiter(eintrag.id)}
className={cn(
"-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition",
reiter === eintrag.id
? "border-accent text-accent"
: "border-transparent text-muted hover:text-ink",
)}
>
{eintrag.label}
</button>
))}
</div>
{reiter === "subscriptions" && <SubscriptionsTab />}
{reiter === "categories" && <CategoriesTab />}
{reiter === "year" && <YearTab />}
{reiter === "export" && <ExportTab />}
</>
);
}
/* --- Abos ----------------------------------------------------------------- */
type Sortierung = "annual" | "monthly" | "title" | "notice";
function SubscriptionsTab() {
const [sortierung, setSortierung] = useState<Sortierung>("annual");
const { data, isLoading } = useSubscriptions();
const kategorieName = useCategoryLookup();
if (isLoading || !data) return <Skeleton className="h-64" />;
if (data.entries.length === 0) {
return (
<EmptyState
icon={Repeat}
title="Keine laufenden Posten"
description="Sobald wiederkehrende Ausgaben angelegt sind, erscheint hier die Jahresübersicht."
/>
);
}
const sortiert = [...data.entries].sort((links, rechts) => {
switch (sortierung) {
case "monthly":
return toNumber(rechts.monthly_cost) - toNumber(links.monthly_cost);
case "title":
return links.title.localeCompare(rechts.title, "de");
case "notice":
return (links.days_until_notice ?? 99999) - (rechts.days_until_notice ?? 99999);
default:
return toNumber(rechts.annual_cost) - toNumber(links.annual_cost);
}
});
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
<StatTile
label="Laufende Posten"
value={String(data.entries.filter((eintrag) => !eintrag.is_installment).length)}
hint={`zuzüglich ${data.entries.filter((eintrag) => eintrag.is_installment).length} Ratenzahlungen`}
/>
</div>
{data.upcoming_deadlines.length > 0 && (
<div className="card border-warning/40 p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
<AlertTriangle aria-hidden className="h-4 w-4" />
Kündigungsfristen der nächsten 60 Tage
</h2>
<ul className="mt-2 space-y-1 text-xs">
{data.upcoming_deadlines.map((eintrag) => (
<li key={eintrag.recurrence_id} className="flex flex-wrap items-center gap-x-2">
<span className="font-medium text-ink">{eintrag.title}</span>
<span className="text-muted">
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
</span>
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
</li>
))}
</ul>
</div>
)}
<ChartCard
title="Alle laufenden Posten"
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
actions={
<Select
aria-label="Sortierung"
className="h-9 w-44"
value={sortierung}
onChange={(ereignis) => setSortierung(ereignis.target.value as Sortierung)}
>
<option value="annual">Nach Jahreskosten</option>
<option value="monthly">Nach Monatskosten</option>
<option value="title">Nach Name</option>
<option value="notice">Nach Kündigungsfrist</option>
</Select>
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<caption className="sr-only">Laufende Posten mit Jahreskosten</caption>
<thead className="border-b border-line text-left text-xs text-muted">
<tr>
<th scope="col" className="py-2 font-medium">
Posten
</th>
<th scope="col" className="hidden py-2 font-medium md:table-cell">
Rhythmus
</th>
<th scope="col" className="py-2 text-right font-medium">
pro Monat
</th>
<th scope="col" className="py-2 text-right font-medium">
pro Jahr
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{sortiert.map((eintrag) => (
<SubscriptionRow
key={eintrag.recurrence_id}
entry={eintrag}
categoryName={kategorieName(eintrag.category_id)}
/>
))}
</tbody>
</table>
</div>
</ChartCard>
</div>
);
}
function SubscriptionRow({
entry,
categoryName,
}: {
entry: Subscription;
categoryName: string;
}) {
return (
<tr>
<td className="py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-ink">{entry.title}</span>
{entry.is_installment && <Badge tone="accent">Raten</Badge>}
{entry.is_cancelled && <Badge tone="negative">Gekündigt</Badge>}
{entry.days_until_notice !== null && entry.days_until_notice <= 60 && (
<Badge tone="warning">Frist in {entry.days_until_notice} Tagen</Badge>
)}
</div>
<p className="text-xs text-faint">
{entry.merchant_name ? `${entry.merchant_name} · ` : ""}
{categoryName}
</p>
</td>
<td className="hidden py-2 text-xs text-muted md:table-cell">
{describeRRule(entry.rrule)}
</td>
<td className="py-2 text-right tabular text-muted">{formatMoney(entry.monthly_cost)}</td>
<td className="py-2 text-right tabular font-medium text-ink">
{formatMoney(entry.annual_cost)}
</td>
</tr>
);
}
/* --- Kategorien ----------------------------------------------------------- */
function CategoriesTab() {
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
const heute = new Date();
const von =
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
const bis =
zeitraum === "month"
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
: `${heute.getFullYear()}-12-31`;
const { data, isLoading } = useCategoryReport(von, bis);
if (isLoading || !data) return <Skeleton className="h-72" />;
return (
<div className="space-y-3">
<div className="flex gap-1">
{(
[
["month", formatMonth(von)],
["year", String(heute.getFullYear())],
] as const
).map(([wert, beschriftung]) => (
<Button
key={wert}
size="sm"
variant={zeitraum === wert ? "primary" : "secondary"}
onClick={() => setZeitraum(wert)}
>
{beschriftung}
</Button>
))}
</div>
<CategoryDonut
categories={data.categories}
title="Ausgaben nach Kategorien"
description="Klick auf eine Kategorie zeigt die Unterkategorien."
/>
</div>
);
}
/* --- Jahresvergleich ------------------------------------------------------ */
function YearTab() {
const [jahr, setJahr] = useState(() => new Date().getFullYear());
const { data, isLoading } = useYearComparison(jahr);
return (
<div className="space-y-3">
<Select
aria-label="Jahr"
className="h-9 w-32"
value={jahr}
onChange={(ereignis) => setJahr(Number(ereignis.target.value))}
>
{Array.from({ length: 6 }, (_, index) => new Date().getFullYear() - index).map((wert) => (
<option key={wert} value={wert}>
{wert}
</option>
))}
</Select>
{isLoading || !data ? <Skeleton className="h-72" /> : <YearComparisonChart data={data} />}
</div>
);
}
/* --- Export --------------------------------------------------------------- */
function ExportTab() {
const [monat, setMonat] = useState(() => firstOfMonth());
const jahr = new Date().getFullYear();
return (
<div className="grid gap-3 lg:grid-cols-3">
<ExportCard
title="Buchungen"
description={`Alle einmaligen Buchungen des Jahres ${jahr}.`}
links={[
{ label: "CSV", href: exportUrl("transactions", "csv") },
{ label: "Excel", href: exportUrl("transactions", "xlsx") },
]}
/>
<ExportCard
title="Wiederkehrende Posten"
description="Alle Serien mit Rhythmus, Jahreskosten und Vertragsdaten."
links={[
{ label: "CSV", href: exportUrl("recurrences", "csv") },
{ label: "Excel", href: exportUrl("recurrences", "xlsx") },
]}
/>
<ExportCard
title="Monatsauswertung"
description="Alle Bewegungen des Monats samt Kennzahlen."
extra={
<input
type="month"
aria-label="Monat der Auswertung"
value={monat.slice(0, 7)}
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
/>
}
links={[
{ label: "CSV", href: exportUrl("month", "csv", { month: monat }) },
{ label: "Excel", href: exportUrl("month", "xlsx", { month: monat }) },
]}
/>
</div>
);
}
function ExportCard({
title,
description,
links,
extra,
}: {
title: string;
description: string;
links: { label: string; href: string }[];
extra?: React.ReactNode;
}) {
return (
<section className="card flex flex-col p-4">
<h2 className="text-sm font-semibold text-ink">{title}</h2>
<p className="mb-3 mt-0.5 text-xs text-muted">{description}</p>
{extra}
<div className="mt-auto flex gap-2">
{links.map((verweis) => (
<a
key={verweis.label}
href={verweis.href}
download
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-raised px-3 text-xs font-medium text-ink transition hover:bg-line"
>
<Download aria-hidden className="h-3.5 w-3.5" />
{verweis.label}
</a>
))}
</div>
</section>
);
}
+42
View File
@@ -21,3 +21,45 @@ Object.defineProperty(window, "matchMedia", {
dispatchEvent: () => false,
}),
});
// jsdom rechnet kein Layout: ohne diese Ergänzungen bliebe jedes Diagramm
// 0 Pixel groß und Recharts würde bei jedem Test eine Warnung ausgeben.
const TEST_BREITE = 640;
const TEST_HOEHE = 320;
class ResizeObserverStub implements ResizeObserver {
constructor(private readonly rueckruf: ResizeObserverCallback) {}
observe(ziel: Element): void {
const abmessung = { inlineSize: TEST_BREITE, blockSize: TEST_HOEHE };
this.rueckruf(
[
{
target: ziel,
contentRect: { width: TEST_BREITE, height: TEST_HOEHE } as DOMRectReadOnly,
borderBoxSize: [abmessung],
contentBoxSize: [abmessung],
devicePixelContentBoxSize: [abmessung],
} as ResizeObserverEntry,
],
this,
);
}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver ??= ResizeObserverStub;
for (const [eigenschaft, wert] of [
["offsetWidth", TEST_BREITE],
["clientWidth", TEST_BREITE],
["offsetHeight", TEST_HOEHE],
["clientHeight", TEST_HOEHE],
] as const) {
Object.defineProperty(HTMLElement.prototype, eigenschaft, {
configurable: true,
value: wert,
});
}
+199
View File
@@ -315,6 +315,58 @@ export interface Totals {
balance: Money;
}
export interface Budget {
id: number;
category_id: number;
period_month: IsoDate;
limit_amount: Money;
rollover: boolean;
created_at: IsoDateTime;
}
export interface BudgetInput {
category_id: number;
period_month: IsoDate;
limit_amount: Money;
rollover?: boolean;
}
export interface BudgetTemplate {
id: number;
category_id: number;
valid_from: IsoDate;
valid_until: IsoDate | null;
limit_amount: Money;
rollover: boolean;
}
export interface SavingsGoal {
id: number;
name: string;
target_amount: Money;
target_date: IsoDate | null;
current_amount: Money;
account_id: number | null;
monthly_contribution: Money | null;
color: string;
icon: string;
is_archived: boolean;
created_at: IsoDateTime;
updated_at: IsoDateTime;
}
export interface SavingsGoalInput {
name: string;
target_amount: Money;
target_date?: IsoDate | null;
current_amount?: Money;
account_id?: number | null;
monthly_contribution?: Money | null;
color?: string;
icon?: string;
is_archived?: boolean;
}
export interface MonthReport {
month: IsoDate;
planned: Totals;
@@ -330,3 +382,150 @@ export interface MonthReport {
open_count: number;
skipped_count: number;
}
export interface ForecastMonth {
month: IsoDate;
income: Money;
expenses: Money;
balance: Money;
cumulative_balance: Money;
}
export interface Forecast {
months: ForecastMonth[];
total_income: Money;
total_expenses: Money;
}
export interface CategorySlice {
category_id: number;
name: string;
color: string;
icon: string;
amount: Money;
count: number;
children: CategorySlice[];
}
export interface CategoryReport {
date_from: IsoDate;
date_to: IsoDate;
kind: EntryKind;
total: Money;
categories: CategorySlice[];
}
export interface Subscription {
recurrence_id: number;
title: string;
merchant_id: number | null;
merchant_name: string | null;
category_id: number;
amount: Money;
annual_cost: Money;
monthly_cost: Money;
rrule: string;
is_installment: boolean;
is_cancelled: boolean;
contract_term: ContractTerm | null;
days_until_notice: number | null;
}
export interface SubscriptionReport {
entries: Subscription[];
total_annual: Money;
total_monthly: Money;
upcoming_deadlines: Subscription[];
}
export interface YearComparisonRow {
category_id: number;
name: string;
color: string;
current: Money;
previous: Money;
delta: Money;
}
export interface YearComparison {
year: number;
rows: YearComparisonRow[];
current_total: Money;
previous_total: Money;
}
export interface CalendarEntry {
title: string;
kind: EntryKind;
amount: Money;
category_id: number;
merchant_id: number | null;
account_id: number | null;
source: "recurrence" | "transaction";
recurrence_id: number | null;
occurrence_date: IsoDate | null;
status: OccurrenceStatus | null;
is_variable: boolean;
}
export interface CalendarDay {
date: IsoDate;
entries: CalendarEntry[];
net: Money;
running_balance: Money;
is_business_day: boolean;
}
export interface CalendarMonth {
month: IsoDate;
days: CalendarDay[];
opening_balance: Money;
closing_balance: Money;
lowest_balance: Money;
lowest_balance_on: IsoDate | null;
}
export type BudgetState = "ok" | "warning" | "exceeded";
export interface BudgetStatus {
category_id: number;
category_name: string;
color: string;
period_month: IsoDate;
limit_amount: Money;
carried_over: Money;
available: Money;
spent: Money;
remaining: Money;
ratio: number;
state: BudgetState;
rollover: boolean;
is_template: boolean;
}
export interface SavingsGoalProgress {
goal_id: number;
name: string;
color: string;
icon: string;
target_amount: Money;
current_amount: Money;
remaining_amount: Money;
ratio: number;
target_date: IsoDate | null;
months_left: number | null;
required_monthly: Money | null;
monthly_contribution: Money | null;
is_on_track: boolean | null;
}
export interface Dashboard {
month: MonthReport;
total_balance: Money;
forecast: ForecastMonth[];
categories: CategorySlice[];
budgets: BudgetStatus[];
goals: SavingsGoalProgress[];
upcoming: CalendarEntry[];
upcoming_deadlines: Subscription[];
}
+10
View File
@@ -22,6 +22,16 @@ export default defineConfig({
build: {
outDir: "dist",
sourcemap: false,
rollupOptions: {
output: {
// Recharts wiegt mehr als der Rest der Anwendung und ändert sich selten
// als eigener Chunk bleibt er über Releases hinweg im Browser-Cache.
manualChunks: {
charts: ["recharts"],
vendor: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
},
},
},
},
test: {
globals: true,