Files
moneyfy/frontend/src/pages/ReportsPage.tsx
T
Jonas MenzelandClaude Opus 5 0a6261fc55
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s
feat: einstellbarer Monatsbeginn zum Gehaltstag
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag
ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom
Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats,
in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis
zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den
Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint.

Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der
Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten.

Die Einstellung liegt in einer einzeiligen Tabelle hinter
GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich
period_start und period_end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
2026-09-10 09:52:51 +02:00

383 lines
13 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.
/** 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 { useMonthStartDay } from "@/hooks/useAppSettings";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
exportUrl,
useCategoryReport,
useSubscriptions,
useYearComparison,
} from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { formatDate, formatMoney, toNumber } from "@/lib/format";
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
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: "Laufende Kosten" },
{ 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="Laufende Kosten p. a."
value={formatMoney(data.total_annual)}
large
hint="Alle wiederkehrenden Ausgaben ohne Ratenzahlungen."
/>
<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="Jede wiederkehrende Ausgabe mit ihren Jahreskosten von der Miete bis
zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht in die
Gesamtsumme, weil sie enden."
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 startDay = useMonthStartDay();
const heute = new Date();
const laufend = periodKey(startDay);
const grenzen = periodBounds(startDay, laufend);
const von = zeitraum === "month" ? grenzen.start : `${heute.getFullYear()}-01-01`;
const bis = zeitraum === "month" ? grenzen.end : `${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", periodLabel(startDay, laufend)],
["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 startDay = useMonthStartDay();
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const monat = gewaehlt ?? periodKey(startDay);
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 samt Kennzahlen: ${periodLabel(startDay, monat)}.`}
extra={
<input
type="month"
aria-label="Monat der Auswertung"
value={monat.slice(0, 7)}
onChange={(ereignis) => setGewaehlt(`${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>
);
}