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
+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>
);
}