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