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:
moneyfy
2026-09-09 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
+128
View File
@@ -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.");
},
});
}
+93
View File
@@ -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()}`;
}