Files
moneyfy/frontend/src/pages/GoalsPage.tsx
T
moneyfyandClaude Opus 5 0adf154049 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
2026-09-09 16:50:22 +02:00

307 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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>
);
}