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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user