("annual");
+ const { data, isLoading } = useSubscriptions();
+ const kategorieName = useCategoryLookup();
+
+ if (isLoading || !data) return ;
+
+ if (data.entries.length === 0) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+
+
+
+ !eintrag.is_installment).length)}
+ hint={`zuzüglich ${data.entries.filter((eintrag) => eintrag.is_installment).length} Ratenzahlungen`}
+ />
+
+
+ {data.upcoming_deadlines.length > 0 && (
+
+
+
+ Kündigungsfristen der nächsten 60 Tage
+
+
+ {data.upcoming_deadlines.map((eintrag) => (
+ -
+ {eintrag.title}
+
+ kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
+
+ noch {eintrag.days_until_notice} Tage
+
+ ))}
+
+
+ )}
+
+
setSortierung(ereignis.target.value as Sortierung)}
+ >
+
+
+
+
+
+ }
+ >
+
+
+ Laufende Posten mit Jahreskosten
+
+
+ |
+ Posten
+ |
+
+ Rhythmus
+ |
+
+ pro Monat
+ |
+
+ pro Jahr
+ |
+
+
+
+ {sortiert.map((eintrag) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function SubscriptionRow({
+ entry,
+ categoryName,
+}: {
+ entry: Subscription;
+ categoryName: string;
+}) {
+ return (
+
+ |
+
+ {entry.title}
+ {entry.is_installment && Raten}
+ {entry.is_cancelled && Gekündigt}
+ {entry.days_until_notice !== null && entry.days_until_notice <= 60 && (
+ Frist in {entry.days_until_notice} Tagen
+ )}
+
+
+ {entry.merchant_name ? `${entry.merchant_name} · ` : ""}
+ {categoryName}
+
+ |
+
+ {describeRRule(entry.rrule)}
+ |
+ {formatMoney(entry.monthly_cost)} |
+
+ {formatMoney(entry.annual_cost)}
+ |
+
+ );
+}
+
+/* --- 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 ;
+
+ return (
+
+
+ {(
+ [
+ ["month", formatMonth(von)],
+ ["year", String(heute.getFullYear())],
+ ] as const
+ ).map(([wert, beschriftung]) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+/* --- Jahresvergleich ------------------------------------------------------ */
+
+function YearTab() {
+ const [jahr, setJahr] = useState(() => new Date().getFullYear());
+ const { data, isLoading } = useYearComparison(jahr);
+
+ return (
+
+
+
+ {isLoading || !data ? : }
+
+ );
+}
+
+/* --- Export --------------------------------------------------------------- */
+
+function ExportTab() {
+ const [monat, setMonat] = useState(() => firstOfMonth());
+ const jahr = new Date().getFullYear();
+
+ return (
+
+
+
+
+
+ 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 }) },
+ ]}
+ />
+
+ );
+}
+
+function ExportCard({
+ title,
+ description,
+ links,
+ extra,
+}: {
+ title: string;
+ description: string;
+ links: { label: string; href: string }[];
+ extra?: React.ReactNode;
+}) {
+ return (
+
+ {title}
+ {description}
+ {extra}
+
+
+ );
+}
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 966dc29..1f82e21 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -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,
+ });
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index b695d4e..cecea46 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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[];
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index c4b8aca..b894a2b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -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,