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
307 lines
9.7 KiB
TypeScript
307 lines
9.7 KiB
TypeScript
/** 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>
|
||
);
|
||
}
|