feat(frontend): Oberfläche mit RRULE-Editor und Logo-Auswahl

- Vite, React 18, TypeScript und Tailwind mit dunklem Standard-Theme über
  CSS-Variablen, heller Modus umschaltbar und in localStorage gemerkt
- Anmeldung, erzwungener Passwortwechsel, Layout mit Seitenleiste
- API-Client mit stiller Token-Erneuerung, TanStack Query mit Fehler-Toasts
- Seiten Recurrences (inkl. Detail-Drawer), Transactions, Merchants, Settings
- Geführter RRULE-Editor mit Vorlagen, Expertenmodus, deutschem Klartext und
  Live-Vorschau der nächsten Termine vom preview-Endpunkt
- Firmen-Kachelgrid mit Logo, Markenfarbe und Logo-Auswahldialog samt Upload
- Beträge durchgängig in de-DE, Eingabe in beiden Schreibweisen
- 49 Tests, tsc --noEmit und eslint sauber, Produktionsbundle baut

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:23:04 +02:00
co-authored by Claude Opus 5
parent 8edb69fe6a
commit 8d6fcfeb58
57 changed files with 13064 additions and 2 deletions
+69
View File
@@ -0,0 +1,69 @@
/** Anmeldung, Abmeldung und der angemeldete Benutzer. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, isUnauthorized } from "@/lib/api";
import { keys } from "@/lib/queryClient";
import { toast } from "@/store/toast";
import type { MessageResponse, User } from "@/types/api";
export function useMe() {
return useQuery({
queryKey: keys.me,
queryFn: async () => {
try {
return await api.get<User>("/me");
} catch (fehler) {
// Kein gültiges Token bedeutet schlicht: nicht angemeldet.
if (isUnauthorized(fehler)) return null;
throw fehler;
}
},
staleTime: 60_000,
retry: false,
});
}
export function useLogin() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: { username: string; password: string }) =>
api.post<User>("/auth/login", daten),
onSuccess: (benutzer) => {
client.setQueryData(keys.me, benutzer);
void client.invalidateQueries();
},
});
}
export function useLogout() {
const client = useQueryClient();
return useMutation({
mutationFn: () => api.post<MessageResponse>("/auth/logout"),
onSuccess: () => {
client.setQueryData(keys.me, null);
client.clear();
toast.info("Abgemeldet.");
},
});
}
export function useChangePassword() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: { current_password: string; new_password: string }) =>
api.post<MessageResponse>("/auth/change-password", daten),
onSuccess: () => {
// Der Passwortwechsel beendet alle Sitzungen es folgt eine neue Anmeldung.
client.setQueryData(keys.me, null);
client.clear();
toast.success("Passwort geändert.", "Bitte melde dich neu an.");
},
});
}
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
export function loginErrorMessage(error: unknown): string {
if (error instanceof ApiError) return error.message;
return "Der Server ist nicht erreichbar.";
}
+17
View File
@@ -0,0 +1,17 @@
/** Liefert zu einer Kategorie-ID den vollständigen Namen inklusive Oberkategorie. */
import { useCategoryTree } from "@/hooks/useEntities";
export function useCategoryLookup(): (id: number) => string {
const { data: baum = [] } = useCategoryTree();
const namen = new Map<number, string>();
for (const oberkategorie of baum) {
namen.set(oberkategorie.id, oberkategorie.name);
for (const unterkategorie of oberkategorie.children) {
namen.set(unterkategorie.id, `${oberkategorie.name} · ${unterkategorie.name}`);
}
}
return (id: number) => namen.get(id) ?? "Unbekannt";
}
+405
View File
@@ -0,0 +1,405 @@
/**
* Abfragen und Mutationen der Stammdaten.
*
* Alle Mutationen invalidieren die betroffenen Schlüssel und melden Erfolg per
* Toast; Fehler übernimmt der zentrale Query-Client.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { keys } from "@/lib/queryClient";
import { toast } from "@/store/toast";
import type {
Account,
AccountBalance,
AccountInput,
AmountVersion,
Category,
CategoryInput,
CategoryTree,
LogoSearchResult,
Merchant,
MerchantInput,
MessageResponse,
Occurrence,
OccurrenceConfirmInput,
Page,
Recurrence,
RecurrenceDetail,
RecurrenceInput,
Transaction,
TransactionInput,
} from "@/types/api";
/* --- Konten --------------------------------------------------------------- */
export function useAccounts(onlyActive = false) {
return useQuery({
queryKey: [...keys.accounts, onlyActive],
queryFn: () => api.get<Account[]>("/accounts", onlyActive ? { is_active: true } : undefined),
});
}
export function useAccountBalance(accountId: number | null, asOf?: string) {
return useQuery({
queryKey: keys.accountBalance(accountId ?? 0, asOf),
queryFn: () =>
api.get<AccountBalance>(`/accounts/${accountId}/balance`, asOf ? { as_of: asOf } : undefined),
enabled: accountId !== null,
});
}
export function useSaveAccount() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: AccountInput | Partial<AccountInput> }) =>
id ? api.patch<Account>(`/accounts/${id}`, daten) : api.post<Account>("/accounts", daten),
onSuccess: (_konto, variablen) => {
void client.invalidateQueries({ queryKey: keys.accounts });
toast.success(variablen.id ? "Konto gespeichert." : "Konto angelegt.");
},
});
}
export function useDeleteAccount() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/accounts/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: keys.accounts });
toast.success("Konto gelöscht.");
},
});
}
/* --- Kategorien ----------------------------------------------------------- */
export function useCategoryTree() {
return useQuery({
queryKey: keys.categories,
queryFn: () => api.get<CategoryTree[]>("/categories"),
staleTime: 5 * 60_000,
});
}
export function useCategoriesFlat() {
return useQuery({
queryKey: keys.categoriesFlat,
queryFn: () => api.get<Category[]>("/categories/flat"),
staleTime: 5 * 60_000,
});
}
export function useSaveCategory() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: CategoryInput | Partial<CategoryInput> }) =>
id
? api.patch<Category>(`/categories/${id}`, daten)
: api.post<Category>("/categories", daten),
onSuccess: (_kategorie, variablen) => {
void client.invalidateQueries({ queryKey: keys.categories });
toast.success(variablen.id ? "Kategorie gespeichert." : "Kategorie angelegt.");
},
});
}
export function useDeleteCategory() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/categories/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: keys.categories });
toast.success("Kategorie gelöscht.");
},
});
}
/* --- Firmen --------------------------------------------------------------- */
export function useMerchants(query?: string) {
return useQuery({
queryKey: keys.merchants(query),
queryFn: () => api.get<Page<Merchant>>("/merchants", { q: query, limit: 200 }),
});
}
export function useSaveMerchant() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: MerchantInput | Partial<MerchantInput> }) =>
id ? api.patch<Merchant>(`/merchants/${id}`, daten) : api.post<Merchant>("/merchants", daten),
onSuccess: (_firma, variablen) => {
void client.invalidateQueries({ queryKey: ["merchants"] });
toast.success(variablen.id ? "Firma gespeichert." : "Firma angelegt.");
},
});
}
export function useDeleteMerchant() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/merchants/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["merchants"] });
void client.invalidateQueries({ queryKey: ["transactions"] });
toast.success("Firma gelöscht.");
},
});
}
export function useLogoSearch() {
return useMutation({
mutationFn: ({ merchantId, domain }: { merchantId: number; domain?: string }) =>
api.post<LogoSearchResult>(
`/merchants/${merchantId}/logo/search`,
undefined,
domain ? { domain } : undefined,
),
});
}
export function useSelectLogo() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ merchantId, candidateId }: { merchantId: number; candidateId: number }) =>
api.post<Merchant>(`/merchants/${merchantId}/logo/select`, { candidate_id: candidateId }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["merchants"] });
toast.success("Logo übernommen.");
},
});
}
export function useUploadLogo() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ merchantId, file }: { merchantId: number; file: File }) => {
const daten = new FormData();
daten.append("file", file);
return api.upload<Merchant>(`/merchants/${merchantId}/logo/upload`, daten);
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["merchants"] });
toast.success("Logo hochgeladen.");
},
});
}
/* --- Wiederkehrende Posten ------------------------------------------------ */
export interface RecurrenceFilter {
[schluessel: string]: string | number | boolean | undefined;
kind?: string;
active?: boolean;
category_id?: number;
account_id?: number;
merchant_id?: number;
}
export function useRecurrences(filter: RecurrenceFilter = {}) {
return useQuery({
queryKey: keys.recurrences(filter),
queryFn: () => api.get<Recurrence[]>("/recurrences", filter),
});
}
export function useRecurrence(id: number | null) {
return useQuery({
queryKey: keys.recurrence(id ?? 0),
queryFn: () => api.get<RecurrenceDetail>(`/recurrences/${id}`),
enabled: id !== null,
});
}
export function useRecurrencePreview(id: number | null, from: string, to: string) {
return useQuery({
queryKey: keys.recurrencePreview(id ?? 0, from, to),
queryFn: () => api.get<Occurrence[]>(`/recurrences/${id}/preview`, { from, to }),
enabled: id !== null,
});
}
export function useSaveRecurrence() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, daten }: { id?: number; daten: RecurrenceInput | Partial<RecurrenceInput> }) =>
id
? api.patch<RecurrenceDetail>(`/recurrences/${id}`, daten)
: api.post<RecurrenceDetail>("/recurrences", daten),
onSuccess: (_posten, variablen) => {
void client.invalidateQueries({ queryKey: ["recurrences"] });
void client.invalidateQueries({ queryKey: ["occurrences"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success(variablen.id ? "Posten gespeichert." : "Posten angelegt.");
},
});
}
export function useDeleteRecurrence() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/recurrences/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["recurrences"] });
void client.invalidateQueries({ queryKey: ["occurrences"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Posten gelöscht.");
},
});
}
export function useAddAmountVersion() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
recurrenceId,
daten,
}: {
recurrenceId: number;
daten: { amount: string; valid_from: string; note?: string | null };
}) => api.post<AmountVersion>(`/recurrences/${recurrenceId}/amount-versions`, daten),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["recurrences"] });
void client.invalidateQueries({ queryKey: ["occurrences"] });
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Preisversion angelegt.");
},
});
}
export function useCancelContract() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, effectiveDate }: { id: number; effectiveDate?: string }) =>
api.post<RecurrenceDetail>(
`/recurrences/${id}/cancel`,
undefined,
effectiveDate ? { effective_date: effectiveDate } : undefined,
),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["recurrences"] });
void client.invalidateQueries({ queryKey: ["occurrences"] });
toast.success("Kündigung vermerkt.");
},
});
}
/* --- Fälligkeiten --------------------------------------------------------- */
export interface OccurrenceFilter {
[schluessel: string]: string | number | undefined;
from: string;
to: string;
kind?: string;
category_id?: number;
account_id?: number;
status?: string;
}
export function useOccurrences(filter: OccurrenceFilter) {
return useQuery({
queryKey: keys.occurrences(filter),
queryFn: () => api.get<Occurrence[]>("/occurrences", { ...filter }),
});
}
function occurrenceInvalidation(client: ReturnType<typeof useQueryClient>): void {
void client.invalidateQueries({ queryKey: ["occurrences"] });
void client.invalidateQueries({ queryKey: ["reports"] });
void client.invalidateQueries({ queryKey: keys.accounts });
}
export function useConfirmOccurrence() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: OccurrenceConfirmInput) =>
api.post<Occurrence>("/occurrences/confirm", daten),
onSuccess: () => {
occurrenceInvalidation(client);
toast.success("Fälligkeit bestätigt.");
},
});
}
export function useSkipOccurrence() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: { recurrence_id: number; occurrence_date: string; note?: string }) =>
api.post<Occurrence>("/occurrences/skip", daten),
onSuccess: () => {
occurrenceInvalidation(client);
toast.success("Fälligkeit ausgelassen.");
},
});
}
export function useResetOccurrence() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: { recurrence_id: number; occurrence_date: string }) =>
api.post<Occurrence>("/occurrences/reset", daten),
onSuccess: () => {
occurrenceInvalidation(client);
toast.success("Zurückgesetzt.");
},
});
}
/* --- Buchungen ------------------------------------------------------------ */
export interface TransactionFilter {
[schluessel: string]: string | number | undefined;
from?: string;
to?: string;
kind?: string;
category_id?: number;
account_id?: number;
merchant_id?: number;
q?: string;
limit?: number;
offset?: number;
}
export function useTransactions(filter: TransactionFilter = {}) {
return useQuery({
queryKey: keys.transactions(filter),
queryFn: () => api.get<Page<Transaction>>("/transactions", { ...filter }),
});
}
export function useSaveTransaction() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: TransactionInput | Partial<TransactionInput>;
}) =>
id
? api.patch<Transaction>(`/transactions/${id}`, daten)
: api.post<Transaction>("/transactions", daten),
onSuccess: (_buchung, variablen) => {
void client.invalidateQueries({ queryKey: ["transactions"] });
void client.invalidateQueries({ queryKey: ["reports"] });
void client.invalidateQueries({ queryKey: keys.accounts });
toast.success(variablen.id ? "Buchung gespeichert." : "Buchung angelegt.");
},
});
}
export function useDeleteTransaction() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/transactions/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["transactions"] });
void client.invalidateQueries({ queryKey: ["reports"] });
void client.invalidateQueries({ queryKey: keys.accounts });
toast.success("Buchung gelöscht.");
},
});
}