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
+23
View File
@@ -0,0 +1,23 @@
/** Gemeinsame Einrichtung aller Frontend-Tests. */
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
+58
View File
@@ -0,0 +1,58 @@
/** Hilfsmittel zum Rendern von Komponenten in Tests. */
import { type ReactElement, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
}
export function renderWithProviders(ui: ReactElement, route = "/") {
const client = createTestQueryClient();
function Rahmen({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>
<MemoryRouter
initialEntries={[route]}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}
return { client, ...render(ui, { wrapper: Rahmen }) };
}
/** Antwortet auf `fetch` anhand einer Zuordnung von Pfadfragment zu Nutzlast. */
export function mockFetch(routen: Record<string, unknown>): ReturnType<typeof vi.fn> {
const nachbildung = vi.fn(async (eingabe: RequestInfo | URL) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const treffer = Object.keys(routen).find((muster) => url.includes(muster));
if (treffer === undefined) {
return new Response(JSON.stringify({ detail: "Not Found", code: "not_found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(routen[treffer]), {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", nachbildung);
return nachbildung;
}