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:
+11
-2
@@ -5,8 +5,13 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { Skeleton } from "@/components/ui/Feedback";
|
||||
import { useMe } from "@/hooks/useAuth";
|
||||
import { BudgetsPage } from "@/pages/BudgetsPage";
|
||||
import { CalendarPage } from "@/pages/CalendarPage";
|
||||
import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { GoalsPage } from "@/pages/GoalsPage";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { ReportsPage } from "@/pages/ReportsPage";
|
||||
import { MerchantsPage } from "@/pages/MerchantsPage";
|
||||
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
||||
import { SettingsPage } from "@/pages/SettingsPage";
|
||||
@@ -31,12 +36,16 @@ export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<Navigate to="/recurrences" replace />} />
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/recurrences" element={<RecurrencesPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/merchants" element={<MerchantsPage />} />
|
||||
<Route path="/budgets" element={<BudgetsPage />} />
|
||||
<Route path="/goals" element={<GoalsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/recurrences" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Tests der Budget-Ampel. */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import type { BudgetStatus } from "@/types/api";
|
||||
|
||||
function budget(overrides: Partial<BudgetStatus> = {}): BudgetStatus {
|
||||
return {
|
||||
category_id: 11,
|
||||
category_name: "Lebensmittel",
|
||||
color: "#84cc16",
|
||||
period_month: "2026-03-01",
|
||||
limit_amount: "400.00",
|
||||
carried_over: "0.00",
|
||||
available: "400.00",
|
||||
spent: "100.00",
|
||||
remaining: "300.00",
|
||||
ratio: 0.25,
|
||||
state: "ok",
|
||||
rollover: false,
|
||||
is_template: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rendern(status: BudgetStatus) {
|
||||
return render(
|
||||
<ul>
|
||||
<BudgetMeter budget={status} />
|
||||
</ul>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BudgetMeter", () => {
|
||||
it("zeigt Verbrauch, Rest und Prozentwert", () => {
|
||||
rendern(budget());
|
||||
|
||||
expect(screen.getByText("Lebensmittel")).toBeInTheDocument();
|
||||
expect(screen.getByText(/100,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/400,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/300,00.*übrig/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trägt den Zustand nicht allein über die Farbe", () => {
|
||||
rendern(budget({ ratio: 0.9, state: "warning", spent: "360.00", remaining: "40.00" }));
|
||||
|
||||
// Prozentwert und Wortlaut stehen neben dem Symbol.
|
||||
expect(screen.getByText(/90 %/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/fast ausgeschöpft/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet eine Überschreitung mit dem Fehlbetrag", () => {
|
||||
rendern(
|
||||
budget({ ratio: 1.15, state: "exceeded", spent: "460.00", remaining: "-60.00" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/überschritten/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/60,00.*zu viel/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stellt den Fortschritt auch für Screenreader bereit", () => {
|
||||
rendern(budget({ ratio: 0.25 }));
|
||||
|
||||
const messgeraet = screen.getByRole("meter");
|
||||
expect(messgeraet).toHaveAttribute("aria-valuenow", "25");
|
||||
expect(messgeraet).toHaveAccessibleName(/Lebensmittel: 25 Prozent verbraucht/);
|
||||
});
|
||||
|
||||
it("nennt Übertrag und Herkunft aus einer Vorlage", () => {
|
||||
rendern(budget({ carried_over: "50.00", available: "450.00", is_template: true }));
|
||||
|
||||
expect(screen.getByText(/Übertrag aus Vormonaten: 50,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Aus einer Vorlage/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Budget-Ampel.
|
||||
*
|
||||
* Der Zustand wird nie allein über die Farbe getragen: jede Zeile führt ein
|
||||
* Symbol, einen Wortlaut und den Prozentwert mit.
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert } from "lucide-react";
|
||||
|
||||
import { STATUS } from "@/components/charts/chartTheme";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { BudgetState, BudgetStatus } from "@/types/api";
|
||||
|
||||
const ZUSTAND: Record<
|
||||
BudgetState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2; klasse: string }
|
||||
> = {
|
||||
ok: { label: "im Rahmen", color: STATUS.good, icon: CheckCircle2, klasse: "text-positive" },
|
||||
warning: {
|
||||
label: "fast ausgeschöpft",
|
||||
color: STATUS.warning,
|
||||
icon: AlertTriangle,
|
||||
klasse: "text-warning",
|
||||
},
|
||||
exceeded: {
|
||||
label: "überschritten",
|
||||
color: STATUS.critical,
|
||||
icon: CircleAlert,
|
||||
klasse: "text-negative",
|
||||
},
|
||||
};
|
||||
|
||||
export function BudgetMeter({ budget }: { budget: BudgetStatus }) {
|
||||
const zustand = ZUSTAND[budget.state];
|
||||
const Symbol = zustand.icon;
|
||||
const anteil = Math.min(budget.ratio, 1);
|
||||
const ueberzogen = budget.ratio > 1;
|
||||
|
||||
return (
|
||||
<li className="card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: budget.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-ink">{budget.category_name}</span>
|
||||
</div>
|
||||
<span className="shrink-0 tabular text-sm text-ink">
|
||||
{formatMoney(budget.spent)}
|
||||
<span className="text-faint"> / {formatMoney(budget.available)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
|
||||
role="meter"
|
||||
aria-valuenow={Math.round(budget.ratio * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${budget.category_name}: ${Math.round(budget.ratio * 100)} Prozent verbraucht`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${anteil * 100}%`, backgroundColor: zustand.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs">
|
||||
<span className={cn("flex items-center gap-1", zustand.klasse)}>
|
||||
<Symbol aria-hidden className="h-3.5 w-3.5" />
|
||||
{Math.round(budget.ratio * 100)} % · {zustand.label}
|
||||
</span>
|
||||
<span className={ueberzogen ? "text-negative" : "text-muted"}>
|
||||
{ueberzogen
|
||||
? `${formatMoney(Math.abs(toNumber(budget.remaining)))} zu viel`
|
||||
: `${formatMoney(budget.remaining)} übrig`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(budget.carried_over !== "0.00" || budget.is_template) && (
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{budget.carried_over !== "0.00" &&
|
||||
`Übertrag aus Vormonaten: ${formatMoney(budget.carried_over)}. `}
|
||||
{budget.is_template && "Aus einer Vorlage."}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Tests des Kategorien-Rings samt Drilldown. */
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CategoryDonut } from "@/components/charts/CategoryDonut";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import type { CategorySlice } from "@/types/api";
|
||||
|
||||
function slice(
|
||||
id: number,
|
||||
name: string,
|
||||
amount: string,
|
||||
children: CategorySlice[] = [],
|
||||
): CategorySlice {
|
||||
return {
|
||||
category_id: id,
|
||||
name,
|
||||
color: "#84cc16",
|
||||
icon: "circle",
|
||||
amount,
|
||||
count: 1,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
const KATEGORIEN: CategorySlice[] = [
|
||||
slice(1, "Wohnen", "950.00", [slice(11, "Miete", "900.00"), slice(12, "Strom", "50.00")]),
|
||||
slice(2, "Lebenshaltung", "320.00", [slice(21, "Lebensmittel", "320.00")]),
|
||||
slice(3, "Mobilität", "130.00"),
|
||||
];
|
||||
|
||||
describe("CategoryDonut", () => {
|
||||
it("zeigt Summe, Anteile und Beträge im Klartext", () => {
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
|
||||
|
||||
// Die Zuordnung hängt nie allein an der Farbe.
|
||||
expect(screen.getByText("Wohnen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.400,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText("68 %")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blättert per Klick in die Unterkategorien und wieder zurück", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} title="Ausgaben" />);
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: /Wohnen/ }));
|
||||
|
||||
expect(screen.getByText("Ausgaben · Wohnen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Miete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Strom")).toBeInTheDocument();
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: "Zurück" }));
|
||||
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet keinen Drilldown bei nur einer Unterkategorie", () => {
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
|
||||
|
||||
// Lebenshaltung hat nur ein Kind – ein Aufklappen brächte nichts.
|
||||
expect(screen.queryByRole("button", { name: /Lebenshaltung/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fasst mehr als sechs Kategorien zusammen", () => {
|
||||
const viele = Array.from({ length: 9 }, (_, index) =>
|
||||
slice(index + 1, `Kategorie ${index + 1}`, String((9 - index) * 10)),
|
||||
);
|
||||
|
||||
renderWithProviders(<CategoryDonut categories={viele} />);
|
||||
|
||||
expect(screen.getByText("Weitere (3)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Kategorie 8")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet einen leeren Zeitraum verständlich", () => {
|
||||
renderWithProviders(<CategoryDonut categories={[]} />);
|
||||
|
||||
expect(screen.getByText(/keine Bewegungen/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Aufteilung nach Kategorien mit Drilldown auf die Unterkategorien.
|
||||
*
|
||||
* Die Farben stammen aus den Kategorien selbst – sie sind dort die Identität
|
||||
* und tauchen in der ganzen Oberfläche auf. Damit die Zuordnung nicht allein an
|
||||
* der Farbe hängt, trägt jede Zeile daneben Name und Betrag.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip } from "@/components/charts/ChartFrame";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import type { CategorySlice } from "@/types/api";
|
||||
|
||||
/** Mehr Segmente lassen sich im Ring nicht mehr auseinanderhalten. */
|
||||
const MAX_SEGMENTE = 6;
|
||||
const REST_FARBE = "#94a3b8";
|
||||
|
||||
interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
amount: number;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
function zuSegmenten(slices: CategorySlice[]): Segment[] {
|
||||
const sortiert = [...slices].sort((links, rechts) => toNumber(rechts.amount) - toNumber(links.amount));
|
||||
const sichtbar = sortiert.slice(0, MAX_SEGMENTE);
|
||||
const rest = sortiert.slice(MAX_SEGMENTE);
|
||||
|
||||
const segmente: Segment[] = sichtbar.map((slice) => ({
|
||||
id: slice.category_id,
|
||||
name: slice.name,
|
||||
color: slice.color,
|
||||
amount: toNumber(slice.amount),
|
||||
hasChildren: slice.children.length > 1,
|
||||
}));
|
||||
|
||||
if (rest.length > 0) {
|
||||
segmente.push({
|
||||
id: -1,
|
||||
name: `Weitere (${rest.length})`,
|
||||
color: REST_FARBE,
|
||||
amount: rest.reduce((summe, slice) => summe + toNumber(slice.amount), 0),
|
||||
hasChildren: false,
|
||||
});
|
||||
}
|
||||
return segmente;
|
||||
}
|
||||
|
||||
export function CategoryDonut({
|
||||
categories,
|
||||
title = "Nach Kategorien",
|
||||
description,
|
||||
}: {
|
||||
categories: CategorySlice[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [drilldown, setDrilldown] = useState<CategorySlice | null>(null);
|
||||
|
||||
const quelle = drilldown ? drilldown.children : categories;
|
||||
const segmente = zuSegmenten(quelle);
|
||||
const gesamt = segmente.reduce((summe, segment) => summe + segment.amount, 0);
|
||||
|
||||
if (segmente.length === 0) {
|
||||
return (
|
||||
<ChartCard title={title} description={description}>
|
||||
<p className="py-10 text-center text-xs text-muted">
|
||||
Für diesen Zeitraum gibt es keine Bewegungen.
|
||||
</p>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={drilldown ? `${title} · ${drilldown.name}` : title}
|
||||
description={description}
|
||||
actions={
|
||||
drilldown && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setDrilldown(null)}>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
Zurück
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative h-48 w-full sm:w-48 sm:shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segmente}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
innerRadius="62%"
|
||||
outerRadius="98%"
|
||||
// Zwei Pixel Fläche zwischen den Segmenten statt eines Rahmens.
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
onClick={(eintrag: unknown) => {
|
||||
const segment = eintrag as Segment;
|
||||
const passend = quelle.find((slice) => slice.category_id === segment.id);
|
||||
if (passend && passend.children.length > 1) setDrilldown(passend);
|
||||
}}
|
||||
>
|
||||
{segmente.map((segment) => (
|
||||
<Cell
|
||||
key={segment.id}
|
||||
fill={segment.color}
|
||||
className={segment.hasChildren ? "cursor-pointer" : undefined}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const segment = payload[0]?.payload as Segment;
|
||||
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
|
||||
return (
|
||||
<ChartTooltip
|
||||
title={segment.name}
|
||||
rows={[
|
||||
{ label: "Betrag", value: formatMoney(segment.amount), color: segment.color },
|
||||
{ label: "Anteil", value: `${anteil.toFixed(1)} %` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Die Summe steht in der Mitte – dort sucht man sie. */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[11px] text-muted">Gesamt</span>
|
||||
<span className="tabular text-sm font-semibold text-ink">{formatMoney(gesamt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-1">
|
||||
{segmente.map((segment) => {
|
||||
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
|
||||
const passend = quelle.find((slice) => slice.category_id === segment.id);
|
||||
const aufklappbar = Boolean(passend && passend.children.length > 1);
|
||||
|
||||
const inhalt = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-ink">{segment.name}</span>
|
||||
<span className="shrink-0 tabular text-muted">{anteil.toFixed(0)} %</span>
|
||||
<span className="w-24 shrink-0 text-right tabular font-medium text-ink">
|
||||
{formatMoney(segment.amount)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={segment.id}>
|
||||
{aufklappbar ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => passend && setDrilldown(passend)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs transition hover:bg-raised"
|
||||
>
|
||||
{inhalt}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs">{inhalt}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/** Rahmen, Legende und Tooltip – für alle Diagramme gleich. */
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
legend,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
legend?: LegendItem[];
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("card p-4", className)}>
|
||||
<header className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</header>
|
||||
|
||||
{/* Ab zwei Serien ist die Legende immer vorhanden – Farbe allein trägt nie. */}
|
||||
{legend && legend.length > 1 && (
|
||||
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{legend.map((eintrag) => (
|
||||
<li key={eintrag.label} className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: eintrag.color }}
|
||||
/>
|
||||
{eintrag.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TooltipRow {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Einheitlicher Tooltip; die Werte stehen in Textfarbe, nicht in der Serienfarbe. */
|
||||
export function ChartTooltip({ title, rows }: { title: string; rows: TooltipRow[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface px-3 py-2 shadow-xl">
|
||||
<p className="mb-1 text-xs font-medium text-ink">{title}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{rows.map((zeile) => (
|
||||
<li key={zeile.label} className="flex items-center gap-2 text-xs">
|
||||
{zeile.color && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: zeile.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted">{zeile.label}</span>
|
||||
<span className="ml-auto tabular text-ink">{zeile.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aufklappbare Wertetabelle – die Alternative zum Ablesen aus der Grafik. */
|
||||
export function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
caption,
|
||||
}: {
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<details className="mt-3 text-xs">
|
||||
<summary className="cursor-pointer text-muted transition hover:text-ink">
|
||||
Werte als Tabelle
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<caption className="sr-only">{caption}</caption>
|
||||
<thead className="text-left text-muted">
|
||||
<tr>
|
||||
{columns.map((spalte, index) => (
|
||||
<th
|
||||
key={spalte}
|
||||
scope="col"
|
||||
className={cn("py-1 font-medium", index > 0 && "text-right")}
|
||||
>
|
||||
{spalte}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{rows.map((zeile, zeilenIndex) => (
|
||||
<tr key={zeilenIndex}>
|
||||
{zeile.map((zelle, spaltenIndex) => (
|
||||
<td
|
||||
key={spaltenIndex}
|
||||
className={cn(
|
||||
"py-1",
|
||||
spaltenIndex === 0 ? "text-ink" : "text-right tabular text-muted",
|
||||
)}
|
||||
>
|
||||
{zelle}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kennzahl für Fälle, in denen eine Zahl mehr sagt als ein Diagramm. */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "default",
|
||||
large = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: ReactNode;
|
||||
tone?: "default" | "positive" | "negative";
|
||||
large?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-muted">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 font-semibold",
|
||||
large ? "text-3xl" : "text-xl",
|
||||
tone === "positive" && "text-positive",
|
||||
tone === "negative" && "text-negative",
|
||||
tone === "default" && "text-ink",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{hint && <div className="mt-1 text-xs text-muted">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Veränderung gegenüber einem Vergleichswert, mit Vorzeichen und Wortlaut. */
|
||||
export function DeltaHint({
|
||||
value,
|
||||
goodWhenPositive = true,
|
||||
suffix = "ggü. Vormonat",
|
||||
}: {
|
||||
value: string;
|
||||
goodWhenPositive?: boolean;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const zahl = Number.parseFloat(value);
|
||||
if (!Number.isFinite(zahl) || zahl === 0) {
|
||||
return <span className="text-faint">unverändert {suffix}</span>;
|
||||
}
|
||||
const gut = zahl > 0 === goodWhenPositive;
|
||||
return (
|
||||
<span className={gut ? "text-positive" : "text-negative"}>
|
||||
{zahl > 0 ? "▲" : "▼"} {formatMoney(Math.abs(zahl))} {suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Zwölf-Monats-Vorschau.
|
||||
*
|
||||
* Bewusst zwei Diagramme untereinander statt zweier Größenachsen in einem Bild:
|
||||
* Monatsbeträge und kumulierter Kontostand haben verschiedene Größenordnungen,
|
||||
* eine gemeinsame Achse würde einen Zusammenhang vortäuschen.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
|
||||
import {
|
||||
AXIS_PROPS,
|
||||
BAR_GAP,
|
||||
BAR_RADIUS,
|
||||
GRID_PROPS,
|
||||
SERIES,
|
||||
} from "@/components/charts/chartTheme";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import type { ForecastMonth } from "@/types/api";
|
||||
|
||||
const MONATSKUERZEL = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mär",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Dez",
|
||||
];
|
||||
|
||||
interface Punkt {
|
||||
label: string;
|
||||
monat: string;
|
||||
einkuenfte: number;
|
||||
ausgaben: number;
|
||||
saldo: number;
|
||||
kumuliert: number;
|
||||
}
|
||||
|
||||
function kompakt(wert: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert);
|
||||
}
|
||||
|
||||
export function ForecastChart({ months }: { months: ForecastMonth[] }) {
|
||||
const punkte: Punkt[] = months.map((monat) => {
|
||||
const datum = new Date(monat.month);
|
||||
return {
|
||||
label: `${MONATSKUERZEL[datum.getMonth()]} ${String(datum.getFullYear()).slice(2)}`,
|
||||
monat: monat.month,
|
||||
einkuenfte: toNumber(monat.income),
|
||||
ausgaben: toNumber(monat.expenses),
|
||||
saldo: toNumber(monat.balance),
|
||||
kumuliert: toNumber(monat.cumulative_balance),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ChartCard
|
||||
title="Einnahmen und Ausgaben"
|
||||
description="Jährliche Posten stehen in ihrem echten Fälligkeitsmonat."
|
||||
legend={[
|
||||
{ label: "Einkünfte", color: SERIES.one },
|
||||
{ label: "Ausgaben", color: SERIES.two },
|
||||
]}
|
||||
>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={punkte} barGap={BAR_GAP} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: "Einkünfte",
|
||||
value: formatMoney(payload[0]?.payload.einkuenfte ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
{
|
||||
label: "Ausgaben",
|
||||
value: formatMoney(payload[0]?.payload.ausgaben ?? 0),
|
||||
color: SERIES.two,
|
||||
},
|
||||
{
|
||||
label: "Saldo",
|
||||
value: formatMoney(payload[0]?.payload.saldo ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="einkuenfte" name="Einkünfte" fill={SERIES.one} radius={BAR_RADIUS} />
|
||||
<Bar dataKey="ausgaben" name="Ausgaben" fill={SERIES.two} radius={BAR_RADIUS} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
caption="Einnahmen und Ausgaben je Monat"
|
||||
columns={["Monat", "Einkünfte", "Ausgaben", "Saldo"]}
|
||||
rows={punkte.map((punkt) => [
|
||||
punkt.label,
|
||||
formatMoney(punkt.einkuenfte),
|
||||
formatMoney(punkt.ausgaben),
|
||||
formatMoney(punkt.saldo),
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Kumulierter Kontostand"
|
||||
description="Fortgeschrieben aus dem heutigen Saldo aller Konten."
|
||||
>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={punkte} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
|
||||
{/* Die Nulllinie ist die Grenze, auf die es ankommt. */}
|
||||
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: "Kontostand",
|
||||
value: formatMoney(payload[0]?.payload.kumuliert ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="kumuliert"
|
||||
name="Kontostand"
|
||||
stroke={SERIES.one}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 0, fill: SERIES.one }}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Jahresvergleich je Oberkategorie als gruppierte Balken. */
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
|
||||
import {
|
||||
AXIS_PROPS,
|
||||
BAR_GAP,
|
||||
GRID_PROPS,
|
||||
SERIES,
|
||||
} from "@/components/charts/chartTheme";
|
||||
import { formatMoney, formatSignedMoney, toNumber } from "@/lib/format";
|
||||
import type { YearComparison } from "@/types/api";
|
||||
|
||||
function kompakt(wert: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert);
|
||||
}
|
||||
|
||||
export function YearComparisonChart({ data }: { data: YearComparison }) {
|
||||
const punkte = data.rows.map((zeile) => ({
|
||||
name: zeile.name,
|
||||
aktuell: toNumber(zeile.current),
|
||||
vorjahr: toNumber(zeile.previous),
|
||||
delta: toNumber(zeile.delta),
|
||||
}));
|
||||
|
||||
if (punkte.length === 0) {
|
||||
return (
|
||||
<ChartCard title={`Jahresvergleich ${data.year}`}>
|
||||
<p className="py-10 text-center text-xs text-muted">
|
||||
Für {data.year} und {data.year - 1} liegen keine Ausgaben vor.
|
||||
</p>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={`Jahresvergleich ${data.year}`}
|
||||
description={`${formatMoney(data.current_total)} gegenüber ${formatMoney(
|
||||
data.previous_total,
|
||||
)} im Vorjahr.`}
|
||||
legend={[
|
||||
{ label: String(data.year), color: SERIES.one },
|
||||
{ label: String(data.year - 1), color: SERIES.two },
|
||||
]}
|
||||
>
|
||||
<div style={{ height: Math.max(200, punkte.length * 44) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={punkte}
|
||||
layout="vertical"
|
||||
barGap={BAR_GAP}
|
||||
margin={{ top: 4, right: 12, bottom: 0, left: 4 }}
|
||||
>
|
||||
<CartesianGrid {...GRID_PROPS} horizontal={false} vertical />
|
||||
<XAxis type="number" {...AXIS_PROPS} tickFormatter={kompakt} />
|
||||
<YAxis type="category" dataKey="name" {...AXIS_PROPS} width={110} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: String(data.year),
|
||||
value: formatMoney(payload[0]?.payload.aktuell ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
{
|
||||
label: String(data.year - 1),
|
||||
value: formatMoney(payload[0]?.payload.vorjahr ?? 0),
|
||||
color: SERIES.two,
|
||||
},
|
||||
{
|
||||
label: "Veränderung",
|
||||
value: formatSignedMoney(payload[0]?.payload.delta ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="aktuell" name={String(data.year)} fill={SERIES.one} radius={[0, 4, 4, 0]} />
|
||||
<Bar
|
||||
dataKey="vorjahr"
|
||||
name={String(data.year - 1)}
|
||||
fill={SERIES.two}
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
caption={`Ausgaben ${data.year} gegenüber ${data.year - 1}`}
|
||||
columns={["Kategorie", String(data.year), String(data.year - 1), "Veränderung"]}
|
||||
rows={punkte.map((punkt) => [
|
||||
punkt.name,
|
||||
formatMoney(punkt.aktuell),
|
||||
formatMoney(punkt.vorjahr),
|
||||
formatSignedMoney(punkt.delta),
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Gemeinsame Bausteine der Diagramme.
|
||||
*
|
||||
* Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
||||
* Flächen dieser Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde.
|
||||
* Die Reihenfolge ist Teil dieser Absicherung und darf nicht umsortiert werden.
|
||||
*/
|
||||
|
||||
export const SERIES = {
|
||||
/** Einkünfte, laufendes Jahr, kumulierter Saldo. */
|
||||
one: "var(--viz-series-1)",
|
||||
/** Ausgaben, Vorjahr. */
|
||||
two: "var(--viz-series-2)",
|
||||
three: "var(--viz-series-3)",
|
||||
} as const;
|
||||
|
||||
export const CHROME = {
|
||||
grid: "var(--viz-grid)",
|
||||
axis: "var(--viz-axis)",
|
||||
label: "var(--viz-label)",
|
||||
surface: "var(--viz-surface)",
|
||||
} as const;
|
||||
|
||||
export const STATUS = {
|
||||
good: "var(--viz-good)",
|
||||
warning: "var(--viz-warning)",
|
||||
critical: "var(--viz-critical)",
|
||||
} as const;
|
||||
|
||||
/** Achsen und Raster bleiben zurückhaltend: dünne, durchgezogene Linien. */
|
||||
export const AXIS_PROPS = {
|
||||
stroke: CHROME.axis,
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
tick: { fill: CHROME.label, fontSize: 11 },
|
||||
} as const;
|
||||
|
||||
export const GRID_PROPS = {
|
||||
stroke: CHROME.grid,
|
||||
strokeWidth: 1,
|
||||
vertical: false,
|
||||
} as const;
|
||||
|
||||
/** Abgerundete Balkenenden an der Grundlinie. */
|
||||
export const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0];
|
||||
|
||||
/** Zwei Pixel Fläche zwischen benachbarten Balken statt eines Rahmens. */
|
||||
export const BAR_GAP = 2;
|
||||
@@ -1,6 +1,17 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
Repeat,
|
||||
Settings,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -11,9 +22,14 @@ interface NavEintrag {
|
||||
}
|
||||
|
||||
const NAVIGATION: NavEintrag[] = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/calendar", label: "Kalender", icon: CalendarDays },
|
||||
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
|
||||
{ to: "/transactions", label: "Buchungen", icon: Receipt },
|
||||
{ to: "/merchants", label: "Firmen", icon: Building2 },
|
||||
{ to: "/budgets", label: "Budgets", icon: Wallet },
|
||||
{ to: "/goals", label: "Sparziele", icon: PiggyBank },
|
||||
{ to: "/reports", label: "Auswertungen", icon: BarChart3 },
|
||||
{ to: "/settings", label: "Einstellungen", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -35,6 +51,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/** Budgets, Vorlagen und Sparziele als Stammdaten. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "@/store/toast";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetInput,
|
||||
BudgetTemplate,
|
||||
MessageResponse,
|
||||
SavingsGoal,
|
||||
SavingsGoalInput,
|
||||
} from "@/types/api";
|
||||
|
||||
function invalidate(client: ReturnType<typeof useQueryClient>): void {
|
||||
void client.invalidateQueries({ queryKey: ["budgets"] });
|
||||
void client.invalidateQueries({ queryKey: ["budget-templates"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
}
|
||||
|
||||
export function useBudgets(month?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["budgets", month ?? ""],
|
||||
queryFn: () => api.get<Budget[]>("/budgets", month ? { month } : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveBudget() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, daten }: { id?: number; daten: BudgetInput | Partial<BudgetInput> }) =>
|
||||
id ? api.patch<Budget>(`/budgets/${id}`, daten) : api.post<Budget>("/budgets", daten),
|
||||
onSuccess: (_budget, variablen) => {
|
||||
invalidate(client);
|
||||
toast.success(variablen.id ? "Budget gespeichert." : "Budget angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudget() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/budgets/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budget gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBudgetTemplates() {
|
||||
return useQuery({
|
||||
queryKey: ["budget-templates"],
|
||||
queryFn: () => api.get<BudgetTemplate[]>("/budget-templates"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveBudgetTemplate() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (daten: {
|
||||
category_id: number;
|
||||
valid_from: string;
|
||||
limit_amount: string;
|
||||
rollover?: boolean;
|
||||
}) => api.post<BudgetTemplate>("/budget-templates", daten),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budgetvorlage angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudgetTemplate() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/budget-templates/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budgetvorlage gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSavingsGoals(includeArchived = false) {
|
||||
return useQuery({
|
||||
queryKey: ["savings-goals", includeArchived],
|
||||
queryFn: () =>
|
||||
api.get<SavingsGoal[]>(
|
||||
"/savings-goals",
|
||||
includeArchived ? { include_archived: true } : undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveGoal() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
daten,
|
||||
}: {
|
||||
id?: number;
|
||||
daten: SavingsGoalInput | Partial<SavingsGoalInput>;
|
||||
}) =>
|
||||
id
|
||||
? api.patch<SavingsGoal>(`/savings-goals/${id}`, daten)
|
||||
: api.post<SavingsGoal>("/savings-goals", daten),
|
||||
onSuccess: (_ziel, variablen) => {
|
||||
void client.invalidateQueries({ queryKey: ["savings-goals"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
toast.success(variablen.id ? "Sparziel gespeichert." : "Sparziel angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGoal() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/savings-goals/${id}`),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ["savings-goals"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
toast.success("Sparziel gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Abfragen der Auswertungen. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
BudgetStatus,
|
||||
CalendarMonth,
|
||||
CategoryReport,
|
||||
Dashboard,
|
||||
EntryKind,
|
||||
Forecast,
|
||||
MonthReport,
|
||||
SavingsGoalProgress,
|
||||
SubscriptionReport,
|
||||
YearComparison,
|
||||
} from "@/types/api";
|
||||
|
||||
export function useDashboard(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "dashboard", month],
|
||||
queryFn: () => api.get<Dashboard>("/reports/dashboard", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthReport(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "month", month],
|
||||
queryFn: () => api.get<MonthReport>("/reports/month", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useForecast(months = 12, start?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "forecast", months, start ?? ""],
|
||||
queryFn: () => api.get<Forecast>("/reports/forecast", { months, start }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategoryReport(from: string, to: string, kind: EntryKind = "expense") {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "categories", from, to, kind],
|
||||
queryFn: () => api.get<CategoryReport>("/reports/categories", { from, to, kind }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "subscriptions"],
|
||||
queryFn: () => api.get<SubscriptionReport>("/reports/subscriptions"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useYearComparison(year: number, kind: EntryKind = "expense") {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "year-comparison", year, kind],
|
||||
queryFn: () => api.get<YearComparison>("/reports/year-comparison", { year, kind }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCalendar(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "calendar", month],
|
||||
queryFn: () => api.get<CalendarMonth>("/reports/calendar", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBudgetStatus(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "budgets", month],
|
||||
queryFn: () => api.get<BudgetStatus[]>("/reports/budgets", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGoalProgress() {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "savings-goals"],
|
||||
queryFn: () => api.get<SavingsGoalProgress[]>("/reports/savings-goals"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Baut die URL eines Exports; der Browser lädt sie direkt herunter. */
|
||||
export function exportUrl(
|
||||
what: "transactions" | "recurrences" | "month",
|
||||
format: "csv" | "xlsx",
|
||||
params: Record<string, string | number | undefined> = {},
|
||||
): string {
|
||||
const suchparameter = new URLSearchParams({ format });
|
||||
for (const [schluessel, wert] of Object.entries(params)) {
|
||||
if (wert !== undefined && wert !== "") suchparameter.set(schluessel, String(wert));
|
||||
}
|
||||
return `/api/export/${what}?${suchparameter.toString()}`;
|
||||
}
|
||||
@@ -19,6 +19,24 @@
|
||||
--color-warning: 180 83 9;
|
||||
}
|
||||
|
||||
/* Diagrammfarben, heller Modus.
|
||||
Die Reihenfolge der Serienfarben ist die Absicherung gegen Farbfehlsichtigkeit
|
||||
und wurde gegen die Flächen dieser Anwendung geprüft – nicht umsortieren.
|
||||
Grün/Rot wäre als Serienpaar bei Deuteranopie nicht unterscheidbar (ΔE 7,9)
|
||||
und bleibt deshalb den Vorzeichen im Text vorbehalten. */
|
||||
:root {
|
||||
--viz-series-1: #2a78d6;
|
||||
--viz-series-2: #eb6834;
|
||||
--viz-series-3: #1baf7a;
|
||||
--viz-grid: #e2e8f0;
|
||||
--viz-axis: #94a3b8;
|
||||
--viz-label: #64748b;
|
||||
--viz-surface: #ffffff;
|
||||
--viz-good: #0ca30c;
|
||||
--viz-warning: #fab219;
|
||||
--viz-critical: #d03b3b;
|
||||
}
|
||||
|
||||
/* Dunkler Modus – die Vorgabe der Anwendung */
|
||||
.dark {
|
||||
--color-ground: 15 17 21;
|
||||
@@ -33,6 +51,18 @@
|
||||
--color-positive: 74 222 128;
|
||||
--color-negative: 248 113 113;
|
||||
--color-warning: 251 191 36;
|
||||
|
||||
/* Eigene Stufen für die dunkle Fläche, kein automatisches Umdrehen. */
|
||||
--viz-series-1: #3987e5;
|
||||
--viz-series-2: #d95926;
|
||||
--viz-series-3: #199e70;
|
||||
--viz-grid: #2b3038;
|
||||
--viz-axis: #4b5563;
|
||||
--viz-label: #94a3b8;
|
||||
--viz-surface: #181b21;
|
||||
--viz-good: #0ca30c;
|
||||
--viz-warning: #fab219;
|
||||
--viz-critical: #d03b3b;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/** Budgets je Monat samt Ampel und dauerhaften Vorlagen. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { CategorySelect } from "@/components/EntitySelects";
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { Checkbox, Field, Input } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { MoneyInput } from "@/components/ui/MoneyInput";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import {
|
||||
useBudgetTemplates,
|
||||
useBudgets,
|
||||
useDeleteBudget,
|
||||
useDeleteBudgetTemplate,
|
||||
useSaveBudget,
|
||||
useSaveBudgetTemplate,
|
||||
} from "@/hooks/useBudgets";
|
||||
import { useBudgetStatus } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
|
||||
import type { Budget, BudgetTemplate } from "@/types/api";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
|
||||
const [loeschen, setLoeschen] = useState<Budget | null>(null);
|
||||
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
|
||||
|
||||
const { data: status = [], isLoading } = useBudgetStatus(monat);
|
||||
const { data: budgets = [] } = useBudgets(monat);
|
||||
const { data: vorlagen = [] } = useBudgetTemplates();
|
||||
const entfernen = useDeleteBudget();
|
||||
const vorlageEntfernen = useDeleteBudgetTemplate();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Budgets"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setFormular("template")}>Vorlage</Button>
|
||||
<Button variant="primary" onClick={() => setFormular("budget")}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Budget
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : status.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title="Keine Budgets in diesem Monat"
|
||||
description="Ein Budget zeigt, wie viel einer Kategorie noch bleibt. Mit einer Vorlage gilt es dauerhaft ab einem Monat."
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setFormular("budget")}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Erstes Budget anlegen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-2 lg:grid-cols-2">
|
||||
{status.map((eintrag) => (
|
||||
<BudgetMeter key={eintrag.category_id} budget={eintrag} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{budgets.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">
|
||||
Einzelbudgets in diesem Monat
|
||||
</h2>
|
||||
<ul className="divide-y divide-line rounded-card border border-line">
|
||||
{budgets.map((budget) => (
|
||||
<li key={budget.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-ink">
|
||||
{kategorieName(budget.category_id)}
|
||||
</span>
|
||||
{budget.rollover && <Badge>Übertrag</Badge>}
|
||||
<span className="tabular text-muted">{formatMoney(budget.limit_amount)}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => setLoeschen(budget)}
|
||||
aria-label="Budget löschen"
|
||||
>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{vorlagen.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Dauerhafte Vorlagen</h2>
|
||||
<ul className="divide-y divide-line rounded-card border border-line">
|
||||
{vorlagen.map((vorlage) => (
|
||||
<li key={vorlage.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-ink">
|
||||
{kategorieName(vorlage.category_id)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
ab {formatDate(vorlage.valid_from)}
|
||||
{vorlage.valid_until && ` bis ${formatDate(vorlage.valid_until)}`}
|
||||
</span>
|
||||
{vorlage.rollover && <Badge>Übertrag</Badge>}
|
||||
<span className="tabular text-muted">{formatMoney(vorlage.limit_amount)}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => setVorlageLoeschen(vorlage)}
|
||||
aria-label="Vorlage löschen"
|
||||
>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<BudgetDialog
|
||||
kind={formular}
|
||||
month={monat}
|
||||
open={formular !== null}
|
||||
onClose={() => setFormular(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={loeschen !== null}
|
||||
title="Budget löschen"
|
||||
description="Das Budget dieses Monats wird entfernt."
|
||||
loading={entfernen.isPending}
|
||||
onCancel={() => setLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={vorlageLoeschen !== null}
|
||||
title="Vorlage löschen"
|
||||
description="Die Vorlage gilt danach in keinem Monat mehr."
|
||||
loading={vorlageEntfernen.isPending}
|
||||
onCancel={() => setVorlageLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (vorlageLoeschen)
|
||||
vorlageEntfernen.mutate(vorlageLoeschen.id, {
|
||||
onSuccess: () => setVorlageLoeschen(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetDialog({
|
||||
kind,
|
||||
month,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
kind: "budget" | "template" | null;
|
||||
month: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kategorie, setKategorie] = useState<number | null>(null);
|
||||
const [betrag, setBetrag] = useState("");
|
||||
const [uebertrag, setUebertrag] = useState(false);
|
||||
const [gueltigAb, setGueltigAb] = useState(month);
|
||||
|
||||
const budgetSpeichern = useSaveBudget();
|
||||
const vorlageSpeichern = useSaveBudgetTemplate();
|
||||
const laeuft = budgetSpeichern.isPending || vorlageSpeichern.isPending;
|
||||
|
||||
function absenden(ereignis: FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
if (kategorie === null || !betrag) return;
|
||||
|
||||
const fertig = {
|
||||
onSuccess: () => {
|
||||
setKategorie(null);
|
||||
setBetrag("");
|
||||
setUebertrag(false);
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "template") {
|
||||
vorlageSpeichern.mutate(
|
||||
{
|
||||
category_id: kategorie,
|
||||
valid_from: gueltigAb,
|
||||
limit_amount: betrag,
|
||||
rollover: uebertrag,
|
||||
},
|
||||
fertig,
|
||||
);
|
||||
} else {
|
||||
budgetSpeichern.mutate(
|
||||
{
|
||||
daten: {
|
||||
category_id: kategorie,
|
||||
period_month: month,
|
||||
limit_amount: betrag,
|
||||
rollover: uebertrag,
|
||||
},
|
||||
},
|
||||
fertig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
title={kind === "template" ? "Neue Budgetvorlage" : "Neues Budget"}
|
||||
description={
|
||||
kind === "template"
|
||||
? "Gilt ab dem gewählten Monat dauerhaft, bis eine neue Vorlage sie ablöst."
|
||||
: `Gilt nur für ${formatMonth(month)}.`
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={absenden}
|
||||
loading={laeuft}
|
||||
disabled={kategorie === null || !betrag}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={absenden} className="space-y-3">
|
||||
<Field label="Kategorie" required hint="Budgets auf einer Oberkategorie umfassen deren Unterkategorien.">
|
||||
{(id) => (
|
||||
<CategorySelect id={id} required kind="expense" value={kategorie} onChange={setKategorie} />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{kind === "template" && (
|
||||
<Field label="Gültig ab" required>
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="month"
|
||||
value={gueltigAb.slice(0, 7)}
|
||||
onChange={(ereignis) => setGueltigAb(`${ereignis.target.value}-01`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Betrag" required>
|
||||
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
label="Rest in den Folgemonat übertragen"
|
||||
hint="Nicht verbrauchtes Budget erhöht das Budget des nächsten Monats."
|
||||
checked={uebertrag}
|
||||
onChange={(ereignis) => setUebertrag(ereignis.target.checked)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/** Tests des Cashflow-Kalenders. */
|
||||
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CalendarPage } from "@/pages/CalendarPage";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import type { CalendarDay, CalendarEntry } from "@/types/api";
|
||||
|
||||
function eintrag(overrides: Partial<CalendarEntry> = {}): CalendarEntry {
|
||||
return {
|
||||
title: "Miete",
|
||||
kind: "expense",
|
||||
amount: "950.00",
|
||||
category_id: 11,
|
||||
merchant_id: null,
|
||||
account_id: 1,
|
||||
source: "recurrence",
|
||||
recurrence_id: 7,
|
||||
occurrence_date: "2026-03-01",
|
||||
status: "planned",
|
||||
is_variable: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Baut einen vollständigen März 2026 mit zwei belegten Tagen. */
|
||||
function maerz(): CalendarDay[] {
|
||||
return Array.from({ length: 31 }, (_, index) => {
|
||||
const tag = index + 1;
|
||||
const datum = `2026-03-${String(tag).padStart(2, "0")}`;
|
||||
const wochentag = new Date(datum).getDay();
|
||||
|
||||
if (tag === 2) {
|
||||
return {
|
||||
date: datum,
|
||||
entries: [eintrag({ occurrence_date: "2026-03-01" })],
|
||||
net: "-950.00",
|
||||
running_balance: "50.00",
|
||||
is_business_day: true,
|
||||
};
|
||||
}
|
||||
if (tag === 28) {
|
||||
return {
|
||||
date: datum,
|
||||
entries: [
|
||||
eintrag({
|
||||
title: "Gehalt",
|
||||
kind: "income",
|
||||
amount: "3200.00",
|
||||
recurrence_id: 8,
|
||||
occurrence_date: "2026-03-28",
|
||||
}),
|
||||
],
|
||||
net: "3200.00",
|
||||
running_balance: "3250.00",
|
||||
is_business_day: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
date: datum,
|
||||
entries: [],
|
||||
net: "0.00",
|
||||
running_balance: tag < 2 ? "1000.00" : tag < 28 ? "50.00" : "3250.00",
|
||||
is_business_day: wochentag !== 0 && wochentag !== 6,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockApi() {
|
||||
const anfragen: { url: string; method: string; body: unknown }[] = [];
|
||||
|
||||
const json = (daten: unknown) =>
|
||||
new Response(JSON.stringify(daten), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
|
||||
anfragen.push({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
|
||||
});
|
||||
|
||||
if (url.includes("/api/reports/calendar")) {
|
||||
return json({
|
||||
month: "2026-03-01",
|
||||
days: maerz(),
|
||||
opening_balance: "1000.00",
|
||||
closing_balance: "3250.00",
|
||||
lowest_balance: "50.00",
|
||||
lowest_balance_on: "2026-03-02",
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/merchants")) {
|
||||
return json({ items: [], total: 0, limit: 200, offset: 0 });
|
||||
}
|
||||
if (url.includes("/api/occurrences/")) {
|
||||
return json({});
|
||||
}
|
||||
return json({});
|
||||
}),
|
||||
);
|
||||
|
||||
return anfragen;
|
||||
}
|
||||
|
||||
describe("Cashflow-Kalender", () => {
|
||||
beforeEach(() => {
|
||||
mockApi();
|
||||
});
|
||||
|
||||
it("zeigt die Kennzahlen des Monats", async () => {
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
|
||||
expect(screen.getByText(/am 02\.03\.2026/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rendert ein vollständiges Monatsraster", async () => {
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument();
|
||||
});
|
||||
// 31 Tage im März.
|
||||
expect(screen.getAllByRole("button", { name: /\d{2}\.03\.2026/ })).toHaveLength(31);
|
||||
});
|
||||
|
||||
it("öffnet die Tagesansicht mit den Posten", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
|
||||
|
||||
const detail = await screen.findByRole("heading", { name: "02.03.2026" });
|
||||
const bereich = detail.closest("section")!;
|
||||
// Der Betrag steht sowohl in der Zeile als auch im Tagessaldo – hier zählt die Zeile.
|
||||
const zeile = within(bereich).getByRole("listitem");
|
||||
expect(within(zeile).getByText("Miete")).toBeInTheDocument();
|
||||
expect(within(zeile).getByText(/950,00/)).toBeInTheDocument();
|
||||
expect(within(zeile).getByText("Geplant")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bestätigt eine Fälligkeit über das nominale Datum", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
|
||||
|
||||
await nutzer.click(await screen.findByRole("button", { name: "Miete bestätigen" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const bestaetigung = anfragen.find((e) => e.url.includes("/occurrences/confirm"));
|
||||
expect(bestaetigung).toBeDefined();
|
||||
// Der Zahltag ist der 02.03., der Schlüssel bleibt der 01.03.
|
||||
expect(bestaetigung?.body).toEqual({
|
||||
recurrence_id: 7,
|
||||
occurrence_date: "2026-03-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("meldet leere Tage verständlich", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/05\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/05\.03\.2026/));
|
||||
|
||||
expect(await screen.findByText("Nichts fällig")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blättert in den Vormonat", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
const vorher = anfragen.filter((e) => e.url.includes("/reports/calendar")).length;
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: "Vorheriger Monat" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(anfragen.filter((e) => e.url.includes("/reports/calendar")).length).toBeGreaterThan(
|
||||
vorher,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Cashflow-Kalender.
|
||||
*
|
||||
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
|
||||
* Kontostands über den Monat.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, SkipForward } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, StatTile } from "@/components/charts/ChartFrame";
|
||||
import { AXIS_PROPS, GRID_PROPS, SERIES } from "@/components/charts/chartTheme";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
|
||||
import { useCalendar } from "@/hooks/useReports";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
|
||||
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
|
||||
|
||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
|
||||
function fuehrendeLeerfelder(erster: string): number {
|
||||
const datum = new Date(erster);
|
||||
return (datum.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export function CalendarPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useCalendar(monat);
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||
|
||||
const tage = data?.days ?? [];
|
||||
const heute = todayIso();
|
||||
const detailTag = tage.find((tag) => tag.date === gewaehlterTag) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cashflow-Kalender"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, -1));
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat(firstOfMonth());
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, 1));
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-96" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
|
||||
<StatTile
|
||||
label="Stand zum Monatsende"
|
||||
value={formatMoney(data.closing_balance)}
|
||||
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tiefster Stand"
|
||||
value={formatMoney(data.lowest_balance)}
|
||||
tone={toNumber(data.lowest_balance) < 0 ? "negative" : "default"}
|
||||
hint={data.lowest_balance_on ? `am ${formatDate(data.lowest_balance_on)}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="card p-3">
|
||||
<div className="mb-1 grid grid-cols-7 gap-1.5">
|
||||
{WOCHENTAGE.map((tag) => (
|
||||
<div key={tag} className="py-1 text-center text-[11px] font-medium text-muted">
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
|
||||
<div key={`leer-${index}`} aria-hidden />
|
||||
))}
|
||||
|
||||
{tage.map((tag) => (
|
||||
<TagZelle
|
||||
key={tag.date}
|
||||
tag={tag}
|
||||
istHeute={tag.date === heute}
|
||||
istGewaehlt={tag.date === gewaehlterTag}
|
||||
firmen={firmen}
|
||||
onSelect={() => setGewaehlterTag(tag.date === gewaehlterTag ? null : tag.date)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{detailTag && (
|
||||
<TagDetail
|
||||
tag={detailTag}
|
||||
firmen={firmen}
|
||||
onClose={() => setGewaehlterTag(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartCard
|
||||
title="Verlauf des Kontostands"
|
||||
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
|
||||
>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={tage.map((tag) => ({
|
||||
tag: new Date(tag.date).getDate(),
|
||||
stand: toNumber(tag.running_balance),
|
||||
}))}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="verlauf" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={SERIES.one} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={SERIES.one} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="tag" {...AXIS_PROPS} />
|
||||
<YAxis
|
||||
{...AXIS_PROPS}
|
||||
width={52}
|
||||
tickFormatter={(wert: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert)
|
||||
}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={`${label}. ${formatMonth(monat)}`}
|
||||
rows={[
|
||||
{
|
||||
label: "Kontostand",
|
||||
value: formatMoney(payload[0]?.payload.stand ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="stand"
|
||||
stroke={SERIES.one}
|
||||
strokeWidth={2}
|
||||
fill="url(#verlauf)"
|
||||
dot={false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagZelle({
|
||||
tag,
|
||||
istHeute,
|
||||
istGewaehlt,
|
||||
firmen,
|
||||
onSelect,
|
||||
}: {
|
||||
tag: CalendarDay;
|
||||
istHeute: boolean;
|
||||
istGewaehlt: boolean;
|
||||
firmen: Map<number, Merchant>;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const netto = toNumber(tag.net);
|
||||
const tagesZahl = new Date(tag.date).getDate();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={istGewaehlt}
|
||||
aria-label={`${formatDate(tag.date)}, ${tag.entries.length} Posten, Saldo ${formatMoney(tag.net)}`}
|
||||
className={cn(
|
||||
"flex min-h-[5.5rem] flex-col rounded-lg border p-1.5 text-left transition",
|
||||
istGewaehlt ? "border-accent bg-accent/10" : "border-line hover:border-faint",
|
||||
!tag.is_business_day && "bg-raised/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular",
|
||||
istHeute
|
||||
? "flex h-5 w-5 items-center justify-center rounded-full bg-accent font-semibold text-accent-ink"
|
||||
: tag.is_business_day
|
||||
? "text-muted"
|
||||
: "text-faint",
|
||||
)}
|
||||
>
|
||||
{tagesZahl}
|
||||
</span>
|
||||
{netto !== 0 && (
|
||||
<span
|
||||
className={cn("tabular text-[10px]", netto < 0 ? "text-negative" : "text-positive")}
|
||||
>
|
||||
{netto > 0 ? "+" : "−"}
|
||||
{new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.abs(netto))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap gap-0.5">
|
||||
{tag.entries.slice(0, 4).map((eintrag, index) => {
|
||||
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
|
||||
return (
|
||||
<MerchantLogo
|
||||
key={`${eintrag.title}-${index}`}
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={18}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tag.entries.length > 4 && (
|
||||
<span className="self-center text-[10px] text-faint">+{tag.entries.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TagDetail({
|
||||
tag,
|
||||
firmen,
|
||||
onClose,
|
||||
}: {
|
||||
tag: CalendarDay;
|
||||
firmen: Map<number, Merchant>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const bestaetigen = useConfirmOccurrence();
|
||||
const auslassen = useSkipOccurrence();
|
||||
|
||||
return (
|
||||
<section className="card p-4">
|
||||
<header className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-ink">{formatDate(tag.date)}</h2>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted">
|
||||
Saldo{" "}
|
||||
<span className={cn("tabular", toNumber(tag.net) < 0 ? "text-negative" : "text-positive")}>
|
||||
{formatMoney(tag.net)}
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tag.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="Nichts fällig"
|
||||
description="An diesem Tag steht keine Zahlung an."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{tag.entries.map((eintrag, index) => (
|
||||
<EintragZeile
|
||||
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
|
||||
eintrag={eintrag}
|
||||
firma={eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined}
|
||||
onConfirm={() =>
|
||||
eintrag.recurrence_id &&
|
||||
eintrag.occurrence_date &&
|
||||
bestaetigen.mutate({
|
||||
recurrence_id: eintrag.recurrence_id,
|
||||
occurrence_date: eintrag.occurrence_date,
|
||||
})
|
||||
}
|
||||
onSkip={() =>
|
||||
eintrag.recurrence_id &&
|
||||
eintrag.occurrence_date &&
|
||||
auslassen.mutate({
|
||||
recurrence_id: eintrag.recurrence_id,
|
||||
occurrence_date: eintrag.occurrence_date,
|
||||
})
|
||||
}
|
||||
busy={bestaetigen.isPending || auslassen.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EintragZeile({
|
||||
eintrag,
|
||||
firma,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
busy,
|
||||
}: {
|
||||
eintrag: CalendarEntry;
|
||||
firma: Merchant | undefined;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const bestaetigbar = eintrag.source === "recurrence" && eintrag.status === "planned";
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 py-2">
|
||||
<MerchantLogo
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{eintrag.title}</p>
|
||||
<p className="text-xs text-faint">
|
||||
{eintrag.source === "transaction"
|
||||
? "Einmalige Buchung"
|
||||
: eintrag.status === "confirmed"
|
||||
? "Bestätigt"
|
||||
: "Geplant"}
|
||||
{eintrag.is_variable && " · geschätzt"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 tabular text-sm font-medium",
|
||||
eintrag.kind === "income" ? "text-positive" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{eintrag.kind === "income" ? "+" : "−"}
|
||||
{formatMoney(eintrag.amount)}
|
||||
</span>
|
||||
|
||||
{bestaetigbar && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
aria-label={`${eintrag.title} bestätigen`}
|
||||
>
|
||||
<Check aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
disabled={busy}
|
||||
aria-label={`${eintrag.title} auslassen`}
|
||||
>
|
||||
<SkipForward aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
|
||||
|
||||
import { CategoryDonut } from "@/components/charts/CategoryDonut";
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import { ChartCard, DeltaHint, StatTile } from "@/components/charts/ChartFrame";
|
||||
import { ForecastChart } from "@/components/charts/ForecastChart";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import { useMerchants } from "@/hooks/useEntities";
|
||||
import { useDashboard } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
|
||||
|
||||
export function DashboardPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const { data, isLoading } = useDashboard(monat);
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-72" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Die große Kennzahl steht allein – sie ist die Antwort auf die
|
||||
häufigste Frage und braucht kein Diagramm. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile
|
||||
label="Verfügbar nach Fixkosten"
|
||||
value={formatMoney(data.month.available_after_fixed)}
|
||||
large
|
||||
tone={
|
||||
Number.parseFloat(data.month.available_after_fixed) < 0 ? "negative" : "positive"
|
||||
}
|
||||
hint={`Einkünfte ${formatMoney(data.month.planned.income)} abzüglich Fixkosten ${formatMoney(
|
||||
data.month.fixed_costs,
|
||||
)}${
|
||||
data.month.reserves !== "0.00"
|
||||
? ` und Rücklagen ${formatMoney(data.month.reserves)}`
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Einkünfte im Monat"
|
||||
value={formatMoney(data.month.planned.income)}
|
||||
hint={<DeltaHint value={data.month.delta_to_previous.income} />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Ausgaben im Monat"
|
||||
value={formatMoney(data.month.planned.expenses)}
|
||||
hint={
|
||||
<DeltaHint value={data.month.delta_to_previous.expenses} goodWhenPositive={false} />
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Kontostand heute"
|
||||
value={formatMoney(data.total_balance)}
|
||||
hint={`${data.month.open_count} offen, ${data.month.confirmed_count} bestätigt`}
|
||||
/>
|
||||
</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 laufen ab
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{data.upcoming_deadlines.map((eintrag) => (
|
||||
<li key={eintrag.recurrence_id} className="flex flex-wrap gap-x-2 text-xs">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<CategoryDonut
|
||||
categories={data.categories}
|
||||
description={`Ausgaben im ${formatMonth(monat)}. Klick auf eine Kategorie zeigt die Unterkategorien.`}
|
||||
/>
|
||||
|
||||
<ChartCard
|
||||
title="Nächste Fälligkeiten"
|
||||
description="Die kommenden 14 Tage."
|
||||
>
|
||||
{data.upcoming.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted">
|
||||
In den nächsten zwei Wochen steht nichts an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{data.upcoming.slice(0, 8).map((eintrag, index) => {
|
||||
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
|
||||
return (
|
||||
<li
|
||||
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
|
||||
className="flex items-center gap-2.5 py-2"
|
||||
>
|
||||
<MerchantLogo
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{eintrag.title}</p>
|
||||
<p className="truncate text-xs text-faint">
|
||||
{kategorieName(eintrag.category_id)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 tabular text-sm ${
|
||||
eintrag.kind === "income" ? "text-positive" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{eintrag.kind === "income" ? "+" : "−"}
|
||||
{formatMoney(eintrag.amount)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to="/calendar"
|
||||
className="mt-2 inline-block text-xs text-accent transition hover:underline"
|
||||
>
|
||||
Zum Cashflow-Kalender
|
||||
</Link>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<ForecastChart months={data.forecast} />
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Budgets</h2>
|
||||
{data.budgets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title="Keine Budgets für diesen Monat"
|
||||
description="Lege ein Budget an, um den Verbrauch je Kategorie im Blick zu behalten."
|
||||
action={
|
||||
<Link to="/budgets">
|
||||
<Button variant="primary">Budgets pflegen</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.budgets.map((budget) => (
|
||||
<BudgetMeter key={budget.category_id} budget={budget} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Sparziele</h2>
|
||||
{data.goals.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PiggyBank}
|
||||
title="Keine Sparziele"
|
||||
description="Lege ein Ziel an – moneyfy rechnet die nötige Monatsrate aus."
|
||||
action={
|
||||
<Link to="/goals">
|
||||
<Button variant="primary">Sparziel anlegen</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.goals.map((ziel) => (
|
||||
<li key={ziel.goal_id} className="card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-ink">{ziel.name}</span>
|
||||
<span className="shrink-0 tabular text-sm text-ink">
|
||||
{formatMoney(ziel.current_amount)}
|
||||
<span className="text-faint"> / {formatMoney(ziel.target_amount)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-raised">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(ziel.ratio, 1) * 100}%`,
|
||||
backgroundColor: ziel.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted">
|
||||
{ziel.target_date
|
||||
? `Bis ${formatDate(ziel.target_date)} (${relativeDays(ziel.target_date)}) · ${formatMoney(
|
||||
ziel.required_monthly,
|
||||
)} pro Monat nötig`
|
||||
: "Ohne Zieldatum"}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,3 +21,45 @@ Object.defineProperty(window, "matchMedia", {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
// jsdom rechnet kein Layout: ohne diese Ergänzungen bliebe jedes Diagramm
|
||||
// 0 Pixel groß und Recharts würde bei jedem Test eine Warnung ausgeben.
|
||||
const TEST_BREITE = 640;
|
||||
const TEST_HOEHE = 320;
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
constructor(private readonly rueckruf: ResizeObserverCallback) {}
|
||||
|
||||
observe(ziel: Element): void {
|
||||
const abmessung = { inlineSize: TEST_BREITE, blockSize: TEST_HOEHE };
|
||||
this.rueckruf(
|
||||
[
|
||||
{
|
||||
target: ziel,
|
||||
contentRect: { width: TEST_BREITE, height: TEST_HOEHE } as DOMRectReadOnly,
|
||||
borderBoxSize: [abmessung],
|
||||
contentBoxSize: [abmessung],
|
||||
devicePixelContentBoxSize: [abmessung],
|
||||
} as ResizeObserverEntry,
|
||||
],
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver ??= ResizeObserverStub;
|
||||
|
||||
for (const [eigenschaft, wert] of [
|
||||
["offsetWidth", TEST_BREITE],
|
||||
["clientWidth", TEST_BREITE],
|
||||
["offsetHeight", TEST_HOEHE],
|
||||
["clientHeight", TEST_HOEHE],
|
||||
] as const) {
|
||||
Object.defineProperty(HTMLElement.prototype, eigenschaft, {
|
||||
configurable: true,
|
||||
value: wert,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,6 +315,58 @@ export interface Totals {
|
||||
balance: Money;
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
id: number;
|
||||
category_id: number;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
rollover: boolean;
|
||||
created_at: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface BudgetInput {
|
||||
category_id: number;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
rollover?: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetTemplate {
|
||||
id: number;
|
||||
category_id: number;
|
||||
valid_from: IsoDate;
|
||||
valid_until: IsoDate | null;
|
||||
limit_amount: Money;
|
||||
rollover: boolean;
|
||||
}
|
||||
|
||||
export interface SavingsGoal {
|
||||
id: number;
|
||||
name: string;
|
||||
target_amount: Money;
|
||||
target_date: IsoDate | null;
|
||||
current_amount: Money;
|
||||
account_id: number | null;
|
||||
monthly_contribution: Money | null;
|
||||
color: string;
|
||||
icon: string;
|
||||
is_archived: boolean;
|
||||
created_at: IsoDateTime;
|
||||
updated_at: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface SavingsGoalInput {
|
||||
name: string;
|
||||
target_amount: Money;
|
||||
target_date?: IsoDate | null;
|
||||
current_amount?: Money;
|
||||
account_id?: number | null;
|
||||
monthly_contribution?: Money | null;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export interface MonthReport {
|
||||
month: IsoDate;
|
||||
planned: Totals;
|
||||
@@ -330,3 +382,150 @@ export interface MonthReport {
|
||||
open_count: number;
|
||||
skipped_count: number;
|
||||
}
|
||||
|
||||
export interface ForecastMonth {
|
||||
month: IsoDate;
|
||||
income: Money;
|
||||
expenses: Money;
|
||||
balance: Money;
|
||||
cumulative_balance: Money;
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
months: ForecastMonth[];
|
||||
total_income: Money;
|
||||
total_expenses: Money;
|
||||
}
|
||||
|
||||
export interface CategorySlice {
|
||||
category_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
amount: Money;
|
||||
count: number;
|
||||
children: CategorySlice[];
|
||||
}
|
||||
|
||||
export interface CategoryReport {
|
||||
date_from: IsoDate;
|
||||
date_to: IsoDate;
|
||||
kind: EntryKind;
|
||||
total: Money;
|
||||
categories: CategorySlice[];
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
recurrence_id: number;
|
||||
title: string;
|
||||
merchant_id: number | null;
|
||||
merchant_name: string | null;
|
||||
category_id: number;
|
||||
amount: Money;
|
||||
annual_cost: Money;
|
||||
monthly_cost: Money;
|
||||
rrule: string;
|
||||
is_installment: boolean;
|
||||
is_cancelled: boolean;
|
||||
contract_term: ContractTerm | null;
|
||||
days_until_notice: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionReport {
|
||||
entries: Subscription[];
|
||||
total_annual: Money;
|
||||
total_monthly: Money;
|
||||
upcoming_deadlines: Subscription[];
|
||||
}
|
||||
|
||||
export interface YearComparisonRow {
|
||||
category_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
current: Money;
|
||||
previous: Money;
|
||||
delta: Money;
|
||||
}
|
||||
|
||||
export interface YearComparison {
|
||||
year: number;
|
||||
rows: YearComparisonRow[];
|
||||
current_total: Money;
|
||||
previous_total: Money;
|
||||
}
|
||||
|
||||
export interface CalendarEntry {
|
||||
title: string;
|
||||
kind: EntryKind;
|
||||
amount: Money;
|
||||
category_id: number;
|
||||
merchant_id: number | null;
|
||||
account_id: number | null;
|
||||
source: "recurrence" | "transaction";
|
||||
recurrence_id: number | null;
|
||||
occurrence_date: IsoDate | null;
|
||||
status: OccurrenceStatus | null;
|
||||
is_variable: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: IsoDate;
|
||||
entries: CalendarEntry[];
|
||||
net: Money;
|
||||
running_balance: Money;
|
||||
is_business_day: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
month: IsoDate;
|
||||
days: CalendarDay[];
|
||||
opening_balance: Money;
|
||||
closing_balance: Money;
|
||||
lowest_balance: Money;
|
||||
lowest_balance_on: IsoDate | null;
|
||||
}
|
||||
|
||||
export type BudgetState = "ok" | "warning" | "exceeded";
|
||||
|
||||
export interface BudgetStatus {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
color: string;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
carried_over: Money;
|
||||
available: Money;
|
||||
spent: Money;
|
||||
remaining: Money;
|
||||
ratio: number;
|
||||
state: BudgetState;
|
||||
rollover: boolean;
|
||||
is_template: boolean;
|
||||
}
|
||||
|
||||
export interface SavingsGoalProgress {
|
||||
goal_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
target_amount: Money;
|
||||
current_amount: Money;
|
||||
remaining_amount: Money;
|
||||
ratio: number;
|
||||
target_date: IsoDate | null;
|
||||
months_left: number | null;
|
||||
required_monthly: Money | null;
|
||||
monthly_contribution: Money | null;
|
||||
is_on_track: boolean | null;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
month: MonthReport;
|
||||
total_balance: Money;
|
||||
forecast: ForecastMonth[];
|
||||
categories: CategorySlice[];
|
||||
budgets: BudgetStatus[];
|
||||
goals: SavingsGoalProgress[];
|
||||
upcoming: CalendarEntry[];
|
||||
upcoming_deadlines: Subscription[];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Recharts wiegt mehr als der Rest der Anwendung und ändert sich selten –
|
||||
// als eigener Chunk bleibt er über Releases hinweg im Browser-Cache.
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
vendor: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user