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