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