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:
@@ -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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user