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
+13
View File
@@ -65,6 +65,19 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
- Die Logosuche läuft nach dem Anlegen einer Firma im Hintergrund; der Aufruf
antwortet sofort.
- `make vendor-icons` erzeugt den simple-icons-Index neu.
- Frontend auf React 18, TypeScript, Vite und TailwindCSS mit dunklem Standard-
Theme, umschaltbar und im Browser gespeichert.
- Anmeldung, erzwungener Passwortwechsel und Layout mit Seitenleiste.
- TanStack-Query-Client mit zentralen Fehler-Toasts und stiller Token-Erneuerung
bei abgelaufenem Access-Token.
- Seiten für wiederkehrende Posten (inklusive Detail-Drawer mit Preishistorie,
Ratenfortschritt und Vertragsfristen), Buchungen, Firmen und Einstellungen.
- Geführter RRULE-Editor mit sechs Vorlagen, Expertenmodus, deutscher
Klartextfassung und den nächsten fünf Terminen aus dem `preview`-Endpunkt.
- Firmen als Kachelgrid mit Logo, Markenfarbe, Jahreskosten und Vertragszahl;
Logo-Auswahldialog mit Vorauswahl, erneuter Suche über eine Domain und Upload.
- Beträge durchgängig über `Intl.NumberFormat('de-DE')`; Eingaben akzeptieren
deutsche wie englische Schreibweise.
### Geändert
+23 -1
View File
@@ -1,6 +1,7 @@
# Entwicklungs-Helfer für moneyfy.
.DEFAULT_GOAL := help
BACKEND := backend
FRONTEND := frontend
PY := $(BACKEND)/.venv/bin/python
UV := uv
@@ -49,5 +50,26 @@ format: ## Code formatieren und automatisch behebbare Regelverstöße korrigiere
cd $(BACKEND) && .venv/bin/ruff check --fix .
cd $(BACKEND) && .venv/bin/ruff format .
.PHONY: fe-install
fe-install: ## Frontend-Abhängigkeiten installieren
cd $(FRONTEND) && npm ci
.PHONY: fe-dev
fe-dev: ## Frontend mit Hot Reload starten (Proxy auf das Backend)
cd $(FRONTEND) && npm run dev
.PHONY: fe-lint
fe-lint: ## tsc und eslint
cd $(FRONTEND) && npm run typecheck
cd $(FRONTEND) && npm run lint
.PHONY: fe-test
fe-test: ## Frontend-Tests ausführen
cd $(FRONTEND) && npm run test
.PHONY: fe-build
fe-build: ## Produktionsbundle bauen
cd $(FRONTEND) && npm run build
.PHONY: check
check: lint test ## Lint und Tests zusammen
check: lint test fe-lint fe-test ## Alle Prüfungen von Backend und Frontend
+9 -1
View File
@@ -54,7 +54,15 @@ make seed # Kategoriebaum und Standardregeln
make dev # http://localhost:8000/api/docs
```
Nützliche Ziele: `make test`, `make lint`, `make format`, `make check`.
In einem zweiten Terminal das Frontend, es leitet `/api` an das Backend weiter:
```bash
make fe-install
make fe-dev # http://localhost:5173
```
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
## Konfiguration
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
+33
View File
@@ -0,0 +1,33 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.es2022 },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
},
},
);
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<meta name="description" content="Planung monatlicher Kosten und Einkünfte." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>moneyfy</title>
<script>
// Vor dem ersten Rendern anwenden, damit die Seite nicht kurz aufblitzt.
(function () {
try {
var gespeichert = localStorage.getItem("moneyfy.theme");
if (gespeichert === "light") document.documentElement.classList.remove("dark");
} catch (fehler) {
/* Privatmodus ohne localStorage: es bleibt beim dunklen Standard. */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6109
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "moneyfy-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.5.0",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.3",
"vitest": "^3.2.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#22c55e"/>
<path d="M9 22V10h3.2l3.8 6.4L19.8 10H23v12h-2.8v-7.3L16.9 20h-1.8l-3.3-5.3V22H9z" fill="#04140b"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

+43
View File
@@ -0,0 +1,43 @@
/** Routen der Anwendung und die Weichen für die Anmeldung. */
import { Navigate, Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
import { useMe } from "@/hooks/useAuth";
import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { LoginPage } from "@/pages/LoginPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
import { SettingsPage } from "@/pages/SettingsPage";
import { TransactionsPage } from "@/pages/TransactionsPage";
export function App() {
const { data: benutzer, isLoading } = useMe();
if (isLoading) {
return (
<div className="flex h-full items-center justify-center p-8">
<Skeleton className="h-32 w-full max-w-sm" />
</div>
);
}
if (!benutzer) return <LoginPage />;
// Solange das Startpasswort gilt, sind alle Fachrouten gesperrt.
if (benutzer.must_change_password) return <ChangePasswordPage />;
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/recurrences" replace />} />
<Route path="/recurrences" element={<RecurrencesPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/merchants" element={<MerchantsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/recurrences" replace />} />
</Route>
</Routes>
);
}
+107
View File
@@ -0,0 +1,107 @@
/** Auswahlfelder für Konten, Kategorien und Firmen. */
import { Select } from "@/components/ui/Field";
import { useAccounts, useCategoryTree, useMerchants } from "@/hooks/useEntities";
import type { EntryKind } from "@/types/api";
export function AccountSelect({
id,
value,
onChange,
required,
}: {
id?: string;
value: number | null;
onChange: (value: number) => void;
required?: boolean;
}) {
const { data: konten = [] } = useAccounts(true);
return (
<Select
id={id}
required={required}
value={value ?? ""}
onChange={(ereignis) => onChange(Number(ereignis.target.value))}
>
<option value="" disabled>
Konto wählen
</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.name}
</option>
))}
</Select>
);
}
export function CategorySelect({
id,
value,
kind,
onChange,
required,
}: {
id?: string;
value: number | null;
kind: EntryKind;
onChange: (value: number) => void;
required?: boolean;
}) {
const { data: baum = [] } = useCategoryTree();
const passend = baum.filter((oberkategorie) => oberkategorie.kind === kind);
return (
<Select
id={id}
required={required}
value={value ?? ""}
onChange={(ereignis) => onChange(Number(ereignis.target.value))}
>
<option value="" disabled>
Kategorie wählen
</option>
{passend.map((oberkategorie) => (
<optgroup key={oberkategorie.id} label={oberkategorie.name}>
<option value={oberkategorie.id}>{oberkategorie.name} (allgemein)</option>
{oberkategorie.children.map((unterkategorie) => (
<option key={unterkategorie.id} value={unterkategorie.id}>
{unterkategorie.name}
</option>
))}
</optgroup>
))}
</Select>
);
}
export function MerchantSelect({
id,
value,
onChange,
}: {
id?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { data } = useMerchants();
const firmen = data?.items ?? [];
return (
<Select
id={id}
value={value ?? ""}
onChange={(ereignis) =>
onChange(ereignis.target.value ? Number(ereignis.target.value) : null)
}
>
<option value="">Keine Firma</option>
{firmen.map((firma) => (
<option key={firma.id} value={firma.id}>
{firma.name}
</option>
))}
</Select>
);
}
+51
View File
@@ -0,0 +1,51 @@
/** Logo einer Firma; ohne hinterlegtes Bild erscheinen die Initialen. */
import { useThemeStore } from "@/store/theme";
import { cn } from "@/lib/cn";
import { initialsOf } from "@/lib/initials";
import { logoUrl } from "@/lib/api";
import type { Merchant } from "@/types/api";
export interface MerchantLogoProps {
merchant: Pick<Merchant, "name" | "logo_asset_id" | "brand_color" | "brand_color_dark">;
size?: number;
className?: string;
}
export function MerchantLogo({ merchant, size = 40, className }: MerchantLogoProps) {
const theme = useThemeStore((zustand) => zustand.theme);
// Auf dunklem Grund die aufgehellte Variante nehmen sie erfüllt den Kontrast.
const farbe =
(theme === "dark" ? merchant.brand_color_dark : merchant.brand_color) ??
merchant.brand_color ??
"rgb(var(--color-faint))";
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-line bg-raised",
className,
)}
style={{ width: size, height: size }}
>
{merchant.logo_asset_id ? (
<img
src={logoUrl(merchant.logo_asset_id)}
alt=""
width={size}
height={size}
loading="lazy"
className="h-full w-full object-contain p-1.5"
/>
) : (
<span
aria-hidden
className="font-semibold"
style={{ color: farbe, fontSize: Math.round(size * 0.36) }}
>
{initialsOf(merchant.name)}
</span>
)}
</div>
);
}
@@ -0,0 +1,172 @@
/** Tests des Logo-Auswahldialogs. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MerchantLogoDialog } from "@/components/MerchantLogoDialog";
import { renderWithProviders } from "@/test/utils";
import type { LogoCandidate, Merchant } from "@/types/api";
const FIRMA: Merchant = {
id: 3,
name: "Netflix",
normalized_name: "netflix",
domain: "netflix.com",
aliases: [],
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
logo_source: null,
logo_status: "pending",
created_at: "2026-03-01T10:00:00+01:00",
};
const KANDIDATEN: LogoCandidate[] = [
{
candidate_id: 11,
source: "simple-icons",
title: "Netflix",
score: 1,
mime: "image/svg+xml",
width: null,
height: null,
brand_color: "#e50914",
is_preselected: true,
},
{
candidate_id: 12,
source: "favicon",
title: "Netflix",
score: 0.5,
mime: "image/png",
width: 128,
height: 128,
brand_color: "#e40813",
is_preselected: false,
},
{
candidate_id: 13,
source: "generated",
title: "Netflix",
score: 0.1,
mime: "image/svg+xml",
width: 96,
height: 96,
brand_color: "#f97316",
is_preselected: false,
},
];
/** Antwortet auf Suche und Auswahl und merkt sich die gesendeten Anfragen. */
function mockLogoApi(kandidaten: LogoCandidate[] = KANDIDATEN) {
const anfragen: { url: string; method: string; body: string | null }[] = [];
const nachbildung = vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
anfragen.push({
url,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? init.body : null,
});
if (url.includes("/logo/search")) {
return new Response(JSON.stringify({ merchant_id: 3, candidates: kandidaten }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/logo/select")) {
return new Response(JSON.stringify({ ...FIRMA, logo_asset_id: 11, logo_status: "manual" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
});
vi.stubGlobal("fetch", nachbildung);
return anfragen;
}
describe("MerchantLogoDialog", () => {
beforeEach(() => {
mockLogoApi();
});
it("sucht beim Öffnen und wählt den besten Treffer vor", async () => {
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("radiogroup", { name: "Logos" })).toBeInTheDocument();
});
const auswahl = screen.getAllByRole("radio");
expect(auswahl).toHaveLength(3);
// Der Kandidat mit is_preselected ist markiert, genau einer.
expect(auswahl.filter((knopf) => knopf.getAttribute("aria-checked") === "true")).toHaveLength(1);
expect(auswahl[0]).toHaveAttribute("aria-checked", "true");
expect(screen.getByAltText("Vorschlag von Markenkatalog")).toBeInTheDocument();
expect(screen.getByAltText("Vorschlag von Erzeugt")).toBeInTheDocument();
});
it("übernimmt einen anderen Kandidaten", async () => {
const anfragen = mockLogoApi();
const nutzer = userEvent.setup();
const schliessen = vi.fn();
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={schliessen} />);
await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
await nutzer.click(screen.getAllByRole("radio")[1]!);
await nutzer.click(screen.getByRole("button", { name: "Übernehmen" }));
await waitFor(() => expect(schliessen).toHaveBeenCalled());
const auswahl = anfragen.find((eintrag) => eintrag.url.includes("/logo/select"));
expect(auswahl?.body).toBe(JSON.stringify({ candidate_id: 12 }));
});
it("sucht mit einer geänderten Domain erneut", async () => {
const anfragen = mockLogoApi();
const nutzer = userEvent.setup();
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
const feld = screen.getByLabelText("Domain");
await nutzer.clear(feld);
await nutzer.type(feld, "netflix.de");
await nutzer.click(screen.getByRole("button", { name: "Suchen" }));
await waitFor(() => {
expect(
anfragen.some((eintrag) => eintrag.url.includes("domain=netflix.de")),
).toBe(true);
});
});
it("zeigt die hinterlegte Domain vor", async () => {
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByLabelText("Domain")).toHaveValue("netflix.com");
});
});
it("bietet den Upload an, wenn nichts gefunden wurde", async () => {
mockLogoApi([]);
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/Keine Vorschläge/)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Eigenes Logo hochladen/ })).toBeInTheDocument();
});
it("rendert nichts ohne Firma", () => {
const { container } = renderWithProviders(
<MerchantLogoDialog merchant={null} open onClose={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,214 @@
/**
* Auswahldialog für Firmenlogos.
*
* Die Kandidaten liefert das Backend samt Vorauswahl; alle liegen bereits im
* lokalen Cache und werden über `/api/logos/{id}` angezeigt.
*/
import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { Search, Upload } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field, Input } from "@/components/ui/Field";
import { Skeleton } from "@/components/ui/Feedback";
import { Modal } from "@/components/ui/Modal";
import { useLogoSearch, useSelectLogo, useUploadLogo } from "@/hooks/useEntities";
import { logoUrl } from "@/lib/api";
import { cn } from "@/lib/cn";
import { toast } from "@/store/toast";
import type { LogoCandidate, LogoSource, Merchant } from "@/types/api";
const QUELLEN: Record<LogoSource, string> = {
"simple-icons": "Markenkatalog",
logodev: "logo.dev",
brandfetch: "Brandfetch",
favicon: "Favicon",
upload: "Hochgeladen",
generated: "Erzeugt",
};
const MAX_UPLOAD_BYTES = 1_048_576;
export function MerchantLogoDialog({
merchant,
open,
onClose,
}: {
merchant: Merchant | null;
open: boolean;
onClose: () => void;
}) {
const [kandidaten, setKandidaten] = useState<LogoCandidate[]>([]);
const [gewaehlt, setGewaehlt] = useState<number | null>(null);
const [domain, setDomain] = useState("");
const dateifeld = useRef<HTMLInputElement>(null);
const suchen = useLogoSearch();
const uebernehmen = useSelectLogo();
const hochladen = useUploadLogo();
// Beim Öffnen einmal automatisch suchen.
useEffect(() => {
if (!open || !merchant) return;
setDomain(merchant.domain ?? "");
setKandidaten([]);
setGewaehlt(null);
suchen.mutate(
{ merchantId: merchant.id },
{
onSuccess: (ergebnis) => {
setKandidaten(ergebnis.candidates);
const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
setGewaehlt(beste?.candidate_id ?? ergebnis.candidates[0]?.candidate_id ?? null);
},
},
);
// Nur beim Öffnen bzw. Wechsel der Firma.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, merchant?.id]);
function erneutSuchen() {
if (!merchant) return;
suchen.mutate(
{ merchantId: merchant.id, domain: domain.trim() || undefined },
{
onSuccess: (ergebnis) => {
setKandidaten(ergebnis.candidates);
const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
setGewaehlt(beste?.candidate_id ?? null);
},
},
);
}
function dateiGewaehlt(ereignis: ChangeEvent<HTMLInputElement>) {
const datei = ereignis.target.files?.[0];
ereignis.target.value = "";
if (!datei || !merchant) return;
if (datei.size > MAX_UPLOAD_BYTES) {
toast.error("Die Datei ist zu groß.", "Erlaubt sind höchstens 1 MB.");
return;
}
hochladen.mutate({ merchantId: merchant.id, file: datei }, { onSuccess: onClose });
}
function speichern() {
if (!merchant || gewaehlt === null) return;
uebernehmen.mutate({ merchantId: merchant.id, candidateId: gewaehlt }, { onSuccess: onClose });
}
if (!merchant) return null;
return (
<Modal
open={open}
onClose={onClose}
title={`Logo für ${merchant.name}`}
description="Der beste Treffer ist vorausgewählt. Alle Bilder liegen bereits lokal."
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={speichern}
loading={uebernehmen.isPending}
disabled={gewaehlt === null}
>
Übernehmen
</Button>
</>
}
>
<div className="space-y-4">
<div className="flex items-end gap-2">
<Field
label="Domain"
className="flex-1"
hint="Verbessert die Trefferquote deutlich, etwa netflix.com"
>
{(id) => (
<Input
id={id}
value={domain}
placeholder="beispiel.de"
onChange={(ereignis) => setDomain(ereignis.target.value)}
onKeyDown={(ereignis) => {
if (ereignis.key === "Enter") {
ereignis.preventDefault();
erneutSuchen();
}
}}
/>
)}
</Field>
<Button onClick={erneutSuchen} loading={suchen.isPending} className="mb-[1.375rem]">
<Search aria-hidden className="h-4 w-4" />
Suchen
</Button>
</div>
{suchen.isPending ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-28" />
))}
</div>
) : kandidaten.length > 0 ? (
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-3" role="radiogroup" aria-label="Logos">
{kandidaten.map((kandidat) => (
<li key={kandidat.candidate_id}>
<button
type="button"
role="radio"
aria-checked={gewaehlt === kandidat.candidate_id}
onClick={() => setGewaehlt(kandidat.candidate_id)}
className={cn(
"flex w-full flex-col items-center gap-2 rounded-card border p-3 transition",
gewaehlt === kandidat.candidate_id
? "border-accent bg-accent/10"
: "border-line bg-raised hover:border-faint",
)}
>
<img
src={logoUrl(kandidat.candidate_id)}
alt={`Vorschlag von ${QUELLEN[kandidat.source]}`}
className="h-12 w-12 object-contain"
/>
<span className="text-[11px] text-muted">{QUELLEN[kandidat.source]}</span>
{kandidat.brand_color && (
<span
aria-hidden
className="h-1 w-8 rounded-full"
style={{ backgroundColor: kandidat.brand_color }}
/>
)}
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-xs text-muted">
Keine Vorschläge. Lade stattdessen ein eigenes Bild hoch.
</p>
)}
<div className="border-t border-line pt-3">
<input
ref={dateifeld}
type="file"
accept="image/svg+xml,image/png,image/jpeg"
className="hidden"
onChange={dateiGewaehlt}
/>
<Button onClick={() => dateifeld.current?.click()} loading={hochladen.isPending}>
<Upload aria-hidden className="h-4 w-4" />
Eigenes Logo hochladen
</Button>
<p className="mt-1.5 text-xs text-faint">SVG, PNG oder JPEG bis 1 MB.</p>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,194 @@
/** Tests des geführten RRULE-Editors. */
import { useState } from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RRuleEditor } from "@/components/RRuleEditor";
import { mockFetch, renderWithProviders } from "@/test/utils";
/** Hülle, die den Zustand hält so wie es das Formular tut. */
function Huelle({
start = "FREQ=MONTHLY;BYMONTHDAY=1",
recurrenceId = null,
}: {
start?: string;
recurrenceId?: number | null;
}) {
const [regel, setRegel] = useState(start);
return (
<>
<RRuleEditor
rrule={regel}
dtstart="2026-03-01"
recurrenceId={recurrenceId}
onChange={setRegel}
/>
<output data-testid="regel">{regel}</output>
</>
);
}
describe("RRuleEditor", () => {
beforeEach(() => {
mockFetch({});
});
it("zeigt Klartext und Regel zur Vorgabe", () => {
renderWithProviders(<Huelle />);
expect(screen.getByText("Jeden 1. des Monats, ab 01.03.2026")).toBeInTheDocument();
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=1");
});
it("baut beim Wechsel auf quartalsweise die passende Regel", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Quartalsweise" }));
expect(screen.getByTestId("regel")).toHaveTextContent(
"FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
);
expect(screen.getByText(/Quartalsweise am 1\./)).toBeInTheDocument();
});
it("übernimmt den gewählten Tag im Monat", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.selectOptions(screen.getByLabelText("Tag im Monat"), "15");
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=15");
});
it("erzeugt für den Monatsletzten die BYSETPOS-Regel", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Monatsletzter" }));
expect(screen.getByTestId("regel")).toHaveTextContent(
"FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1",
);
expect(screen.getByText(/am letzten Tag/)).toBeInTheDocument();
});
it("erlaubt im Expertenmodus die freie Eingabe", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
const feld = screen.getByLabelText(/RRULE/);
await nutzer.clear(feld);
await nutzer.type(feld, "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
expect(screen.getByText(/Jährlich am 4\. Juni/)).toBeInTheDocument();
});
it("wählt zu einer bestehenden Regel die passende Vorlage vor", () => {
renderWithProviders(<Huelle start="FREQ=WEEKLY;BYDAY=MO,TH" />);
expect(screen.getByRole("button", { name: "Wöchentlich" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: "Montag" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Donnerstag" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("weist darauf hin, dass Termine erst nach dem Speichern erscheinen", () => {
renderWithProviders(<Huelle />);
expect(screen.getByText(/sobald der Posten gespeichert ist/)).toBeInTheDocument();
});
it("zeigt die vom Backend berechneten Termine", async () => {
mockFetch({
"/recurrences/7/preview": [
{
recurrence_id: 7,
recurrence_title: "Miete",
kind: "expense",
category_id: 1,
merchant_id: null,
account_id: 1,
nominal_date: "2026-03-01",
due_date: "2026-03-02",
effective_date: "2026-03-02",
amount: "950.00",
actual_amount: null,
effective_amount: "950.00",
status: "planned",
is_variable: false,
occurrence_id: null,
note: null,
installment_number: null,
installments_total: null,
},
{
recurrence_id: 7,
recurrence_title: "Miete",
kind: "expense",
category_id: 1,
merchant_id: null,
account_id: 1,
nominal_date: "2026-04-01",
due_date: "2026-04-01",
effective_date: "2026-04-01",
amount: "950.00",
actual_amount: null,
effective_amount: "950.00",
status: "planned",
is_variable: false,
occurrence_id: null,
note: null,
installment_number: null,
installments_total: null,
},
],
});
renderWithProviders(<Huelle recurrenceId={7} />);
await waitFor(() => {
expect(screen.getByText("Nächste Termine")).toBeInTheDocument();
});
// Der 01.03. ist ein Sonntag angezeigt wird der verschobene Zahltag.
expect(screen.getByText("02.03.2026")).toBeInTheDocument();
expect(screen.getByText("01.04.2026")).toBeInTheDocument();
});
it("meldet eine Regel ohne Termine", async () => {
mockFetch({ "/recurrences/7/preview": [] });
renderWithProviders(<Huelle recurrenceId={7} />);
await waitFor(() => {
expect(screen.getByText("Diese Regel ergibt keine Termine.")).toBeInTheDocument();
});
});
it("reicht einen Fehler des Servers an den Expertenmodus durch", async () => {
const nutzer = userEvent.setup();
const bei = vi.fn();
renderWithProviders(
<RRuleEditor
rrule="FREQ=QUARTERLY"
dtstart="2026-03-01"
onChange={bei}
error="Ungültige Wiederholungsregel"
/>,
);
await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
expect(screen.getByText("Ungültige Wiederholungsregel")).toBeInTheDocument();
});
});
+282
View File
@@ -0,0 +1,282 @@
/**
* Geführter Editor für Wiederholungsregeln mit Expertenmodus.
*
* Unabhängig vom gewählten Weg zeigt der Editor immer eine deutsche
* Klartextfassung und sobald der Posten gespeichert ist die nächsten fünf
* Termine, die das Backend tatsächlich berechnet.
*/
import { useEffect, useMemo, useState } from "react";
import { CalendarClock, Info } from "lucide-react";
import { Skeleton } from "@/components/ui/Feedback";
import { Field, Input, Select } from "@/components/ui/Field";
import { useRecurrencePreview } from "@/hooks/useEntities";
import { cn } from "@/lib/cn";
import { addMonthsIso, formatDate } from "@/lib/format";
import {
MONTHS,
PRESETS,
type PresetId,
type PresetState,
WEEKDAYS,
describeRRule,
presetFromRule,
ruleFromPreset,
} from "@/lib/rrule";
export interface RRuleEditorProps {
rrule: string;
dtstart: string;
onChange: (rrule: string) => void;
/** Ist der Posten bereits gespeichert, kommen die Termine vom Server. */
recurrenceId?: number | null;
error?: string | null;
}
export function RRuleEditor({
rrule,
dtstart,
onChange,
recurrenceId = null,
error,
}: RRuleEditorProps) {
const [zustand, setZustand] = useState<PresetState>(() => presetFromRule(rrule, dtstart));
// Wird ein anderer Posten geladen, den Editor neu aufsetzen.
useEffect(() => {
setZustand(presetFromRule(rrule, dtstart));
// Nur beim Wechsel des bearbeiteten Postens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recurrenceId]);
function aendern(teil: Partial<PresetState>) {
const naechster = { ...zustand, ...teil };
setZustand(naechster);
onChange(ruleFromPreset(naechster));
}
const klartext = useMemo(() => describeRRule(rrule, dtstart), [rrule, dtstart]);
return (
<div className="space-y-4">
<Field label="Wiederholung">
{(id) => (
<div id={id} className="flex flex-wrap gap-1.5" role="group" aria-label="Wiederholung">
{PRESETS.map((vorlage) => (
<button
key={vorlage.id}
type="button"
title={vorlage.hint}
aria-pressed={zustand.preset === vorlage.id}
onClick={() => aendern({ preset: vorlage.id as PresetId })}
className={cn(
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition",
zustand.preset === vorlage.id
? "border-accent bg-accent/15 text-accent"
: "border-line bg-raised text-muted hover:text-ink",
)}
>
{vorlage.label}
</button>
))}
</div>
)}
</Field>
<div className="grid gap-3 sm:grid-cols-2">
{(zustand.preset === "monthly" ||
zustand.preset === "everyNMonths" ||
zustand.preset === "quarterly" ||
zustand.preset === "yearly") && (
<Field label="Tag im Monat">
{(id) => (
<Select
id={id}
value={zustand.monthDay}
onChange={(ereignis) => aendern({ monthDay: Number(ereignis.target.value) })}
>
{Array.from({ length: 31 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}.
</option>
))}
</Select>
)}
</Field>
)}
{zustand.preset === "everyNMonths" && (
<Field label="Abstand in Monaten">
{(id) => (
<Input
id={id}
type="number"
min={1}
max={60}
value={zustand.interval}
onChange={(ereignis) => aendern({ interval: Number(ereignis.target.value) })}
/>
)}
</Field>
)}
{zustand.preset === "yearly" && (
<Field label="Monat">
{(id) => (
<Select
id={id}
value={zustand.month}
onChange={(ereignis) => aendern({ month: Number(ereignis.target.value) })}
>
{MONTHS.map((name, index) => (
<option key={name} value={index + 1}>
{name}
</option>
))}
</Select>
)}
</Field>
)}
{zustand.preset === "weekly" && (
<>
<Field label="Wochentage" className="sm:col-span-2">
{(id) => (
<div id={id} className="flex flex-wrap gap-1.5" role="group">
{WEEKDAYS.map((tag) => {
const gewaehlt = zustand.weekdays.includes(tag.code);
return (
<button
key={tag.code}
type="button"
aria-pressed={gewaehlt}
aria-label={tag.label}
onClick={() =>
aendern({
weekdays: gewaehlt
? zustand.weekdays.filter((code) => code !== tag.code)
: [...zustand.weekdays, tag.code],
})
}
className={cn(
"h-8 w-10 rounded-lg border text-xs font-medium transition",
gewaehlt
? "border-accent bg-accent/15 text-accent"
: "border-line bg-raised text-muted hover:text-ink",
)}
>
{tag.short}
</button>
);
})}
</div>
)}
</Field>
<Field label="Abstand in Wochen">
{(id) => (
<Input
id={id}
type="number"
min={1}
max={52}
value={zustand.interval}
onChange={(ereignis) => aendern({ interval: Number(ereignis.target.value) })}
/>
)}
</Field>
</>
)}
{zustand.preset === "custom" && (
<Field
label="RRULE (RFC 5545)"
className="sm:col-span-2"
hint="Ohne DTSTART. Beispiel: FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1"
error={error}
>
{(id) => (
<Input
id={id}
value={zustand.custom}
spellCheck={false}
className="font-mono text-xs"
onChange={(ereignis) => aendern({ custom: ereignis.target.value })}
/>
)}
</Field>
)}
</div>
<div className="rounded-lg border border-line bg-raised px-3 py-2.5">
<div className="flex items-start gap-2">
<Info aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0 text-faint" />
<div className="min-w-0">
<p className="text-sm text-ink">{klartext}</p>
<p className="mt-0.5 truncate font-mono text-[11px] text-faint">{rrule || ""}</p>
</div>
</div>
</div>
{zustand.preset !== "custom" && error && <p className="text-xs text-negative">{error}</p>}
<NextDates recurrenceId={recurrenceId} dtstart={dtstart} />
</div>
);
}
/** Die nächsten fünf Termine berechnet vom Backend, nicht im Browser. */
function NextDates({
recurrenceId,
dtstart,
}: {
recurrenceId: number | null;
dtstart: string;
}) {
const von = dtstart;
const bis = addMonthsIso(dtstart, 36);
const { data, isLoading } = useRecurrencePreview(recurrenceId, von, bis);
if (recurrenceId === null) {
return (
<p className="text-xs text-faint">
Die nächsten Termine erscheinen, sobald der Posten gespeichert ist.
</p>
);
}
if (isLoading) return <Skeleton className="h-16 w-full" />;
const termine = (data ?? []).slice(0, 5);
if (termine.length === 0) {
return <p className="text-xs text-warning">Diese Regel ergibt keine Termine.</p>;
}
return (
<div>
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted">
<CalendarClock aria-hidden className="h-3.5 w-3.5" />
Nächste Termine
</p>
<ul className="flex flex-wrap gap-1.5">
{termine.map((termin) => (
<li
key={termin.nominal_date}
className="rounded-lg border border-line bg-raised px-2 py-1 text-xs tabular text-ink"
title={
termin.due_date !== termin.nominal_date
? `Verschoben vom ${formatDate(termin.nominal_date)}`
: undefined
}
>
{formatDate(termin.due_date)}
{termin.due_date !== termin.nominal_date && (
<span className="ml-1 text-faint"></span>
)}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,365 @@
/** Detailansicht eines wiederkehrenden Postens als seitlicher Drawer. */
import { type FormEvent, useState } from "react";
import { CalendarX2, Pencil, Plus, Trash2, X } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { MerchantLogo } from "@/components/MerchantLogo";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, Skeleton } from "@/components/ui/Feedback";
import { Field, Input } from "@/components/ui/Field";
import { MoneyInput } from "@/components/ui/MoneyInput";
import {
useAddAmountVersion,
useCancelContract,
useDeleteRecurrence,
useRecurrence,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, relativeDays, todayIso } from "@/lib/format";
import { describeRRule } from "@/lib/rrule";
import type { Recurrence } from "@/types/api";
export function RecurrenceDetailDrawer({
recurrenceId,
onClose,
onEdit,
}: {
recurrenceId: number | null;
onClose: () => void;
onEdit: (recurrence: Recurrence) => void;
}) {
const { data: posten, isLoading } = useRecurrence(recurrenceId);
const entfernen = useDeleteRecurrence();
const kuendigen = useCancelContract();
const kategorieName = useCategoryLookup();
const [preisFormular, setPreisFormular] = useState(false);
const [loeschen, setLoeschen] = useState(false);
if (recurrenceId === null) return null;
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/50 animate-fade-in" onClick={onClose} aria-hidden />
<aside
role="dialog"
aria-modal="true"
aria-label="Details des wiederkehrenden Postens"
className="relative flex h-full w-full max-w-lg animate-slide-in-right flex-col border-l border-line bg-surface"
>
<header className="flex items-start justify-between gap-3 border-b border-line p-4">
{isLoading || !posten ? (
<Skeleton className="h-10 w-48" />
) : (
<div className="flex min-w-0 gap-3">
<MerchantLogo
merchant={
posten.merchant ?? {
name: posten.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={40}
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{posten.title}</h2>
<p className="truncate text-xs text-muted">{kategorieName(posten.category_id)}</p>
</div>
</div>
)}
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Schließen">
<X aria-hidden className="h-4 w-4" />
</Button>
</header>
<div className="flex-1 space-y-5 overflow-y-auto p-4">
{isLoading || !posten ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<>
<section className="grid grid-cols-2 gap-3">
<Kennzahl
label="Aktueller Betrag"
value={formatMoney(posten.amount)}
tone={posten.kind === "income" ? "positive" : "default"}
/>
<Kennzahl label="Belastung p. a." value={formatMoney(posten.annual_burden)} />
{posten.monthly_reserve && (
<Kennzahl label="Rücklage / Monat" value={formatMoney(posten.monthly_reserve)} />
)}
{posten.installments && (
<Kennzahl
label="Restschuld"
value={formatMoney(posten.installments.remaining_amount)}
/>
)}
</section>
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Wiederholung</h3>
<p className="text-sm text-ink">
{describeRRule(posten.rrule, posten.dtstart, posten.until)}
</p>
<p className="mt-0.5 font-mono text-[11px] text-faint">{posten.rrule}</p>
{posten.next_dates.length > 0 && (
<ul className="mt-2 flex flex-wrap gap-1.5">
{posten.next_dates.map((termin) => (
<li
key={termin}
className="rounded-lg border border-line bg-raised px-2 py-1 text-xs tabular text-ink"
>
{formatDate(termin)}
<span className="ml-1 text-faint">{relativeDays(termin)}</span>
</li>
))}
</ul>
)}
</section>
{posten.installments && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Ratenzahlung</h3>
<div className="rounded-lg border border-line bg-raised p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-ink">
Rate {posten.installments.paid} von {posten.installments.total}
</span>
<span className="tabular text-muted">
noch {posten.installments.remaining}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line">
<div
className="h-full rounded-full bg-accent transition-all"
style={{
width: `${Math.round(
(posten.installments.paid / posten.installments.total) * 100,
)}%`,
}}
/>
</div>
{posten.installments.final_due_date && (
<p className="mt-2 text-xs text-faint">
Letzte Rate am {formatDate(posten.installments.final_due_date)}
</p>
)}
</div>
</section>
)}
{posten.contract_term && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Vertrag</h3>
<dl className="space-y-1.5 rounded-lg border border-line bg-raised p-3 text-sm">
<Zeile label="Laufzeit bis" wert={formatDate(posten.contract_term.term_end)} />
{posten.contract_term.notice_deadline && (
<Zeile
label="Kündigen bis"
wert={`${formatDate(posten.contract_term.notice_deadline)} (${relativeDays(
posten.contract_term.notice_deadline,
)})`}
/>
)}
{posten.contract_term.renews_on && (
<Zeile
label="Verlängert sich am"
wert={formatDate(posten.contract_term.renews_on)}
/>
)}
{posten.contract_term.is_cancelled && (
<div className="pt-1">
<Badge tone="negative">
Gekündigt zum {formatDate(posten.contract_cancelled_at)}
</Badge>
</div>
)}
</dl>
</section>
)}
<section>
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-medium text-muted">Preishistorie</h3>
<Button size="sm" variant="ghost" onClick={() => setPreisFormular((auf) => !auf)}>
<Plus aria-hidden className="h-3.5 w-3.5" />
Preisversion
</Button>
</div>
{preisFormular && (
<AmountVersionForm
recurrenceId={posten.id}
onDone={() => setPreisFormular(false)}
/>
)}
<ul className="mt-2 divide-y divide-line rounded-lg border border-line">
{[...posten.amount_versions]
.sort((links, rechts) => rechts.valid_from.localeCompare(links.valid_from))
.map((version) => (
<li
key={version.id}
className="flex items-baseline justify-between px-3 py-2 text-sm"
>
<span className="text-muted">ab {formatDate(version.valid_from)}</span>
<span className="tabular font-medium text-ink">
{formatMoney(version.amount)}
</span>
</li>
))}
</ul>
</section>
{posten.notes && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Notiz</h3>
<p className="whitespace-pre-wrap text-sm text-ink">{posten.notes}</p>
</section>
)}
</>
)}
</div>
{posten && (
<footer className="flex flex-wrap gap-2 border-t border-line p-4">
<Button onClick={() => onEdit(posten)}>
<Pencil aria-hidden className="h-4 w-4" />
Bearbeiten
</Button>
{posten.contract_term && !posten.contract_term.is_cancelled && (
<Button
onClick={() => kuendigen.mutate({ id: posten.id })}
loading={kuendigen.isPending}
>
<CalendarX2 aria-hidden className="h-4 w-4" />
Kündigen
</Button>
)}
<Button variant="danger" className="ml-auto" onClick={() => setLoeschen(true)}>
<Trash2 aria-hidden className="h-4 w-4" />
Löschen
</Button>
</footer>
)}
</aside>
<ConfirmDialog
open={loeschen}
title="Posten löschen"
description={`${posten?.title}“ wird mitsamt Preishistorie und erfassten Fälligkeiten gelöscht. Um die Historie zu erhalten, setze den Posten stattdessen auf inaktiv.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(false)}
onConfirm={() => {
if (!posten) return;
entfernen.mutate(posten.id, {
onSuccess: () => {
setLoeschen(false);
onClose();
},
});
}}
/>
</div>
);
}
function Kennzahl({
label,
value,
tone = "default",
}: {
label: string;
value: string;
tone?: "default" | "positive";
}) {
return (
<div className="rounded-lg border border-line bg-raised p-3">
<p className="text-xs text-muted">{label}</p>
<p
className={`mt-0.5 tabular text-lg font-semibold ${
tone === "positive" ? "text-positive" : "text-ink"
}`}
>
{value}
</p>
</div>
);
}
function Zeile({ label, wert }: { label: string; wert: string }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-muted">{label}</dt>
<dd className="tabular text-ink">{wert}</dd>
</div>
);
}
function AmountVersionForm({
recurrenceId,
onDone,
}: {
recurrenceId: number;
onDone: () => void;
}) {
const [betrag, setBetrag] = useState("");
const [gueltigAb, setGueltigAb] = useState(todayIso());
const anlegen = useAddAmountVersion();
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!betrag) return;
anlegen.mutate(
{ recurrenceId, daten: { amount: betrag, valid_from: gueltigAb } },
{ onSuccess: onDone },
);
}
return (
<form
onSubmit={absenden}
className="mt-2 space-y-3 rounded-lg border border-line bg-raised p-3"
>
<p className="text-xs text-muted">
Ab dem Stichtag gilt der neue Betrag. Vergangene Fälligkeiten bleiben unverändert.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Neuer Betrag" required>
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
</Field>
<Field label="Gültig ab" required>
{(id) => (
<Input
id={id}
type="date"
value={gueltigAb}
onChange={(ereignis) => setGueltigAb(ereignis.target.value)}
/>
)}
</Field>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" onClick={onDone}>
Abbrechen
</Button>
<Button
size="sm"
variant="primary"
onClick={absenden}
loading={anlegen.isPending}
disabled={!betrag}
>
Anlegen
</Button>
</div>
</form>
);
}
+452
View File
@@ -0,0 +1,452 @@
/** Formular für wiederkehrende Posten samt RRULE-Editor. */
import { type FormEvent, useState } from "react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { RRuleEditor } from "@/components/RRuleEditor";
import { Button } from "@/components/ui/Button";
import { Checkbox, Field, Input, Select, Textarea } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useAccounts, useSaveRecurrence } from "@/hooks/useEntities";
import { ApiError } from "@/lib/api";
import { todayIso } from "@/lib/format";
import type { BusinessDayShift, EntryKind, Recurrence } from "@/types/api";
interface FormularZustand {
kind: EntryKind;
title: string;
amount: string;
rrule: string;
dtstart: string;
until: string;
category_id: number | null;
account_id: number | null;
merchant_id: number | null;
business_day_shift: BusinessDayShift;
is_variable: boolean;
reserve_enabled: boolean;
is_active: boolean;
notes: string;
installments_total: string;
principal_amount: string;
contract_start: string;
contract_min_term_months: string;
contract_notice_period_days: string;
contract_auto_renew_months: string;
}
function leeresFormular(kontoId: number | null): FormularZustand {
return {
kind: "expense",
title: "",
amount: "",
rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
dtstart: todayIso(),
until: "",
category_id: null,
account_id: kontoId,
merchant_id: null,
business_day_shift: "next",
is_variable: false,
reserve_enabled: false,
is_active: true,
notes: "",
installments_total: "",
principal_amount: "",
contract_start: "",
contract_min_term_months: "",
contract_notice_period_days: "",
contract_auto_renew_months: "",
};
}
function ausPosten(posten: Recurrence): FormularZustand {
return {
kind: posten.kind,
title: posten.title,
amount: posten.amount,
rrule: posten.rrule,
dtstart: posten.dtstart,
until: posten.until ?? "",
category_id: posten.category_id,
account_id: posten.account_id,
merchant_id: posten.merchant_id,
business_day_shift: posten.business_day_shift,
is_variable: posten.is_variable,
reserve_enabled: posten.reserve_enabled,
is_active: posten.is_active,
notes: posten.notes ?? "",
installments_total: posten.installments_total?.toString() ?? "",
principal_amount: posten.principal_amount ?? "",
contract_start: posten.contract_start ?? "",
contract_min_term_months: posten.contract_min_term_months?.toString() ?? "",
contract_notice_period_days: posten.contract_notice_period_days?.toString() ?? "",
contract_auto_renew_months: posten.contract_auto_renew_months?.toString() ?? "",
};
}
function zahlOderNull(wert: string): number | null {
const zahl = Number.parseInt(wert, 10);
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
}
export function RecurrenceFormDialog({
recurrence,
open,
onClose,
}: {
recurrence: Recurrence | null | undefined;
open: boolean;
onClose: () => void;
}) {
const { data: konten = [] } = useAccounts(true);
const speichern = useSaveRecurrence();
const [formular, setFormular] = useState<FormularZustand>(() => leeresFormular(null));
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
const [erweitert, setErweitert] = useState(false);
if (open && initialisiert !== (recurrence?.id ?? null)) {
setFormular(recurrence ? ausPosten(recurrence) : leeresFormular(konten[0]?.id ?? null));
setErweitert(Boolean(recurrence?.installments_total || recurrence?.contract_min_term_months));
setInitialisiert(recurrence?.id ?? null);
}
function setzen(teil: Partial<FormularZustand>) {
setFormular((alt) => ({ ...alt, ...teil }));
}
const absendbar =
formular.title.trim() !== "" &&
formular.amount !== "" &&
formular.rrule.trim() !== "" &&
formular.category_id !== null &&
formular.account_id !== null;
// Fehler zur Wiederholungsregel gehören an den Editor, nicht in einen Toast.
const regelFehler =
speichern.error instanceof ApiError &&
(speichern.error.code === "invalid_rrule" ||
(speichern.error.fieldMessage ?? "").toLowerCase().includes("wiederholungsregel"))
? (speichern.error.fieldMessage ?? speichern.error.message)
: null;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
speichern.mutate(
{
id: recurrence?.id,
daten: {
kind: formular.kind,
title: formular.title.trim(),
amount: formular.amount,
rrule: formular.rrule.trim(),
dtstart: formular.dtstart,
until: formular.until || null,
category_id: formular.category_id!,
account_id: formular.account_id!,
merchant_id: formular.merchant_id,
business_day_shift: formular.business_day_shift,
is_variable: formular.is_variable,
reserve_enabled: formular.reserve_enabled,
is_active: formular.is_active,
notes: formular.notes.trim() || null,
installments_total: zahlOderNull(formular.installments_total),
principal_amount: formular.principal_amount || null,
contract_start: formular.contract_start || null,
contract_min_term_months: zahlOderNull(formular.contract_min_term_months),
contract_notice_period_days: formular.contract_notice_period_days
? Number.parseInt(formular.contract_notice_period_days, 10)
: null,
contract_auto_renew_months: zahlOderNull(formular.contract_auto_renew_months),
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
size="lg"
title={recurrence ? "Posten bearbeiten" : "Neuer wiederkehrender Posten"}
description={
recurrence
? "Eine Betragsänderung hier gilt rückwirkend. Für einen Preiswechsel ab einem Stichtag lege im Detail eine Preisversion an."
: undefined
}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!absendbar}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Richtung" required>
{(id) => (
<Select
id={id}
value={formular.kind}
onChange={(ereignis) =>
setzen({ kind: ereignis.target.value as EntryKind, category_id: null })
}
>
<option value="expense">Ausgabe</option>
<option value="income">Einkunft</option>
</Select>
)}
</Field>
<Field label="Betrag" required hint="Aktueller Betrag der Serie.">
{(id) => (
<MoneyInput
id={id}
value={formular.amount}
onValueChange={(wert) => setzen({ amount: wert })}
/>
)}
</Field>
<Field label="Titel" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={formular.title}
required
placeholder="Netflix Standard"
onChange={(ereignis) => setzen({ title: ereignis.target.value })}
/>
)}
</Field>
<Field label="Konto" required>
{(id) => (
<AccountSelect
id={id}
required
value={formular.account_id}
onChange={(wert) => setzen({ account_id: wert })}
/>
)}
</Field>
<Field label="Kategorie" required>
{(id) => (
<CategorySelect
id={id}
required
kind={formular.kind}
value={formular.category_id}
onChange={(wert) => setzen({ category_id: wert })}
/>
)}
</Field>
<Field label="Firma">
{(id) => (
<MerchantSelect
id={id}
value={formular.merchant_id}
onChange={(wert) => setzen({ merchant_id: wert })}
/>
)}
</Field>
<Field label="Erste Fälligkeit" required>
{(id) => (
<Input
id={id}
type="date"
required
value={formular.dtstart}
onChange={(ereignis) => setzen({ dtstart: ereignis.target.value })}
/>
)}
</Field>
</div>
<div className="border-t border-line pt-4">
<RRuleEditor
rrule={formular.rrule}
dtstart={formular.dtstart}
recurrenceId={recurrence?.id ?? null}
error={regelFehler}
onChange={(regel) => setzen({ rrule: regel })}
/>
</div>
<div className="grid gap-3 border-t border-line pt-4 sm:grid-cols-2">
<Field
label="Wenn der Termin auf Wochenende oder Feiertag fällt"
className="sm:col-span-2"
>
{(id) => (
<Select
id={id}
value={formular.business_day_shift}
onChange={(ereignis) =>
setzen({ business_day_shift: ereignis.target.value as BusinessDayShift })
}
>
<option value="next">Auf den nächsten Werktag verschieben</option>
<option value="previous">Auf den vorherigen Werktag verschieben</option>
<option value="none">Nicht verschieben</option>
</Select>
)}
</Field>
<Field label="Serienende" hint="Leer lassen für unbegrenzt.">
{(id) => (
<Input
id={id}
type="date"
value={formular.until}
onChange={(ereignis) => setzen({ until: ereignis.target.value })}
/>
)}
</Field>
<div className="flex flex-col justify-end gap-2 pb-1">
<Checkbox
label="Betrag ist geschätzt"
hint="Das Ist weicht regelmäßig ab, etwa bei Strom."
checked={formular.is_variable}
onChange={(ereignis) => setzen({ is_variable: ereignis.target.checked })}
/>
<Checkbox
label="Rücklage bilden"
hint="Verteilt nicht-monatliche Posten auf zwölf Monate."
checked={formular.reserve_enabled}
onChange={(ereignis) => setzen({ reserve_enabled: ereignis.target.checked })}
/>
{recurrence && (
<Checkbox
label="Aktiv"
checked={formular.is_active}
onChange={(ereignis) => setzen({ is_active: ereignis.target.checked })}
/>
)}
</div>
</div>
<div className="border-t border-line pt-4">
<button
type="button"
onClick={() => setErweitert((offen) => !offen)}
aria-expanded={erweitert}
className="text-xs font-medium text-muted transition hover:text-ink"
>
{erweitert ? "▾" : "▸"} Raten und Vertragsdaten
</button>
{erweitert && (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="Anzahl Raten" hint="Beendet die Serie unabhängig von der Regel.">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.installments_total}
onChange={(ereignis) => setzen({ installments_total: ereignis.target.value })}
/>
)}
</Field>
<Field label="Darlehenssumme" hint="Grundlage der Restschuldberechnung.">
{(id) => (
<MoneyInput
id={id}
value={formular.principal_amount}
onValueChange={(wert) => setzen({ principal_amount: wert })}
/>
)}
</Field>
<Field label="Vertragsbeginn">
{(id) => (
<Input
id={id}
type="date"
value={formular.contract_start}
onChange={(ereignis) => setzen({ contract_start: ereignis.target.value })}
/>
)}
</Field>
<Field label="Mindestlaufzeit in Monaten">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.contract_min_term_months}
onChange={(ereignis) =>
setzen({ contract_min_term_months: ereignis.target.value })
}
/>
)}
</Field>
<Field label="Kündigungsfrist in Tagen">
{(id) => (
<Input
id={id}
type="number"
min={0}
value={formular.contract_notice_period_days}
onChange={(ereignis) =>
setzen({ contract_notice_period_days: ereignis.target.value })
}
/>
)}
</Field>
<Field label="Verlängerung in Monaten" hint="Leer lassen, wenn keine erfolgt.">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.contract_auto_renew_months}
onChange={(ereignis) =>
setzen({ contract_auto_renew_months: ereignis.target.value })
}
/>
)}
</Field>
</div>
)}
</div>
<Field label="Notiz">
{(id) => (
<Textarea
id={id}
rows={2}
value={formular.notes}
onChange={(ereignis) => setzen({ notes: ereignis.target.value })}
/>
)}
</Field>
</form>
</Modal>
);
}
@@ -0,0 +1,113 @@
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { Sidebar } from "@/components/layout/Sidebar";
import { Button } from "@/components/ui/Button";
import { useLogout, useMe } from "@/hooks/useAuth";
import { useThemeStore } from "@/store/theme";
export function AppLayout() {
const [menuOffen, setMenuOffen] = useState(false);
const { data: benutzer } = useMe();
const abmelden = useLogout();
const theme = useThemeStore((zustand) => zustand.theme);
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
return (
<div className="flex h-full">
{/* Seitenleiste ab Bildschirmbreite lg dauerhaft sichtbar */}
<aside className="hidden w-56 shrink-0 border-r border-line bg-surface lg:block">
<Sidebar />
</aside>
{menuOffen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setMenuOffen(false)}
aria-hidden
/>
<aside className="relative h-full w-56 border-r border-line bg-surface">
<Sidebar onNavigate={() => setMenuOffen(false)} />
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-line bg-surface px-4">
<Button
variant="ghost"
size="sm"
className="lg:hidden"
onClick={() => setMenuOffen((offen) => !offen)}
aria-label={menuOffen ? "Menü schließen" : "Menü öffnen"}
>
{menuOffen ? (
<X aria-hidden className="h-4 w-4" />
) : (
<Menu aria-hidden className="h-4 w-4" />
)}
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Zur hellen Ansicht wechseln" : "Zur dunklen Ansicht wechseln"}
>
{theme === "dark" ? (
<Sun aria-hidden className="h-4 w-4" />
) : (
<Moon aria-hidden className="h-4 w-4" />
)}
</Button>
{benutzer && (
<span className="hidden text-xs text-muted sm:inline">{benutzer.username}</span>
)}
<Button
variant="ghost"
size="sm"
onClick={() => abmelden.mutate()}
loading={abmelden.isPending}
aria-label="Abmelden"
>
<LogOut aria-hidden className="h-4 w-4" />
</Button>
</div>
</header>
<main className="min-w-0 flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
/** Kopfzeile einer Seite mit Titel und optionalen Aktionen. */
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight text-ink">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
@@ -0,0 +1,51 @@
import { NavLink } from "react-router-dom";
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
import { cn } from "@/lib/cn";
interface NavEintrag {
to: string;
label: string;
icon: LucideIcon;
}
const NAVIGATION: NavEintrag[] = [
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
{ to: "/transactions", label: "Buchungen", icon: Receipt },
{ to: "/merchants", label: "Firmen", icon: Building2 },
{ to: "/settings", label: "Einstellungen", icon: Settings },
];
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
return (
<nav aria-label="Hauptnavigation" className="flex h-full flex-col gap-1 p-3">
<div className="mb-4 flex items-center gap-2 px-2 py-1">
<span
aria-hidden
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-sm font-bold text-accent-ink"
>
m
</span>
<span className="text-base font-semibold tracking-tight text-ink">moneyfy</span>
</div>
{NAVIGATION.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
isActive ? "bg-accent/15 text-accent" : "text-muted hover:bg-raised hover:text-ink",
)
}
>
<Icon aria-hidden className="h-4 w-4 shrink-0" />
{label}
</NavLink>
))}
</nav>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const VARIANTEN: Record<Variant, string> = {
primary: "bg-accent text-accent-ink hover:brightness-110 disabled:hover:brightness-100",
secondary: "border border-line bg-raised text-ink hover:bg-line",
ghost: "text-muted hover:bg-raised hover:text-ink",
danger: "border border-negative/40 bg-negative/10 text-negative hover:bg-negative/20",
};
const GROESSEN: Record<Size, string> = {
sm: "h-8 gap-1.5 px-2.5 text-xs",
md: "h-10 gap-2 px-4 text-sm",
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", loading = false, className, children, disabled, ...rest },
ref,
) {
return (
<button
ref={ref}
type="button"
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition",
"disabled:cursor-not-allowed disabled:opacity-50",
VARIANTEN[variant],
GROESSEN[size],
className,
)}
{...rest}
>
{loading && <Loader2 aria-hidden className="h-4 w-4 animate-spin" />}
{children}
</button>
);
});
+115
View File
@@ -0,0 +1,115 @@
/** Ladeskelette, Leerzustände, Abzeichen und Bestätigungsdialog. */
import { type ReactNode } from "react";
import { AlertTriangle, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/cn";
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded-md bg-raised", className)} aria-hidden />;
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2" role="status" aria-label="Daten werden geladen">
{Array.from({ length: rows }, (_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</div>
);
}
export interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-card border border-dashed border-line px-6 py-14 text-center">
<Icon aria-hidden className="h-9 w-9 text-faint" />
<h3 className="mt-3 text-sm font-semibold text-ink">{title}</h3>
<p className="mt-1 max-w-sm text-xs text-muted">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}
type BadgeTone = "neutral" | "accent" | "positive" | "negative" | "warning";
const TOENE: Record<BadgeTone, string> = {
neutral: "bg-raised text-muted",
accent: "bg-accent/15 text-accent",
positive: "bg-positive/15 text-positive",
negative: "bg-negative/15 text-negative",
warning: "bg-warning/15 text-warning",
};
export function Badge({
children,
tone = "neutral",
className,
}: {
children: ReactNode;
tone?: BadgeTone;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
TOENE[tone],
className,
)}
>
{children}
</span>
);
}
export interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "Löschen",
onConfirm,
onCancel,
loading,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
footer={
<>
<Button onClick={onCancel}>Abbrechen</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<div className="flex gap-3">
<AlertTriangle aria-hidden className="h-5 w-5 shrink-0 text-warning" />
<p className="text-sm text-muted">{description}</p>
</div>
</Modal>
);
}
+90
View File
@@ -0,0 +1,90 @@
/** Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. */
import {
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
forwardRef,
useId,
} from "react";
import { cn } from "@/lib/cn";
const EINGABE_KLASSEN =
"w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink " +
"placeholder:text-faint transition focus:border-accent disabled:opacity-60";
export interface FieldProps {
label: string;
hint?: string;
error?: string | null;
required?: boolean;
children: (id: string) => ReactNode;
className?: string;
}
export function Field({ label, hint, error, required, children, className }: FieldProps) {
const id = useId();
return (
<div className={cn("space-y-1.5", className)}>
<label htmlFor={id} className="block text-xs font-medium text-muted">
{label}
{required && <span className="ml-0.5 text-negative">*</span>}
</label>
{children(id)}
{error ? (
<p className="text-xs text-negative">{error}</p>
) : hint ? (
<p className="text-xs text-faint">{hint}</p>
) : null}
</div>
);
}
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...rest }, ref) {
return <input ref={ref} className={cn(EINGABE_KLASSEN, className)} {...rest} />;
},
);
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...rest }, ref) {
return (
<select ref={ref} className={cn(EINGABE_KLASSEN, "pr-8", className)} {...rest}>
{children}
</select>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...rest }, ref) {
return <textarea ref={ref} className={cn(EINGABE_KLASSEN, "resize-y", className)} {...rest} />;
},
);
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label: string;
hint?: string;
}
export function Checkbox({ label, hint, className, ...rest }: CheckboxProps) {
const id = useId();
return (
<div className={cn("flex gap-2.5", className)}>
<input
id={id}
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 rounded border-line bg-raised text-accent accent-[rgb(var(--color-accent))]"
{...rest}
/>
<div className="min-w-0">
<label htmlFor={id} className="block cursor-pointer text-sm text-ink">
{label}
</label>
{hint && <p className="text-xs text-faint">{hint}</p>}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
/** Modaler Dialog mit Fokusfalle und Schließen per Escape. */
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/cn";
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
footer?: ReactNode;
size?: "sm" | "md" | "lg";
}
const BREITEN = { sm: "max-w-md", md: "max-w-2xl", lg: "max-w-4xl" };
export function Modal({
open,
onClose,
title,
description,
children,
footer,
size = "md",
}: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const tastatur = useCallback(
(ereignis: KeyboardEvent) => {
if (ereignis.key === "Escape") {
onClose();
return;
}
if (ereignis.key !== "Tab" || !dialog.current) return;
// Fokus im Dialog halten.
const fokussierbar = dialog.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const erstes = fokussierbar[0];
const letztes = fokussierbar[fokussierbar.length - 1];
if (!erstes || !letztes) return;
if (ereignis.shiftKey && document.activeElement === erstes) {
ereignis.preventDefault();
letztes.focus();
} else if (!ereignis.shiftKey && document.activeElement === letztes) {
ereignis.preventDefault();
erstes.focus();
}
},
[onClose],
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", tastatur);
const vorher = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Erstes Eingabefeld bekommt den Fokus.
const ziel = dialog.current?.querySelector<HTMLElement>("input, select, textarea, button");
ziel?.focus();
return () => {
document.removeEventListener("keydown", tastatur);
document.body.style.overflow = vorher;
};
}, [open, tastatur]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<div
className="fixed inset-0 bg-black/60 animate-fade-in"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
"relative z-10 w-full animate-slide-up rounded-card border border-line bg-surface shadow-2xl",
BREITEN[size],
)}
>
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Dialog schließen">
<X aria-hidden className="h-4 w-4" />
</Button>
</header>
<div className="px-5 py-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</footer>
)}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Betragsfeld in deutscher Schreibweise.
*
* Nach außen fließt immer das API-Format ("13.99"), angezeigt wird "13,99".
*/
import { type InputHTMLAttributes, useEffect, useState } from "react";
import { Input } from "@/components/ui/Field";
import { parseAmountInput, toAmountInput } from "@/lib/format";
export interface MoneyInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type"> {
value: string;
onValueChange: (value: string) => void;
}
export function MoneyInput({ value, onValueChange, ...rest }: MoneyInputProps) {
const [text, setText] = useState(() => toAmountInput(value));
// Von außen gesetzte Werte übernehmen, solange der Nutzer nicht gerade tippt.
useEffect(() => {
const normalisiert = parseAmountInput(text);
if (normalisiert !== value) setText(toAmountInput(value));
// Absichtlich nur auf `value` reagieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<div className="relative">
<Input
inputMode="decimal"
placeholder="0,00"
value={text}
onChange={(ereignis) => {
const eingabe = ereignis.target.value;
setText(eingabe);
onValueChange(parseAmountInput(eingabe) ?? "");
}}
onBlur={() => setText(toAmountInput(parseAmountInput(text) ?? ""))}
className="pr-8 tabular"
{...rest}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sm text-faint">
</span>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/** Zeigt die Meldungen aus dem Toast-Store an. */
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
import { type ToastKind, useToastStore } from "@/store/toast";
const SYMBOLE = { success: CheckCircle2, error: XCircle, info: Info };
const RAHMEN: Record<ToastKind, string> = {
success: "border-positive/40 text-positive",
error: "border-negative/40 text-negative",
info: "border-line text-muted",
};
export function Toaster() {
const toasts = useToastStore((zustand) => zustand.toasts);
const dismiss = useToastStore((zustand) => zustand.dismiss);
if (toasts.length === 0) return null;
return (
<div
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
>
{toasts.map((eintrag) => {
const Symbol = SYMBOLE[eintrag.kind];
return (
<div
key={eintrag.id}
role={eintrag.kind === "error" ? "alert" : "status"}
className={cn(
"pointer-events-auto flex animate-slide-up items-start gap-3 rounded-card border bg-surface p-3 shadow-xl",
RAHMEN[eintrag.kind],
)}
>
<Symbol aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink">{eintrag.title}</p>
{eintrag.description && (
<p className="mt-0.5 break-words text-xs text-muted">{eintrag.description}</p>
)}
</div>
<button
type="button"
onClick={() => dismiss(eintrag.id)}
aria-label="Meldung schließen"
className="shrink-0 rounded p-0.5 text-faint transition hover:text-ink"
>
<X aria-hidden className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
}
+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.");
},
});
}
+73
View File
@@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Heller Modus */
:root {
--color-ground: 248 250 252;
--color-surface: 255 255 255;
--color-raised: 241 245 249;
--color-line: 226 232 240;
--color-ink: 15 23 42;
--color-muted: 71 85 105;
--color-faint: 148 163 184;
--color-accent: 22 163 74;
--color-accent-ink: 255 255 255;
--color-positive: 21 128 61;
--color-negative: 190 18 60;
--color-warning: 180 83 9;
}
/* Dunkler Modus die Vorgabe der Anwendung */
.dark {
--color-ground: 15 17 21;
--color-surface: 24 27 33;
--color-raised: 33 37 45;
--color-line: 51 57 68;
--color-ink: 226 232 240;
--color-muted: 148 163 184;
--color-faint: 100 116 139;
--color-accent: 34 197 94;
--color-accent-ink: 4 20 11;
--color-positive: 74 222 128;
--color-negative: 248 113 113;
--color-warning: 251 191 36;
}
html {
@apply h-full;
}
body {
@apply h-full bg-ground font-sans text-ink antialiased;
}
#root {
@apply h-full;
}
/* Sichtbarer Fokus überall die Anwendung ist vollständig per Tastatur bedienbar. */
:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-ground;
}
::-webkit-scrollbar {
@apply h-2 w-2;
}
::-webkit-scrollbar-thumb {
@apply rounded-full bg-line;
}
}
@layer components {
.card {
@apply rounded-card border border-line bg-surface;
}
/* Zahlen sollen untereinander bündig stehen. */
.tabular {
font-variant-numeric: tabular-nums;
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Schmaler Client um `fetch`.
*
* Cookies fahren immer mit, Fehler kommen als `ApiError` mit dem Fehlercode des
* Backends zurück. Läuft ein Zugriff in ein abgelaufenes Access-Token, wird
* einmalig die Sitzung erneuert und der Aufruf wiederholt.
*/
import type { ProblemDetail } from "@/types/api";
export const API_BASE = "/api";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly problem: ProblemDetail;
constructor(status: number, problem: ProblemDetail) {
super(problem.detail);
this.name = "ApiError";
this.status = status;
this.code = problem.code;
this.problem = problem;
}
/** Erste Feldmeldung einer Eingabeprüfung, falls vorhanden. */
get fieldMessage(): string | null {
const erster = this.problem.errors?.[0];
if (!erster) return null;
const feld = erster.loc.filter((teil) => teil !== "body").join(".");
return feld ? `${feld}: ${erster.msg}` : erster.msg;
}
}
/** Wird ausgelöst, wenn keine gültige Sitzung besteht. */
export function isUnauthorized(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
/** Der Passwortwechsel steht noch aus und blockiert alle Fachrouten. */
export function isPasswordChangeRequired(error: unknown): boolean {
return error instanceof ApiError && error.code === "password_change_required";
}
export type QueryValue = string | number | boolean | null | undefined;
export function buildUrl(path: string, params?: Record<string, QueryValue>): string {
const url = `${API_BASE}${path}`;
if (!params) return url;
const suchparameter = new URLSearchParams();
for (const [schluessel, wert] of Object.entries(params)) {
if (wert === null || wert === undefined || wert === "") continue;
suchparameter.set(schluessel, String(wert));
}
const abfrage = suchparameter.toString();
return abfrage ? `${url}?${abfrage}` : url;
}
interface RequestOptions {
method?: string;
body?: unknown;
params?: Record<string, QueryValue>;
/** Multipart-Inhalt; `body` bleibt dann leer. */
formData?: FormData;
signal?: AbortSignal;
}
async function parseProblem(response: Response): Promise<ProblemDetail> {
try {
const daten = (await response.json()) as Partial<ProblemDetail>;
return {
detail: daten.detail ?? "Unbekannter Fehler.",
code: daten.code ?? "error",
errors: daten.errors,
};
} catch {
return { detail: `Der Server antwortete mit Status ${response.status}.`, code: "error" };
}
}
async function raw(path: string, options: RequestOptions): Promise<Response> {
const kopfzeilen: Record<string, string> = { Accept: "application/json" };
let inhalt: BodyInit | undefined;
if (options.formData) {
// Den Content-Type setzt der Browser samt boundary selbst.
inhalt = options.formData;
} else if (options.body !== undefined) {
kopfzeilen["Content-Type"] = "application/json";
inhalt = JSON.stringify(options.body);
}
return fetch(buildUrl(path, options.params), {
method: options.method ?? "GET",
headers: kopfzeilen,
body: inhalt,
credentials: "same-origin",
signal: options.signal,
});
}
/** Endpunkte, bei denen ein 401 nicht durch eine Token-Erneuerung heilbar ist. */
const KEINE_ERNEUERUNG = ["/auth/login", "/auth/refresh", "/auth/logout"];
let laufendeErneuerung: Promise<boolean> | null = null;
async function erneuereSitzung(): Promise<boolean> {
// Mehrere gleichzeitig fehlschlagende Abfragen teilen sich einen Versuch.
laufendeErneuerung ??= (async () => {
try {
const antwort = await raw("/auth/refresh", { method: "POST" });
return antwort.ok;
} catch {
return false;
} finally {
// Erst im nächsten Mikrotask zurücksetzen, damit Wartende dasselbe Ergebnis sehen.
queueMicrotask(() => {
laufendeErneuerung = null;
});
}
})();
return laufendeErneuerung;
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
let antwort = await raw(path, options);
if (antwort.status === 401 && !KEINE_ERNEUERUNG.some((pfad) => path.startsWith(pfad))) {
if (await erneuereSitzung()) {
antwort = await raw(path, options);
}
}
if (!antwort.ok) {
throw new ApiError(antwort.status, await parseProblem(antwort));
}
if (antwort.status === 204 || antwort.headers.get("content-length") === "0") {
return undefined as T;
}
return (await antwort.json()) as T;
}
export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: "POST", body, params }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: "POST", formData }),
};
/** Bild-URL eines zwischengespeicherten Logos. */
export function logoUrl(assetId: number): string {
return `${API_BASE}/logos/${assetId}`;
}
+4
View File
@@ -0,0 +1,4 @@
/** Fügt Klassennamen zusammen und wirft Leeres weg. */
export function cn(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
+100
View File
@@ -0,0 +1,100 @@
/** Tests der Formatierung und Betragsumwandlung. */
import { describe, expect, it } from "vitest";
import {
addMonthsIso,
firstOfMonth,
formatDate,
formatMoney,
formatMonth,
formatSignedMoney,
parseAmountInput,
relativeDays,
toAmountInput,
toIsoDate,
toNumber,
} from "@/lib/format";
/**
* Intl setzt vor das Währungszeichen ein geschütztes Leerzeichen, dessen genaue
* Form von der ICU-Version abhängt. Für die Erwartungen wird es vereinheitlicht.
*/
function geld(wert: string): string {
return wert.replace(/\s/gu, " ");
}
describe("Geldbeträge", () => {
it("formatiert nach de-DE mit zwei Nachkommastellen", () => {
expect(geld(formatMoney("13.99"))).toBe("13,99 €");
expect(geld(formatMoney("1234.5"))).toBe("1.234,50 €");
expect(geld(formatMoney("0"))).toBe("0,00 €");
expect(geld(formatMoney(-42))).toBe("-42,00 €");
});
it("zeigt auf Wunsch auch ein Pluszeichen", () => {
expect(geld(formatSignedMoney("120"))).toBe("+120,00 €");
expect(geld(formatSignedMoney("-120"))).toBe("-120,00 €");
});
it("behandelt fehlende Werte als null", () => {
expect(toNumber(null)).toBe(0);
expect(toNumber(undefined)).toBe(0);
expect(toNumber("")).toBe(0);
expect(toNumber("keine zahl")).toBe(0);
});
it("liest deutsche und englische Schreibweise", () => {
expect(parseAmountInput("13,99")).toBe("13.99");
expect(parseAmountInput("13.99")).toBe("13.99");
expect(parseAmountInput("1.234,56")).toBe("1234.56");
expect(parseAmountInput(" 12 € ")).toBe("12.00");
expect(parseAmountInput("")).toBeNull();
expect(parseAmountInput("abc")).toBeNull();
});
it("stellt Beträge fürs Eingabefeld dar", () => {
expect(toAmountInput("13.99")).toBe("13,99");
expect(toAmountInput("5")).toBe("5,00");
expect(toAmountInput(null)).toBe("");
});
it("überlebt den Weg Eingabe → API → Eingabe", () => {
expect(toAmountInput(parseAmountInput("1.234,56")!)).toBe("1234,56");
expect(toAmountInput(parseAmountInput("13,99")!)).toBe("13,99");
});
});
describe("Datumsangaben", () => {
it("formatiert im deutschen Format", () => {
expect(formatDate("2026-03-01")).toBe("01.03.2026");
expect(formatDate(null)).toBe("");
expect(formatMonth("2026-03-15")).toBe("März 2026");
});
it("liest reine Datumsangaben als lokalen Tag", () => {
// Ohne diese Behandlung könnte je nach Zeitzone der Vortag erscheinen.
expect(formatDate("2026-01-01")).toBe("01.01.2026");
});
it("rechnet mit Monaten und Monatsgrenzen", () => {
expect(firstOfMonth("2026-03-17")).toBe("2026-03-01");
expect(addMonthsIso("2026-01-31", 1)).toBe("2026-02-28");
expect(addMonthsIso("2026-03-15", -1)).toBe("2026-02-15");
expect(addMonthsIso("2026-01-15", 12)).toBe("2027-01-15");
});
it("wandelt ein Datum verlustfrei in ISO", () => {
expect(toIsoDate(new Date(2026, 5, 4))).toBe("2026-06-04");
});
it("beschreibt Abstände in Tagen", () => {
const heute = new Date(2026, 2, 10);
expect(relativeDays("2026-03-10", heute)).toBe("heute");
expect(relativeDays("2026-03-11", heute)).toBe("morgen");
expect(relativeDays("2026-03-09", heute)).toBe("gestern");
expect(relativeDays("2026-03-15", heute)).toBe("in 5 Tagen");
expect(relativeDays("2026-03-01", heute)).toBe("vor 9 Tagen");
});
});
+144
View File
@@ -0,0 +1,144 @@
/** Formatierung und Rechnen mit Geldbeträgen, durchgängig in de-DE. */
const WAEHRUNG = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const WAEHRUNG_MIT_VORZEICHEN = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
signDisplay: "exceptZero",
});
const ZAHL = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const DATUM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
});
const MONAT = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" });
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", { weekday: "short" });
/** Wandelt einen Betrag der API (String) in eine Zahl. */
export function toNumber(amount: string | number | null | undefined): number {
if (amount === null || amount === undefined || amount === "") return 0;
const zahl = typeof amount === "number" ? amount : Number.parseFloat(amount);
return Number.isFinite(zahl) ? zahl : 0;
}
export function formatMoney(amount: string | number | null | undefined): string {
return WAEHRUNG.format(toNumber(amount));
}
/** Wie `formatMoney`, stellt aber auch bei positiven Werten ein Vorzeichen voran. */
export function formatSignedMoney(amount: string | number | null | undefined): string {
return WAEHRUNG_MIT_VORZEICHEN.format(toNumber(amount));
}
export function formatNumber(value: number): string {
return ZAHL.format(value);
}
/**
* Bringt eine Nutzereingabe auf das Format der API.
* Akzeptiert "12,50", "12.50" und "1.234,56".
*/
export function parseAmountInput(input: string): string | null {
const bereinigt = input.trim().replace(/\s|€/g, "");
if (!bereinigt) return null;
// Deutsche Schreibweise: Punkt trennt Tausender, Komma die Nachkommastellen.
const normalisiert = bereinigt.includes(",")
? bereinigt.replace(/\./g, "").replace(",", ".")
: bereinigt;
const zahl = Number.parseFloat(normalisiert);
if (!Number.isFinite(zahl)) return null;
return zahl.toFixed(2);
}
/** Zeigt einen API-Betrag in einem Eingabefeld an ("13.99" -> "13,99"). */
export function toAmountInput(amount: string | number | null | undefined): string {
if (amount === null || amount === undefined || amount === "") return "";
return toNumber(amount).toFixed(2).replace(".", ",");
}
function toDate(value: string | Date): Date {
if (value instanceof Date) return value;
// Reine Datumsangaben ohne Zeitzone werden als lokaler Tag gelesen.
const [jahr, monat, tag] = value.split("-").map(Number);
if (jahr && monat && tag) return new Date(jahr, monat - 1, tag);
return new Date(value);
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM.format(toDate(value));
}
export function formatDateLong(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM_LANG.format(toDate(value));
}
export function formatMonth(value: string | Date | null | undefined): string {
if (!value) return "";
return MONAT.format(toDate(value));
}
export function formatWeekday(value: string | Date): string {
return WOCHENTAG.format(toDate(value));
}
/** ISO-Datum (YYYY-MM-DD) eines Datums in lokaler Zeit. */
export function toIsoDate(date: Date): string {
const monat = String(date.getMonth() + 1).padStart(2, "0");
const tag = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${monat}-${tag}`;
}
export function todayIso(): string {
return toIsoDate(new Date());
}
export function firstOfMonth(value: string | Date = new Date()): string {
const datum = toDate(value);
return toIsoDate(new Date(datum.getFullYear(), datum.getMonth(), 1));
}
export function addMonthsIso(value: string, months: number): string {
const datum = toDate(value);
const ziel = new Date(datum.getFullYear(), datum.getMonth() + months, 1);
const letzterTag = new Date(ziel.getFullYear(), ziel.getMonth() + 1, 0).getDate();
ziel.setDate(Math.min(datum.getDate(), letzterTag));
return toIsoDate(ziel);
}
/** "in 5 Tagen", "heute", "vor 2 Tagen" für Fälligkeitshinweise. */
export function relativeDays(value: string, reference: Date = new Date()): string {
const ziel = toDate(value);
const heute = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const tage = Math.round((ziel.getTime() - heute.getTime()) / 86_400_000);
if (tage === 0) return "heute";
if (tage === 1) return "morgen";
if (tage === -1) return "gestern";
if (tage > 0) return `in ${tage} Tagen`;
return `vor ${Math.abs(tage)} Tagen`;
}
+10
View File
@@ -0,0 +1,10 @@
/** Ein bis zwei Initialen eines Namens Ersatz, wenn kein Logo vorliegt. */
export function initialsOf(name: string): string {
const woerter = name
.split(/[\s\-_/]+/)
.map((wort) => wort.replace(/[^0-9A-Za-zÄÖÜäöüß]/g, ""))
.filter(Boolean);
if (woerter.length === 0) return "?";
if (woerter.length === 1) return woerter[0]!.slice(0, 2).toUpperCase();
return (woerter[0]![0]! + woerter[1]![0]!).toUpperCase();
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Gemeinsamer Query-Client.
*
* Fehlgeschlagene Abfragen und Mutationen melden sich zentral über einen Toast,
* damit nicht jede Seite ihre eigene Fehlerbehandlung braucht. Fehlende
* Anmeldung ist davon ausgenommen darauf reagiert die Anmeldelogik.
*/
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { ApiError, isPasswordChangeRequired, isUnauthorized } from "@/lib/api";
import { toast } from "@/store/toast";
function beschreibung(error: unknown): { title: string; description?: string } {
if (error instanceof ApiError) {
const feld = error.fieldMessage;
return { title: error.message, description: feld ?? undefined };
}
if (error instanceof Error) {
return { title: "Der Server ist nicht erreichbar.", description: error.message };
}
return { title: "Unbekannter Fehler." };
}
function melden(error: unknown): void {
// Diese beiden Fälle behandelt die Anwendung durch Weiterleitung, nicht per Toast.
if (isUnauthorized(error) || isPasswordChangeRequired(error)) return;
const { title, description } = beschreibung(error);
toast.error(title, description);
}
export function createQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({ onError: melden }),
mutationCache: new MutationCache({ onError: melden }),
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (anzahl, fehler) => {
// Fachliche Fehler wiederholen sich nicht von selbst.
if (fehler instanceof ApiError && fehler.status < 500) return false;
return anzahl < 2;
},
},
mutations: { retry: false },
},
});
}
export const queryClient = createQueryClient();
/** Schlüssel aller Abfragen an einem Ort so bleibt das Invalidieren überschaubar. */
export const keys = {
me: ["me"] as const,
accounts: ["accounts"] as const,
accountBalance: (id: number, asOf?: string) => ["accounts", id, "balance", asOf] as const,
categories: ["categories"] as const,
categoriesFlat: ["categories", "flat"] as const,
merchants: (query?: string) => ["merchants", query ?? ""] as const,
merchant: (id: number) => ["merchants", id] as const,
recurrences: (filter?: Record<string, unknown>) => ["recurrences", filter ?? {}] as const,
recurrence: (id: number) => ["recurrences", id] as const,
recurrencePreview: (id: number, from: string, to: string) =>
["recurrences", id, "preview", from, to] as const,
occurrences: (filter?: Record<string, unknown>) => ["occurrences", filter ?? {}] as const,
transactions: (filter?: Record<string, unknown>) => ["transactions", filter ?? {}] as const,
budgets: (month?: string) => ["budgets", month ?? ""] as const,
goals: ["savings-goals"] as const,
monthReport: (month: string) => ["reports", "month", month] as const,
};
+29
View File
@@ -0,0 +1,29 @@
/** Tests der Hochrechnung für Listenanzeigen. */
import { describe, expect, it } from "vitest";
import { annualCostOf, monthlyCostOf, occurrencesPerYear } from "@/lib/recurrenceMath";
import { LAST_DAY_RULE } from "@/lib/rrule";
describe("Termine pro Jahr", () => {
it("rechnet die gängigen Rhythmen", () => {
expect(occurrencesPerYear("FREQ=MONTHLY;BYMONTHDAY=1")).toBe(12);
expect(occurrencesPerYear("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1")).toBe(4);
expect(occurrencesPerYear("FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=1")).toBe(2);
expect(occurrencesPerYear("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15")).toBe(1);
expect(occurrencesPerYear("FREQ=WEEKLY;BYDAY=MO")).toBe(52);
});
it("zählt BYSETPOS als genau einen Termin je Monat", () => {
// Ohne diese Behandlung ergäbe die Regel vier Termine im Monat.
expect(occurrencesPerYear(LAST_DAY_RULE)).toBe(12);
});
});
describe("Kostenschätzung", () => {
it("rechnet Jahres- und Monatsbetrag", () => {
expect(annualCostOf("FREQ=MONTHLY;BYMONTHDAY=1", 13.99)).toBeCloseTo(167.88, 2);
expect(monthlyCostOf("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", 612)).toBeCloseTo(51, 2);
expect(monthlyCostOf("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1", 150)).toBeCloseTo(50, 2);
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Grobe Hochrechnung für Anzeigen im Frontend.
*
* Maßgeblich bleibt die Berechnung im Backend `RecurrenceDetail.annual_burden`
* ist der genaue Wert. Für Listen und Kacheln, in denen nur die Regel vorliegt,
* genügt diese Schätzung aus Frequenz und Intervall.
*/
import { parseRRule } from "@/lib/rrule";
/** Wie oft eine Regel im Jahr ungefähr auslöst. */
export function occurrencesPerYear(rrule: string): number {
const teile = parseRRule(rrule);
const intervall = Math.max(1, Number(teile.INTERVAL ?? "1") || 1);
switch (teile.FREQ) {
case "DAILY":
return 365 / intervall;
case "WEEKLY": {
const tage = (teile.BYDAY ?? "").split(",").filter(Boolean).length || 1;
return (52 / intervall) * tage;
}
case "MONTHLY": {
const tage = (teile.BYMONTHDAY ?? "").split(",").filter(Boolean).length;
// BYSETPOS wählt aus mehreren Monatstagen genau einen aus.
const proMonat = teile.BYSETPOS ? 1 : Math.max(1, tage);
return (12 / intervall) * proMonat;
}
case "YEARLY": {
const monate = (teile.BYMONTH ?? "").split(",").filter(Boolean).length || 1;
return monate / intervall;
}
default:
return 12;
}
}
/** Geschätzte Jahreskosten eines Postens. */
export function annualCostOf(rrule: string, amount: number): number {
return occurrencesPerYear(rrule) * amount;
}
/** Geschätzter Monatsbetrag eines Postens. */
export function monthlyCostOf(rrule: string, amount: number): number {
return annualCostOf(rrule, amount) / 12;
}
+127
View File
@@ -0,0 +1,127 @@
/** Tests des RRULE-Aufbaus und der deutschen Beschreibung. */
import { describe, expect, it } from "vitest";
import {
DEFAULT_PRESET_STATE,
LAST_DAY_RULE,
buildRRule,
describeRRule,
parseRRule,
presetFromRule,
ruleFromPreset,
} from "@/lib/rrule";
describe("Regeln zerlegen und zusammensetzen", () => {
it("liest die Bestandteile", () => {
expect(parseRRule("FREQ=MONTHLY;BYMONTHDAY=1")).toEqual({
FREQ: "MONTHLY",
BYMONTHDAY: "1",
});
});
it("schreibt FREQ nach vorne", () => {
expect(buildRRule({ BYMONTHDAY: "15", FREQ: "MONTHLY", INTERVAL: "3" })).toBe(
"FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
);
});
});
describe("Vorlagen des Editors", () => {
it("erzeugt die erwarteten Regeln", () => {
expect(ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "monthly", monthDay: 1 })).toBe(
"FREQ=MONTHLY;BYMONTHDAY=1",
);
expect(
ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "quarterly", monthDay: 15 }),
).toBe("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15");
expect(
ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "everyNMonths", interval: 2, monthDay: 10 }),
).toBe("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10");
expect(
ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "yearly", month: 1, monthDay: 15 }),
).toBe("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15");
expect(
ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "weekly", weekdays: ["MO", "TH"], interval: 1 }),
).toBe("FREQ=WEEKLY;BYDAY=MO,TH");
expect(ruleFromPreset({ ...DEFAULT_PRESET_STATE, preset: "lastDayOfMonth" })).toBe(
LAST_DAY_RULE,
);
});
it("erkennt die Vorlage zu einer bestehenden Regel wieder", () => {
expect(presetFromRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-01-01").preset).toBe("monthly");
expect(presetFromRule("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15", "2026-01-15").preset).toBe(
"quarterly",
);
expect(presetFromRule("FREQ=MONTHLY;INTERVAL=2", "2026-01-10").preset).toBe("everyNMonths");
expect(presetFromRule("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15", "2026-01-15").preset).toBe(
"yearly",
);
expect(presetFromRule("FREQ=WEEKLY;BYDAY=MO", "2026-01-05").preset).toBe("weekly");
expect(presetFromRule(LAST_DAY_RULE, "2026-01-31").preset).toBe("lastDayOfMonth");
});
it("schickt unbekannte Regeln in den Expertenmodus", () => {
const zustand = presetFromRule("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36", "2026-01-01");
expect(zustand.preset).toBe("custom");
expect(zustand.custom).toBe("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36");
});
it("überlebt den Weg Regel → Vorlage → Regel", () => {
for (const regel of [
"FREQ=MONTHLY;BYMONTHDAY=1",
"FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10",
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4",
"FREQ=WEEKLY;BYDAY=MO,TH",
LAST_DAY_RULE,
]) {
expect(ruleFromPreset(presetFromRule(regel, "2026-01-01"))).toBe(regel);
}
});
});
describe("Deutsche Beschreibung", () => {
it("beschreibt monatliche Regeln", () => {
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-03-01")).toBe(
"Jeden 1. des Monats, ab 01.03.2026",
);
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=15")).toBe("Jeden 15. des Monats");
});
it("beschreibt Abstände größer als einen Monat", () => {
expect(describeRRule("FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15")).toBe(
"Quartalsweise am 15.",
);
expect(describeRRule("FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=10")).toBe(
"Alle 2 Monate am 10.",
);
});
it("beschreibt den Monatsletzten", () => {
expect(describeRRule(LAST_DAY_RULE)).toBe("Jeden Monat am letzten Tag");
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=-1")).toBe("Jeden Monat am letzten Tag");
});
it("beschreibt jährliche und wöchentliche Regeln", () => {
expect(describeRRule("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15")).toBe("Jährlich am 15. Januar");
expect(describeRRule("FREQ=WEEKLY;BYDAY=MO,TH")).toBe("Jede Woche montags und donnerstags");
expect(describeRRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=FR")).toBe("Alle 2 Wochen freitags");
});
it("nennt Serienende und Anzahl", () => {
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1", "2026-01-01", "2026-12-01")).toBe(
"Jeden 1. des Monats, ab 01.01.2026, bis 01.12.2026",
);
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1;COUNT=36")).toBe(
"Jeden 1. des Monats, 36 Mal",
);
expect(describeRRule("FREQ=MONTHLY;BYMONTHDAY=1;UNTIL=20261231")).toBe(
"Jeden 1. des Monats, bis 31.12.2026",
);
});
it("bleibt bei leerer Regel verständlich", () => {
expect(describeRRule("")).toBe("Keine Wiederholungsregel");
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* Umgang mit RFC-5545-Wiederholungsregeln im Frontend.
*
* Die Berechnung der Termine bleibt Sache des Backends hier geht es nur um
* das Zusammenbauen der Regel im geführten Editor und um eine verständliche
* deutsche Beschreibung.
*/
import { formatDate } from "@/lib/format";
export type RRuleParts = Record<string, string>;
export const WEEKDAYS = [
{ code: "MO", short: "Mo", label: "Montag", adverb: "montags" },
{ code: "TU", short: "Di", label: "Dienstag", adverb: "dienstags" },
{ code: "WE", short: "Mi", label: "Mittwoch", adverb: "mittwochs" },
{ code: "TH", short: "Do", label: "Donnerstag", adverb: "donnerstags" },
{ code: "FR", short: "Fr", label: "Freitag", adverb: "freitags" },
{ code: "SA", short: "Sa", label: "Samstag", adverb: "samstags" },
{ code: "SU", short: "So", label: "Sonntag", adverb: "sonntags" },
] as const;
export const MONTHS = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
] as const;
/** Muster für den Monatsletzten über BYSETPOS so schreibt es der Editor. */
export const LAST_DAY_RULE = "FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1";
export function parseRRule(rrule: string): RRuleParts {
const teile: RRuleParts = {};
for (const abschnitt of rrule.split(";")) {
const [schluessel, wert] = abschnitt.split("=");
if (schluessel && wert) teile[schluessel.trim().toUpperCase()] = wert.trim();
}
return teile;
}
export function buildRRule(parts: RRuleParts): string {
// FREQ steht laut RFC an erster Stelle, der Rest folgt in fester Reihenfolge.
const reihenfolge = [
"FREQ",
"INTERVAL",
"BYMONTH",
"BYMONTHDAY",
"BYDAY",
"BYSETPOS",
"COUNT",
"UNTIL",
];
return reihenfolge
.filter((schluessel) => parts[schluessel])
.map((schluessel) => `${schluessel}=${parts[schluessel]}`)
.join(";");
}
/* --- Vorlagen des geführten Editors --------------------------------------- */
export type PresetId =
| "monthly"
| "everyNMonths"
| "quarterly"
| "yearly"
| "weekly"
| "lastDayOfMonth"
| "custom";
export interface PresetOption {
id: PresetId;
label: string;
hint: string;
}
export const PRESETS: PresetOption[] = [
{ id: "monthly", label: "Monatlich", hint: "An einem festen Tag jedes Monats" },
{ id: "everyNMonths", label: "Alle N Monate", hint: "Zweimonatlich, halbjährlich, …" },
{ id: "quarterly", label: "Quartalsweise", hint: "Alle drei Monate" },
{ id: "yearly", label: "Jährlich", hint: "Einmal im Jahr zu festem Datum" },
{ id: "weekly", label: "Wöchentlich", hint: "An festen Wochentagen" },
{ id: "lastDayOfMonth", label: "Monatsletzter", hint: "Am letzten Tag des Monats" },
{ id: "custom", label: "Expertenmodus", hint: "RRULE von Hand eingeben" },
];
export interface PresetState {
preset: PresetId;
monthDay: number;
interval: number;
month: number;
weekdays: string[];
custom: string;
}
export const DEFAULT_PRESET_STATE: PresetState = {
preset: "monthly",
monthDay: 1,
interval: 2,
month: 1,
weekdays: ["MO"],
custom: "FREQ=MONTHLY;BYMONTHDAY=1",
};
export function ruleFromPreset(state: PresetState): string {
switch (state.preset) {
case "monthly":
return buildRRule({ FREQ: "MONTHLY", BYMONTHDAY: String(state.monthDay) });
case "everyNMonths":
return buildRRule({
FREQ: "MONTHLY",
INTERVAL: String(Math.max(1, state.interval)),
BYMONTHDAY: String(state.monthDay),
});
case "quarterly":
return buildRRule({ FREQ: "MONTHLY", INTERVAL: "3", BYMONTHDAY: String(state.monthDay) });
case "yearly":
return buildRRule({
FREQ: "YEARLY",
BYMONTH: String(state.month),
BYMONTHDAY: String(state.monthDay),
});
case "weekly":
return buildRRule({
FREQ: "WEEKLY",
...(state.interval > 1 ? { INTERVAL: String(state.interval) } : {}),
BYDAY: (state.weekdays.length ? state.weekdays : ["MO"]).join(","),
});
case "lastDayOfMonth":
return LAST_DAY_RULE;
case "custom":
return state.custom.trim();
}
}
/** Erkennt die passende Vorlage zu einer bestehenden Regel für das Bearbeiten. */
export function presetFromRule(rrule: string, dtstart: string): PresetState {
const teile = parseRRule(rrule);
const startTag = Number(dtstart.slice(8, 10)) || 1;
const basis: PresetState = { ...DEFAULT_PRESET_STATE, monthDay: startTag, custom: rrule };
const normalisiert = buildRRule(teile);
if (normalisiert === LAST_DAY_RULE || teile.BYMONTHDAY === "-1") {
return { ...basis, preset: "lastDayOfMonth" };
}
const intervall = Number(teile.INTERVAL ?? "1") || 1;
const monatstag = Number(teile.BYMONTHDAY ?? startTag) || startTag;
// Alles, was über die Vorlagen hinausgeht, gehört in den Expertenmodus.
const zusatz = Object.keys(teile).filter(
(schluessel) =>
!["FREQ", "INTERVAL", "BYMONTHDAY", "BYDAY", "BYMONTH"].includes(schluessel),
);
if (zusatz.length > 0) return { ...basis, preset: "custom" };
if (teile.FREQ === "MONTHLY" && !teile.BYDAY) {
if (intervall === 1) return { ...basis, preset: "monthly", monthDay: monatstag };
if (intervall === 3) return { ...basis, preset: "quarterly", monthDay: monatstag };
return { ...basis, preset: "everyNMonths", interval: intervall, monthDay: monatstag };
}
if (teile.FREQ === "YEARLY" && teile.BYMONTH && !teile.BYDAY) {
return {
...basis,
preset: "yearly",
month: Number(teile.BYMONTH) || 1,
monthDay: monatstag,
};
}
if (teile.FREQ === "WEEKLY") {
return {
...basis,
preset: "weekly",
interval: intervall,
weekdays: (teile.BYDAY ?? "MO").split(","),
};
}
return { ...basis, preset: "custom" };
}
/* --- Klartext ------------------------------------------------------------- */
function joinGerman(items: string[]): string {
if (items.length === 0) return "";
if (items.length === 1) return items[0]!;
return `${items.slice(0, -1).join(", ")} und ${items.at(-1)}`;
}
function parseUntil(value: string): string | null {
// UNTIL kommt als 20261231 oder 20261231T000000Z.
const treffer = /^(\d{4})(\d{2})(\d{2})/.exec(value);
if (!treffer) return null;
return `${treffer[1]}-${treffer[2]}-${treffer[3]}`;
}
/**
* Deutsche Beschreibung einer Regel, etwa
* „Jeden 1. des Monats, ab 01.03.2026“.
*/
export function describeRRule(rrule: string, dtstart?: string, until?: string | null): string {
const regel = rrule.trim();
if (!regel) return "Keine Wiederholungsregel";
const teile = parseRRule(regel);
const intervall = Number(teile.INTERVAL ?? "1") || 1;
const saetze: string[] = [];
const monatstage = (teile.BYMONTHDAY ?? "").split(",").filter(Boolean);
const istMonatsletzter =
teile.BYSETPOS === "-1" || monatstage.includes("-1");
switch (teile.FREQ) {
case "DAILY":
saetze.push(intervall === 1 ? "Jeden Tag" : `Alle ${intervall} Tage`);
break;
case "WEEKLY": {
const tage = (teile.BYDAY ?? "")
.split(",")
.filter(Boolean)
.map((code) => WEEKDAYS.find((tag) => tag.code === code)?.adverb ?? code);
const rhythmus = intervall === 1 ? "Jede Woche" : `Alle ${intervall} Wochen`;
saetze.push(tage.length ? `${rhythmus} ${joinGerman(tage)}` : rhythmus);
break;
}
case "MONTHLY": {
const rhythmus =
intervall === 1
? "Jeden Monat"
: intervall === 3
? "Quartalsweise"
: `Alle ${intervall} Monate`;
if (istMonatsletzter) {
saetze.push(`${rhythmus} am letzten Tag`);
} else if (teile.BYDAY) {
const tage = teile.BYDAY.split(",")
.map((code) => WEEKDAYS.find((tag) => tag.code === code.slice(-2))?.adverb ?? code)
.filter(Boolean);
saetze.push(`${rhythmus} ${joinGerman(tage)}`);
} else if (monatstage.length) {
const tage = monatstage.map((tag) => `${tag}.`);
saetze.push(
intervall === 1
? `Jeden ${joinGerman(tage)} des Monats`
: `${rhythmus} am ${joinGerman(tage)}`,
);
} else {
saetze.push(rhythmus);
}
break;
}
case "YEARLY": {
const monate = (teile.BYMONTH ?? "")
.split(",")
.filter(Boolean)
.map((nummer) => MONTHS[Number(nummer) - 1] ?? nummer);
const tag = monatstage[0];
const rhythmus = intervall === 1 ? "Jährlich" : `Alle ${intervall} Jahre`;
if (monate.length && tag) {
saetze.push(`${rhythmus} am ${tag}. ${joinGerman(monate)}`);
} else if (monate.length) {
saetze.push(`${rhythmus} im ${joinGerman(monate)}`);
} else {
saetze.push(rhythmus);
}
break;
}
default:
return regel;
}
if (dtstart) saetze.push(`ab ${formatDate(dtstart)}`);
const ende = until ?? (teile.UNTIL ? parseUntil(teile.UNTIL) : null);
if (ende) saetze.push(`bis ${formatDate(ende)}`);
if (teile.COUNT) saetze.push(`${teile.COUNT} Mal`);
return saetze.join(", ");
}
+24
View File
@@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/App";
import { Toaster } from "@/components/ui/Toaster";
import "@/index.css";
import { queryClient } from "@/lib/queryClient";
const wurzel = document.getElementById("root");
if (!wurzel) throw new Error("Das Element #root fehlt im Dokument.");
createRoot(wurzel).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
<Toaster />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
+98
View File
@@ -0,0 +1,98 @@
/** Erzwungener Passwortwechsel nach dem ersten Anmelden. */
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Field, Input } from "@/components/ui/Field";
import { useChangePassword } from "@/hooks/useAuth";
const MINDESTLAENGE = 10;
export function ChangePasswordPage() {
const [aktuell, setAktuell] = useState("");
const [neu, setNeu] = useState("");
const [wiederholung, setWiederholung] = useState("");
const wechseln = useChangePassword();
const stimmtUeberein = neu === wiederholung;
const langGenug = neu.length >= MINDESTLAENGE;
const absendbar = Boolean(aktuell) && langGenug && stimmtUeberein;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
wechseln.mutate({ current_password: aktuell, new_password: neu });
}
return (
<div className="flex h-full items-center justify-center p-4">
<form onSubmit={absenden} className="card w-full max-w-sm space-y-4 p-6">
<div>
<h1 className="text-base font-semibold text-ink">Passwort ändern</h1>
<p className="mt-0.5 text-xs text-muted">
Das Startpasswort muss vor der ersten Nutzung ersetzt werden.
</p>
</div>
<Field label="Aktuelles Passwort" required>
{(id) => (
<Input
id={id}
type="password"
autoComplete="current-password"
autoFocus
required
value={aktuell}
onChange={(ereignis) => setAktuell(ereignis.target.value)}
/>
)}
</Field>
<Field
label="Neues Passwort"
required
hint={`Mindestens ${MINDESTLAENGE} Zeichen.`}
error={neu && !langGenug ? `Mindestens ${MINDESTLAENGE} Zeichen nötig.` : null}
>
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
required
value={neu}
onChange={(ereignis) => setNeu(ereignis.target.value)}
/>
)}
</Field>
<Field
label="Neues Passwort wiederholen"
required
error={wiederholung && !stimmtUeberein ? "Die Eingaben stimmen nicht überein." : null}
>
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
required
value={wiederholung}
onChange={(ereignis) => setWiederholung(ereignis.target.value)}
/>
)}
</Field>
<Button
type="submit"
variant="primary"
className="w-full"
loading={wechseln.isPending}
disabled={!absendbar}
>
Passwort ändern
</Button>
</form>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { type FormEvent, useState } from "react";
import { Field, Input } from "@/components/ui/Field";
import { Button } from "@/components/ui/Button";
import { loginErrorMessage, useLogin } from "@/hooks/useAuth";
export function LoginPage() {
const [benutzername, setBenutzername] = useState("");
const [passwort, setPasswort] = useState("");
const anmelden = useLogin();
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
anmelden.mutate({ username: benutzername, password: passwort });
}
return (
<div className="flex h-full items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="mb-6 flex items-center justify-center gap-2">
<span
aria-hidden
className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent text-base font-bold text-accent-ink"
>
m
</span>
<span className="text-xl font-semibold tracking-tight text-ink">moneyfy</span>
</div>
<form onSubmit={absenden} className="card space-y-4 p-6">
<div>
<h1 className="text-base font-semibold text-ink">Anmelden</h1>
<p className="mt-0.5 text-xs text-muted">
Zugang zur Planung deiner monatlichen Kosten und Einkünfte.
</p>
</div>
<Field label="Benutzername" required>
{(id) => (
<Input
id={id}
autoComplete="username"
autoFocus
required
value={benutzername}
onChange={(ereignis) => setBenutzername(ereignis.target.value)}
/>
)}
</Field>
<Field label="Passwort" required>
{(id) => (
<Input
id={id}
type="password"
autoComplete="current-password"
required
value={passwort}
onChange={(ereignis) => setPasswort(ereignis.target.value)}
/>
)}
</Field>
{anmelden.isError && (
<p role="alert" className="rounded-lg bg-negative/10 px-3 py-2 text-xs text-negative">
{loginErrorMessage(anmelden.error)}
</p>
)}
<Button
type="submit"
variant="primary"
className="w-full"
loading={anmelden.isPending}
disabled={!benutzername || !passwort}
>
Anmelden
</Button>
</form>
</div>
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
import { type FormEvent, useMemo, useState } from "react";
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { MerchantLogo } from "@/components/MerchantLogo";
import { MerchantLogoDialog } from "@/components/MerchantLogoDialog";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Field, Input } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import {
useDeleteMerchant,
useMerchants,
useRecurrences,
useSaveMerchant,
} from "@/hooks/useEntities";
import { formatMoney, toNumber } from "@/lib/format";
import { annualCostOf } from "@/lib/recurrenceMath";
import { useThemeStore } from "@/store/theme";
import type { Merchant } from "@/types/api";
export function MerchantsPage() {
const [suche, setSuche] = useState("");
const [bearbeiten, setBearbeiten] = useState<Merchant | null | undefined>(undefined);
const [logoFuer, setLogoFuer] = useState<Merchant | null>(null);
const [loeschen, setLoeschen] = useState<Merchant | null>(null);
const { data, isLoading } = useMerchants(suche || undefined);
const { data: posten = [] } = useRecurrences({ active: true });
const entfernen = useDeleteMerchant();
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
const kennzahlen = useMemo(() => {
const werte = new Map<number, { jahr: number; anzahl: number }>();
for (const eintrag of posten) {
if (eintrag.merchant_id === null) continue;
const bisher = werte.get(eintrag.merchant_id) ?? { jahr: 0, anzahl: 0 };
const betrag = annualCostOf(eintrag.rrule, toNumber(eintrag.amount));
werte.set(eintrag.merchant_id, {
jahr: bisher.jahr + (eintrag.kind === "expense" ? betrag : 0),
anzahl: bisher.anzahl + 1,
});
}
return werte;
}, [posten]);
const firmen = data?.items ?? [];
return (
<>
<PageHeader
title="Firmen"
description="Zahlungsempfänger mit Logo und Markenfarbe."
actions={
<>
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Firma suchen"
aria-label="Firma suchen"
className="h-10 w-52 pl-8"
/>
</div>
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Firma
</Button>
</>
}
/>
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<Skeleton key={index} className="h-32" />
))}
</div>
) : firmen.length === 0 ? (
<EmptyState
icon={Building2}
title={suche ? "Keine Treffer" : "Noch keine Firmen"}
description={
suche
? "Für diesen Suchbegriff gibt es keine Firma."
: "Lege Zahlungsempfänger an das Logo sucht moneyfy selbst."
}
action={
!suche && (
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erste Firma anlegen
</Button>
)
}
/>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{firmen.map((firma) => (
<MerchantCard
key={firma.id}
merchant={firma}
metrics={kennzahlen.get(firma.id)}
onEdit={() => setBearbeiten(firma)}
onLogo={() => setLogoFuer(firma)}
onDelete={() => setLoeschen(firma)}
/>
))}
</ul>
)}
<MerchantFormDialog
merchant={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<MerchantLogoDialog
merchant={logoFuer}
open={logoFuer !== null}
onClose={() => setLogoFuer(null)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Firma löschen"
description={`${loeschen?.name}“ wird gelöscht. Buchungen und Posten bleiben erhalten, verlieren aber die Zuordnung.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</>
);
}
function MerchantCard({
merchant,
metrics,
onEdit,
onLogo,
onDelete,
}: {
merchant: Merchant;
metrics?: { jahr: number; anzahl: number };
onEdit: () => void;
onLogo: () => void;
onDelete: () => void;
}) {
const theme = useThemeStore((zustand) => zustand.theme);
const akzent =
(theme === "dark" ? merchant.brand_color_dark : merchant.brand_color) ?? undefined;
return (
<li
className="card group relative overflow-hidden p-4"
// Die Markenfarbe steht als Custom Property für Akzente bereit.
style={akzent ? ({ "--brand": akzent } as React.CSSProperties) : undefined}
>
<span
aria-hidden
className="absolute inset-x-0 top-0 h-0.5"
style={{ backgroundColor: akzent ?? "transparent" }}
/>
<div className="flex items-start gap-3">
<MerchantLogo merchant={merchant} size={44} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-ink" title={merchant.name}>
{merchant.name}
</h3>
{merchant.domain && (
<p className="truncate text-xs text-faint" title={merchant.domain}>
{merchant.domain}
</p>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{metrics && metrics.anzahl > 0 ? (
<>
<Badge tone="accent">
{formatMoney(metrics.jahr)} <span className="opacity-70">p. a.</span>
</Badge>
<Badge>
{metrics.anzahl} {metrics.anzahl === 1 ? "Vertrag" : "Verträge"}
</Badge>
</>
) : (
<Badge>Keine aktiven Verträge</Badge>
)}
{merchant.logo_status === "manual" && <Badge tone="neutral">Logo manuell</Badge>}
</div>
<div className="mt-3 flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button size="sm" variant="ghost" onClick={onLogo} aria-label={`Logo von ${merchant.name} ändern`}>
<ImageIcon aria-hidden className="h-3.5 w-3.5" />
Logo
</Button>
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${merchant.name} bearbeiten`}>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${merchant.name} löschen`}>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
);
}
function MerchantFormDialog({
merchant,
open,
onClose,
}: {
merchant: Merchant | null | undefined;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveMerchant();
const [name, setName] = useState("");
const [domain, setDomain] = useState("");
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
// Formular auf die geöffnete Firma setzen.
if (open && initialisiert !== (merchant?.id ?? null)) {
setName(merchant?.name ?? "");
setDomain(merchant?.domain ?? "");
setInitialisiert(merchant?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: merchant?.id,
daten: { name: name.trim(), domain: domain.trim() || null },
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={merchant ? "Firma bearbeiten" : "Neue Firma"}
size="sm"
description={
merchant ? undefined : "Nach dem Anlegen sucht moneyfy im Hintergrund nach einem Logo."
}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim()}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Name" required>
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
placeholder="Netflix"
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Domain" hint="Optional, verbessert aber die Logosuche deutlich.">
{(id) => (
<Input
id={id}
value={domain}
placeholder="netflix.com"
onChange={(ereignis) => setDomain(ereignis.target.value)}
/>
)}
</Field>
</form>
</Modal>
);
}
+201
View File
@@ -0,0 +1,201 @@
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
import { useState } from "react";
import { Plus, Repeat, Search } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { MerchantLogo } from "@/components/MerchantLogo";
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, TableSkeleton } from "@/components/ui/Feedback";
import { Input, Select } from "@/components/ui/Field";
import { type RecurrenceFilter, useMerchants, useRecurrences } from "@/hooks/useEntities";
import { formatMoney, toNumber } from "@/lib/format";
import { monthlyCostOf } from "@/lib/recurrenceMath";
import { describeRRule } from "@/lib/rrule";
import type { Recurrence } from "@/types/api";
export function RecurrencesPage() {
const [filter, setFilter] = useState<RecurrenceFilter>({ active: true });
const [suche, setSuche] = useState("");
const [formularFuer, setFormularFuer] = useState<Recurrence | null | undefined>(undefined);
const [detailId, setDetailId] = useState<number | null>(null);
const { data: posten = [], isLoading } = useRecurrences(filter);
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const begriff = suche.trim().toLowerCase();
const sichtbar = begriff
? posten.filter((eintrag) => eintrag.title.toLowerCase().includes(begriff))
: posten;
const monatssumme = sichtbar.reduce((summe, eintrag) => {
const betrag = monthlyCostOf(eintrag.rrule, toNumber(eintrag.amount));
return summe + (eintrag.kind === "expense" ? -betrag : betrag);
}, 0);
return (
<>
<PageHeader
title="Wiederkehrend"
description="Abos, Verträge, Raten und regelmäßige Einkünfte."
actions={
<>
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Posten suchen"
aria-label="Posten suchen"
className="h-10 w-48 pl-8"
/>
</div>
<Select
aria-label="Nach Richtung filtern"
className="h-10 w-36"
value={filter.kind ?? ""}
onChange={(ereignis) =>
setFilter((alt) => ({ ...alt, kind: ereignis.target.value || undefined }))
}
>
<option value="">Alle</option>
<option value="expense">Ausgaben</option>
<option value="income">Einkünfte</option>
</Select>
<Select
aria-label="Nach Status filtern"
className="h-10 w-32"
value={filter.active === undefined ? "" : String(filter.active)}
onChange={(ereignis) =>
setFilter((alt) => ({
...alt,
active: ereignis.target.value === "" ? undefined : ereignis.target.value === "true",
}))
}
>
<option value="true">Aktiv</option>
<option value="false">Inaktiv</option>
<option value="">Alle</option>
</Select>
<Button variant="primary" onClick={() => setFormularFuer(null)}>
<Plus aria-hidden className="h-4 w-4" />
Posten
</Button>
</>
}
/>
{isLoading ? (
<TableSkeleton />
) : sichtbar.length === 0 ? (
<EmptyState
icon={Repeat}
title={begriff ? "Keine Treffer" : "Noch keine wiederkehrenden Posten"}
description={
begriff
? "Für diesen Suchbegriff gibt es keinen Posten."
: "Lege Miete, Abos, Versicherungen oder dein Gehalt an moneyfy berechnet daraus alle Fälligkeiten."
}
action={
!begriff && (
<Button variant="primary" onClick={() => setFormularFuer(null)}>
<Plus aria-hidden className="h-4 w-4" />
Ersten Posten anlegen
</Button>
)
}
/>
) : (
<>
<ul className="space-y-2">
{sichtbar.map((eintrag) => {
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
return (
<li key={eintrag.id}>
<button
type="button"
onClick={() => setDetailId(eintrag.id)}
className="card flex w-full items-center gap-3 p-3 text-left transition hover:border-faint"
>
<MerchantLogo
merchant={firma ?? { name: eintrag.title, logo_asset_id: null, brand_color: null, brand_color_dark: null }}
size={38}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-ink">{eintrag.title}</span>
{!eintrag.is_active && <Badge>Inaktiv</Badge>}
{eintrag.is_variable && <Badge tone="warning">Geschätzt</Badge>}
{eintrag.installments_total && (
<Badge tone="accent">{eintrag.installments_total} Raten</Badge>
)}
{eintrag.contract_cancelled_at && <Badge tone="negative">Gekündigt</Badge>}
{eintrag.reserve_enabled && <Badge tone="accent">Rücklage</Badge>}
</div>
<p className="mt-0.5 truncate text-xs text-muted">
{describeRRule(eintrag.rrule, eintrag.dtstart, eintrag.until)}
</p>
<p className="truncate text-xs text-faint">
{kategorieName(eintrag.category_id)}
</p>
</div>
<div className="shrink-0 text-right">
<p
className={`tabular font-medium ${
eintrag.kind === "income" ? "text-positive" : "text-ink"
}`}
>
{eintrag.kind === "income" ? "+" : ""}
{formatMoney(eintrag.amount)}
</p>
<p className="text-xs text-faint">
{formatMoney(monthlyCostOf(eintrag.rrule, toNumber(eintrag.amount)))} / Monat
</p>
</div>
</button>
</li>
);
})}
</ul>
<p className="mt-3 text-right text-xs text-muted">
Geschätzter Monatssaldo dieser Auswahl:{" "}
<span
className={`tabular font-medium ${
monatssumme < 0 ? "text-ink" : "text-positive"
}`}
>
{formatMoney(monatssumme)}
</span>
</p>
</>
)}
<RecurrenceFormDialog
recurrence={formularFuer}
open={formularFuer !== undefined}
onClose={() => setFormularFuer(undefined)}
/>
<RecurrenceDetailDrawer
recurrenceId={detailId}
onClose={() => setDetailId(null)}
onEdit={(posten) => {
setDetailId(null);
setFormularFuer(posten);
}}
/>
</>
);
}
+687
View File
@@ -0,0 +1,687 @@
/** Einstellungen: Konten, Kategorien und das eigene Konto. */
import { type FormEvent, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useChangePassword, useMe } from "@/hooks/useAuth";
import {
useAccountBalance,
useAccounts,
useCategoryTree,
useDeleteAccount,
useDeleteCategory,
useSaveAccount,
useSaveCategory,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, todayIso } from "@/lib/format";
import { useThemeStore } from "@/store/theme";
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
const KONTOARTEN: Record<AccountType, string> = {
checking: "Girokonto",
credit_card: "Kreditkarte",
savings: "Sparkonto",
cash: "Bargeld",
};
type Reiter = "accounts" | "categories" | "account";
const REITER: { id: Reiter; label: string }[] = [
{ id: "accounts", label: "Konten" },
{ id: "categories", label: "Kategorien" },
{ id: "account", label: "Konto & Darstellung" },
];
export function SettingsPage() {
const [reiter, setReiter] = useState<Reiter>("accounts");
return (
<>
<PageHeader title="Einstellungen" />
<div className="mb-5 flex gap-1 border-b border-line" role="tablist">
{REITER.map((eintrag) => (
<button
key={eintrag.id}
type="button"
role="tab"
aria-selected={reiter === eintrag.id}
onClick={() => setReiter(eintrag.id)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium transition ${
reiter === eintrag.id
? "border-accent text-accent"
: "border-transparent text-muted hover:text-ink"
}`}
>
{eintrag.label}
</button>
))}
</div>
{reiter === "accounts" && <AccountsSection />}
{reiter === "categories" && <CategoriesSection />}
{reiter === "account" && <UserSection />}
</>
);
}
/* --- Konten --------------------------------------------------------------- */
function AccountsSection() {
const { data: konten = [], isLoading } = useAccounts();
const [bearbeiten, setBearbeiten] = useState<Account | null | undefined>(undefined);
const [loeschen, setLoeschen] = useState<Account | null>(null);
const entfernen = useDeleteAccount();
return (
<section>
<div className="mb-3 flex justify-end">
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Konto
</Button>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : konten.length === 0 ? (
<EmptyState
icon={Wallet}
title="Noch keine Konten"
description="Ohne Konto lassen sich keine Buchungen oder Posten anlegen."
action={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Konto anlegen
</Button>
}
/>
) : (
<ul className="space-y-2">
{konten.map((konto) => (
<AccountRow
key={konto.id}
account={konto}
onEdit={() => setBearbeiten(konto)}
onDelete={() => setLoeschen(konto)}
/>
))}
</ul>
)}
<AccountDialog
account={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Konto löschen"
description={`${loeschen?.name}“ wird gelöscht. Das geht nur, solange keine Buchungen oder Posten darauf verweisen.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</section>
);
}
function AccountRow({
account,
onEdit,
onDelete,
}: {
account: Account;
onEdit: () => void;
onDelete: () => void;
}) {
const { data: saldo } = useAccountBalance(account.id);
return (
<li className="card group flex items-center gap-3 p-3">
<span
aria-hidden
className="h-9 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: account.color }}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-ink">{account.name}</span>
<Badge>{KONTOARTEN[account.type]}</Badge>
{!account.is_active && <Badge tone="warning">Inaktiv</Badge>}
</div>
<p className="text-xs text-faint">
{account.iban_last4 ? `IBAN …${account.iban_last4} · ` : ""}
Eröffnet {formatDate(account.opening_balance_date)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-xs text-muted">Saldo heute</p>
<p className="tabular font-medium text-ink">
{saldo ? formatMoney(saldo.balance) : "…"}
</p>
</div>
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${account.name} bearbeiten`}>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${account.name} löschen`}>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
);
}
function AccountDialog({
account,
open,
onClose,
}: {
account: Account | null | undefined;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveAccount();
const [name, setName] = useState("");
const [art, setArt] = useState<AccountType>("checking");
const [iban, setIban] = useState("");
const [saldo, setSaldo] = useState("0.00");
const [stichtag, setStichtag] = useState(todayIso());
const [farbe, setFarbe] = useState("#3b82f6");
const [aktiv, setAktiv] = useState(true);
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (account?.id ?? null)) {
setName(account?.name ?? "");
setArt(account?.type ?? "checking");
setIban(account?.iban_last4 ?? "");
setSaldo(account?.opening_balance ?? "0.00");
setStichtag(account?.opening_balance_date ?? todayIso());
setFarbe(account?.color ?? "#3b82f6");
setAktiv(account?.is_active ?? true);
setInitialisiert(account?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: account?.id,
daten: {
name: name.trim(),
type: art,
iban_last4: iban.trim() || null,
opening_balance: saldo || "0.00",
opening_balance_date: stichtag,
color: farbe,
is_active: aktiv,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={account ? "Konto bearbeiten" : "Neues Konto"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim()}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
<Field label="Name" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
placeholder="Girokonto"
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Art">
{(id) => (
<Select
id={id}
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as AccountType)}
>
{Object.entries(KONTOARTEN).map(([wert, bezeichnung]) => (
<option key={wert} value={wert}>
{bezeichnung}
</option>
))}
</Select>
)}
</Field>
<Field label="Letzte vier IBAN-Stellen">
{(id) => (
<Input
id={id}
inputMode="numeric"
maxLength={4}
pattern="\d{4}"
value={iban}
placeholder="4711"
onChange={(ereignis) => setIban(ereignis.target.value.replace(/\D/g, ""))}
/>
)}
</Field>
<Field label="Eröffnungssaldo" hint="Saldo zum Stichtag.">
{(id) => <MoneyInput id={id} value={saldo} onValueChange={setSaldo} />}
</Field>
<Field label="Stichtag" required>
{(id) => (
<Input
id={id}
type="date"
required
value={stichtag}
onChange={(ereignis) => setStichtag(ereignis.target.value)}
/>
)}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
<div className="flex items-end pb-2">
<Checkbox
label="Aktiv"
hint="Inaktive Konten erscheinen nicht mehr zur Auswahl."
checked={aktiv}
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
/>
</div>
</form>
</Modal>
);
}
/* --- Kategorien ----------------------------------------------------------- */
function CategoriesSection() {
const { data: baum = [], isLoading } = useCategoryTree();
const [bearbeiten, setBearbeiten] = useState<
{ kategorie: Category | null; parent: CategoryTree | null; kind: EntryKind } | undefined
>(undefined);
const [loeschen, setLoeschen] = useState<Category | null>(null);
const entfernen = useDeleteCategory();
if (isLoading) return <Skeleton className="h-64 w-full" />;
return (
<section className="space-y-4">
{(["expense", "income"] as EntryKind[]).map((richtung) => {
const gruppen = baum.filter((oberkategorie) => oberkategorie.kind === richtung);
return (
<div key={richtung}>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
{richtung === "expense" ? "Ausgaben" : "Einkünfte"}
</h2>
<Button
size="sm"
onClick={() => setBearbeiten({ kategorie: null, parent: null, kind: richtung })}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Oberkategorie
</Button>
</div>
<ul className="space-y-2">
{gruppen.map((oberkategorie) => (
<li key={oberkategorie.id} className="card p-3">
<div className="group flex items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: oberkategorie.color }}
/>
<span className="flex-1 truncate font-medium text-ink">
{oberkategorie.name}
</span>
{oberkategorie.is_fixed_cost && <Badge tone="accent">Fixkosten</Badge>}
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
aria-label={`Unterkategorie zu ${oberkategorie.name} anlegen`}
onClick={() =>
setBearbeiten({
kategorie: null,
parent: oberkategorie,
kind: richtung,
})
}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${oberkategorie.name} bearbeiten`}
onClick={() =>
setBearbeiten({ kategorie: oberkategorie, parent: null, kind: richtung })
}
>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${oberkategorie.name} löschen`}
onClick={() => setLoeschen(oberkategorie)}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{oberkategorie.children.length > 0 && (
<ul className="mt-2 space-y-1 border-l border-line pl-4">
{oberkategorie.children.map((unterkategorie) => (
<li
key={unterkategorie.id}
className="group flex items-center gap-2 py-0.5 text-sm"
>
<span className="flex-1 truncate text-muted">{unterkategorie.name}</span>
{unterkategorie.is_fixed_cost && <Badge tone="accent">Fix</Badge>}
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
aria-label={`${unterkategorie.name} bearbeiten`}
onClick={() =>
setBearbeiten({
kategorie: unterkategorie,
parent: oberkategorie,
kind: richtung,
})
}
>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${unterkategorie.name} löschen`}
onClick={() => setLoeschen(unterkategorie)}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
})}
{bearbeiten && (
<CategoryDialog
category={bearbeiten.kategorie}
parent={bearbeiten.parent}
kind={bearbeiten.kind}
open
onClose={() => setBearbeiten(undefined)}
/>
)}
<ConfirmDialog
open={loeschen !== null}
title="Kategorie löschen"
description={`${loeschen?.name}“ wird gelöscht. Das geht nur, solange nichts darauf verweist andernfalls archiviere sie.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</section>
);
}
function CategoryDialog({
category,
parent,
kind,
open,
onClose,
}: {
category: Category | null;
parent: CategoryTree | null;
kind: EntryKind;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveCategory();
const [name, setName] = useState(category?.name ?? "");
const [farbe, setFarbe] = useState(category?.color ?? parent?.color ?? "#64748b");
const [fixkosten, setFixkosten] = useState(category?.is_fixed_cost ?? parent?.is_fixed_cost ?? false);
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: category?.id,
daten: category
? { name: name.trim(), color: farbe, is_fixed_cost: fixkosten }
: {
name: name.trim(),
kind,
parent_id: parent?.id ?? null,
color: farbe,
is_fixed_cost: fixkosten,
},
},
{ onSuccess: onClose },
);
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={category ? "Kategorie bearbeiten" : parent ? "Neue Unterkategorie" : "Neue Oberkategorie"}
description={parent ? `Unter „${parent.name}` : undefined}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim()}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Name" required>
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
<Checkbox
label="Zählt zu den Fixkosten"
hint="Fließt in die Kennzahl „verfügbar nach Fixkosten“ ein."
checked={fixkosten}
onChange={(ereignis) => setFixkosten(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
/* --- Konto und Darstellung ------------------------------------------------ */
function UserSection() {
const { data: benutzer } = useMe();
const theme = useThemeStore((zustand) => zustand.theme);
const setTheme = useThemeStore((zustand) => zustand.setTheme);
const wechseln = useChangePassword();
const [aktuell, setAktuell] = useState("");
const [neu, setNeu] = useState("");
const [wiederholung, setWiederholung] = useState("");
const absendbar = Boolean(aktuell) && neu.length >= 10 && neu === wiederholung;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
wechseln.mutate({ current_password: aktuell, new_password: neu });
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<section className="card p-4">
<h2 className="text-sm font-semibold text-ink">Darstellung</h2>
<p className="mt-0.5 text-xs text-muted">
Die Wahl wird im Browser gespeichert und gilt für dieses Gerät.
</p>
<div className="mt-3 flex gap-2">
{(["dark", "light"] as const).map((wert) => (
<Button
key={wert}
variant={theme === wert ? "primary" : "secondary"}
onClick={() => setTheme(wert)}
>
{wert === "dark" ? "Dunkel" : "Hell"}
</Button>
))}
</div>
</section>
<section className="card p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<KeyRound aria-hidden className="h-4 w-4" />
Passwort ändern
</h2>
{benutzer && (
<p className="mt-0.5 text-xs text-muted">
Angemeldet als {benutzer.username}. Ein Wechsel beendet alle Sitzungen.
</p>
)}
<form onSubmit={absenden} className="mt-3 space-y-3">
<Field label="Aktuelles Passwort" required>
{(id) => (
<Input
id={id}
type="password"
autoComplete="current-password"
value={aktuell}
onChange={(ereignis) => setAktuell(ereignis.target.value)}
/>
)}
</Field>
<Field label="Neues Passwort" required hint="Mindestens 10 Zeichen.">
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
value={neu}
onChange={(ereignis) => setNeu(ereignis.target.value)}
/>
)}
</Field>
<Field
label="Wiederholen"
required
error={wiederholung && neu !== wiederholung ? "Die Eingaben stimmen nicht überein." : null}
>
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
value={wiederholung}
onChange={(ereignis) => setWiederholung(ereignis.target.value)}
/>
)}
</Field>
<Button
type="submit"
variant="primary"
loading={wechseln.isPending}
disabled={!absendbar}
>
Passwort ändern
</Button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,249 @@
/** Deckt den CRUD-Fluss einer Buchung über die Oberfläche ab. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TransactionsPage } from "@/pages/TransactionsPage";
import { renderWithProviders } from "@/test/utils";
import type { Account, CategoryTree, Transaction } from "@/types/api";
const KONTO: Account = {
id: 1,
name: "Girokonto",
type: "checking",
iban_last4: "4711",
opening_balance: "1000.00",
opening_balance_date: "2026-01-01",
color: "#3b82f6",
icon: "wallet",
is_active: true,
sort_order: 0,
created_at: "2026-01-01T10:00:00+01:00",
updated_at: "2026-01-01T10:00:00+01:00",
};
const KATEGORIEN: CategoryTree[] = [
{
id: 10,
parent_id: null,
name: "Lebenshaltung",
kind: "expense",
color: "#84cc16",
icon: "shopping-basket",
is_fixed_cost: false,
sort_order: 0,
is_archived: false,
children: [
{
id: 11,
parent_id: 10,
name: "Lebensmittel",
kind: "expense",
color: "#84cc16",
icon: "shopping-cart",
is_fixed_cost: false,
sort_order: 0,
is_archived: false,
},
],
},
];
const BUCHUNG: Transaction = {
id: 5,
kind: "expense",
title: "Wocheneinkauf",
merchant_id: null,
category_id: 11,
account_id: 1,
amount: "84.30",
booking_date: "2026-03-05",
note: null,
tags: [],
created_at: "2026-03-05T18:00:00+01:00",
merchant: null,
};
/** Bildet die benötigten Endpunkte nach und protokolliert schreibende Zugriffe. */
function mockApi(buchungen: Transaction[]) {
const bestand = [...buchungen];
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown, status = 200) =>
new Response(JSON.stringify(daten), {
status,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const methode = init?.method ?? "GET";
const rumpf = typeof init?.body === "string" ? JSON.parse(init.body) : null;
anfragen.push({ url, method: methode, body: rumpf });
if (url.includes("/api/accounts")) return json([KONTO]);
if (url.includes("/api/categories")) return json(KATEGORIEN);
if (url.includes("/api/merchants")) {
return json({ items: [], total: 0, limit: 200, offset: 0 });
}
if (url.includes("/api/transactions")) {
if (methode === "POST") {
const neu: Transaction = { ...BUCHUNG, id: 99, ...rumpf, merchant: null };
bestand.unshift(neu);
return json(neu, 201);
}
if (methode === "PATCH") {
const index = bestand.findIndex((eintrag) => url.endsWith(String(eintrag.id)));
if (index >= 0) bestand[index] = { ...bestand[index]!, ...rumpf };
return json(bestand[index]);
}
if (methode === "DELETE") {
const index = bestand.findIndex((eintrag) => url.endsWith(String(eintrag.id)));
if (index >= 0) bestand.splice(index, 1);
return json({ detail: "Buchung gelöscht." });
}
return json({
items: bestand,
total: bestand.length,
limit: 25,
offset: 0,
});
}
return json({ detail: "Not Found", code: "not_found" }, 404);
}),
);
return anfragen;
}
describe("Buchungen über die Oberfläche", () => {
beforeEach(() => {
mockApi([BUCHUNG]);
});
it("zeigt vorhandene Buchungen in deutschem Format", async () => {
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
expect(screen.getByText("05.03.2026")).toBeInTheDocument();
expect(screen.getByText(/84,30/)).toBeInTheDocument();
expect(screen.getByText("Lebenshaltung · Lebensmittel")).toBeInTheDocument();
});
it("zeigt einen Leerzustand mit Aufforderung", async () => {
mockApi([]);
renderWithProviders(<TransactionsPage />);
await waitFor(() => {
expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Erste Buchung erfassen/ })).toBeInTheDocument();
});
it("legt über das Formular eine Buchung an", async () => {
const anfragen = mockApi([]);
const nutzer = userEvent.setup();
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
await nutzer.type(within(dialog).getByLabelText(/Titel/), "Drogerie");
await nutzer.type(within(dialog).getByLabelText(/Betrag/), "24,90");
await nutzer.selectOptions(within(dialog).getByLabelText(/Kategorie/), "11");
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
await waitFor(() => {
const angelegt = anfragen.find(
(eintrag) => eintrag.method === "POST" && eintrag.url.includes("/api/transactions"),
);
expect(angelegt).toBeDefined();
// Der Betrag geht im API-Format über die Leitung.
expect(angelegt?.body).toMatchObject({
title: "Drogerie",
amount: "24.90",
category_id: 11,
account_id: 1,
kind: "expense",
});
});
});
it("verlangt Titel, Betrag und Kategorie", async () => {
mockApi([]);
const nutzer = userEvent.setup();
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
expect(within(dialog).getByRole("button", { name: "Speichern" })).toBeDisabled();
await nutzer.type(within(dialog).getByLabelText(/Titel/), "Nur ein Titel");
expect(within(dialog).getByRole("button", { name: "Speichern" })).toBeDisabled();
});
it("bearbeitet eine vorhandene Buchung", async () => {
const anfragen = mockApi([BUCHUNG]);
const nutzer = userEvent.setup();
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: "Wocheneinkauf bearbeiten" }));
const dialog = await screen.findByRole("dialog", { name: "Buchung bearbeiten" });
const titel = within(dialog).getByLabelText(/Titel/);
await nutzer.clear(titel);
await nutzer.type(titel, "Großeinkauf");
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
await waitFor(() => {
const geaendert = anfragen.find((eintrag) => eintrag.method === "PATCH");
expect(geaendert?.body).toMatchObject({ title: "Großeinkauf" });
});
});
it("löscht erst nach Rückfrage", async () => {
const anfragen = mockApi([BUCHUNG]);
const nutzer = userEvent.setup();
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Wocheneinkauf")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: "Wocheneinkauf löschen" }));
const rueckfrage = await screen.findByRole("dialog", { name: "Buchung löschen" });
expect(within(rueckfrage).getByText(/endgültig gelöscht/)).toBeInTheDocument();
// Vor der Bestätigung darf nichts passiert sein.
expect(anfragen.some((eintrag) => eintrag.method === "DELETE")).toBe(false);
await nutzer.click(within(rueckfrage).getByRole("button", { name: "Löschen" }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.method === "DELETE")).toBe(true);
});
});
it("leert die Kategorie beim Wechsel der Richtung", async () => {
mockApi([]);
const nutzer = userEvent.setup();
renderWithProviders(<TransactionsPage />);
await waitFor(() => expect(screen.getByText("Noch keine Buchungen")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Erste Buchung erfassen/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Buchung" });
await nutzer.selectOptions(within(dialog).getByLabelText(/Kategorie/), "11");
expect(within(dialog).getByLabelText(/Kategorie/)).toHaveValue("11");
// Eine Ausgabenkategorie passt nicht zu einer Einkunft.
await nutzer.selectOptions(within(dialog).getByLabelText(/Richtung/), "income");
expect(within(dialog).getByLabelText(/Kategorie/)).toHaveValue("");
});
});
+457
View File
@@ -0,0 +1,457 @@
/** Einmalige Buchungen: Liste, Filter und Formular. */
import { type FormEvent, useState } from "react";
import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { ConfirmDialog, EmptyState, TableSkeleton } from "@/components/ui/Feedback";
import { Field, Input, Select, Textarea } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import {
type TransactionFilter,
useAccounts,
useDeleteTransaction,
useSaveTransaction,
useTransactions,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, todayIso } from "@/lib/format";
import type { EntryKind, Transaction } from "@/types/api";
const SEITENGROESSE = 25;
export function TransactionsPage() {
const [filter, setFilter] = useState<TransactionFilter>({ limit: SEITENGROESSE, offset: 0 });
const [suche, setSuche] = useState("");
const [bearbeiten, setBearbeiten] = useState<Transaction | null | undefined>(undefined);
const [loeschen, setLoeschen] = useState<Transaction | null>(null);
const { data, isLoading } = useTransactions({ ...filter, q: suche || undefined });
const entfernen = useDeleteTransaction();
const kategorieName = useCategoryLookup();
const buchungen = data?.items ?? [];
const gesamt = data?.total ?? 0;
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
const seiten = Math.max(1, Math.ceil(gesamt / SEITENGROESSE));
return (
<>
<PageHeader
title="Buchungen"
description="Einmalige Ausgaben und Einnahmen."
actions={
<>
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
value={suche}
onChange={(ereignis) => {
setSuche(ereignis.target.value);
setFilter((alt) => ({ ...alt, offset: 0 }));
}}
placeholder="Titel oder Notiz"
aria-label="Buchungen durchsuchen"
className="h-10 w-52 pl-8"
/>
</div>
<Select
aria-label="Nach Richtung filtern"
className="h-10 w-36"
value={filter.kind ?? ""}
onChange={(ereignis) =>
setFilter((alt) => ({ ...alt, kind: ereignis.target.value || undefined, offset: 0 }))
}
>
<option value="">Alle</option>
<option value="expense">Ausgaben</option>
<option value="income">Einkünfte</option>
</Select>
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Buchung
</Button>
</>
}
/>
{isLoading ? (
<TableSkeleton />
) : buchungen.length === 0 ? (
<EmptyState
icon={Receipt}
title={suche || filter.kind ? "Keine Treffer" : "Noch keine Buchungen"}
description={
suche || filter.kind
? "Passe Suche oder Filter an."
: "Erfasse einmalige Ausgaben und Einnahmen, die keiner Serie folgen."
}
action={
!suche &&
!filter.kind && (
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erste Buchung erfassen
</Button>
)
}
/>
) : (
<>
<div className="card overflow-hidden">
<table className="w-full text-sm">
<caption className="sr-only">Liste der Buchungen</caption>
<thead className="border-b border-line text-left text-xs text-muted">
<tr>
<th scope="col" className="px-4 py-2.5 font-medium">
Datum
</th>
<th scope="col" className="px-4 py-2.5 font-medium">
Buchung
</th>
<th scope="col" className="hidden px-4 py-2.5 font-medium md:table-cell">
Kategorie
</th>
<th scope="col" className="px-4 py-2.5 text-right font-medium">
Betrag
</th>
<th scope="col" className="px-4 py-2.5">
<span className="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{buchungen.map((buchung) => (
<tr key={buchung.id} className="group transition hover:bg-raised/60">
<td className="whitespace-nowrap px-4 py-2.5 tabular text-muted">
{formatDate(buchung.booking_date)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
{buchung.merchant ? (
<MerchantLogo merchant={buchung.merchant} size={28} />
) : null}
<div className="min-w-0">
<p className="truncate font-medium text-ink">{buchung.title}</p>
{buchung.note && (
<p className="truncate text-xs text-faint">{buchung.note}</p>
)}
</div>
</div>
</td>
<td className="hidden px-4 py-2.5 text-xs text-muted md:table-cell">
{kategorieName(buchung.category_id)}
</td>
<td
className={`whitespace-nowrap px-4 py-2.5 text-right tabular font-medium ${
buchung.kind === "income" ? "text-positive" : "text-ink"
}`}
>
{buchung.kind === "income" ? "+" : ""}
{formatMoney(buchung.amount)}
</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
onClick={() => setBearbeiten(buchung)}
aria-label={`${buchung.title} bearbeiten`}
>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setLoeschen(buchung)}
aria-label={`${buchung.title} löschen`}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{seiten > 1 && (
<div className="mt-3 flex items-center justify-between text-xs text-muted">
<span>
{gesamt} {gesamt === 1 ? "Buchung" : "Buchungen"}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={seite === 1}
onClick={() =>
setFilter((alt) => ({
...alt,
offset: Math.max(0, (alt.offset ?? 0) - SEITENGROESSE),
}))
}
>
Zurück
</Button>
<span className="tabular">
Seite {seite} von {seiten}
</span>
<Button
size="sm"
disabled={seite >= seiten}
onClick={() =>
setFilter((alt) => ({ ...alt, offset: (alt.offset ?? 0) + SEITENGROESSE }))
}
>
Weiter
</Button>
</div>
</div>
)}
</>
)}
<TransactionDialog
transaction={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Buchung löschen"
description={`${loeschen?.title}“ wird endgültig gelöscht.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</>
);
}
interface FormularZustand {
kind: EntryKind;
title: string;
amount: string;
booking_date: string;
category_id: number | null;
account_id: number | null;
merchant_id: number | null;
note: string;
}
function leeresFormular(kontoId: number | null): FormularZustand {
return {
kind: "expense",
title: "",
amount: "",
booking_date: todayIso(),
category_id: null,
account_id: kontoId,
merchant_id: null,
note: "",
};
}
function TransactionDialog({
transaction,
open,
onClose,
}: {
transaction: Transaction | null | undefined;
open: boolean;
onClose: () => void;
}) {
const { data: konten = [] } = useAccounts(true);
const speichern = useSaveTransaction();
const [formular, setFormular] = useState<FormularZustand>(() => leeresFormular(null));
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (transaction?.id ?? null)) {
setFormular(
transaction
? {
kind: transaction.kind,
title: transaction.title,
amount: transaction.amount,
booking_date: transaction.booking_date,
category_id: transaction.category_id,
account_id: transaction.account_id,
merchant_id: transaction.merchant_id,
note: transaction.note ?? "",
}
: leeresFormular(konten[0]?.id ?? null),
);
setInitialisiert(transaction?.id ?? null);
}
const absendbar =
formular.title.trim() !== "" &&
formular.amount !== "" &&
formular.category_id !== null &&
formular.account_id !== null;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
speichern.mutate(
{
id: transaction?.id,
daten: {
kind: formular.kind,
title: formular.title.trim(),
amount: formular.amount,
booking_date: formular.booking_date,
category_id: formular.category_id!,
account_id: formular.account_id!,
merchant_id: formular.merchant_id,
note: formular.note.trim() || null,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={transaction ? "Buchung bearbeiten" : "Neue Buchung"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!absendbar}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
<Field label="Richtung" required>
{(id) => (
<Select
id={id}
value={formular.kind}
onChange={(ereignis) =>
// Die Kategorie passt nach dem Wechsel nicht mehr und wird geleert.
setFormular((alt) => ({
...alt,
kind: ereignis.target.value as EntryKind,
category_id: null,
}))
}
>
<option value="expense">Ausgabe</option>
<option value="income">Einkunft</option>
</Select>
)}
</Field>
<Field label="Betrag" required>
{(id) => (
<MoneyInput
id={id}
value={formular.amount}
onValueChange={(wert) => setFormular((alt) => ({ ...alt, amount: wert }))}
/>
)}
</Field>
<Field label="Titel" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={formular.title}
required
placeholder="Wocheneinkauf"
onChange={(ereignis) =>
setFormular((alt) => ({ ...alt, title: ereignis.target.value }))
}
/>
)}
</Field>
<Field label="Datum" required>
{(id) => (
<Input
id={id}
type="date"
value={formular.booking_date}
required
onChange={(ereignis) =>
setFormular((alt) => ({ ...alt, booking_date: ereignis.target.value }))
}
/>
)}
</Field>
<Field label="Konto" required>
{(id) => (
<AccountSelect
id={id}
required
value={formular.account_id}
onChange={(wert) => setFormular((alt) => ({ ...alt, account_id: wert }))}
/>
)}
</Field>
<Field label="Kategorie" required>
{(id) => (
<CategorySelect
id={id}
required
kind={formular.kind}
value={formular.category_id}
onChange={(wert) => setFormular((alt) => ({ ...alt, category_id: wert }))}
/>
)}
</Field>
<Field label="Firma">
{(id) => (
<MerchantSelect
id={id}
value={formular.merchant_id}
onChange={(wert) => setFormular((alt) => ({ ...alt, merchant_id: wert }))}
/>
)}
</Field>
<Field label="Notiz" className="sm:col-span-2">
{(id) => (
<Textarea
id={id}
rows={2}
value={formular.note}
onChange={(ereignis) =>
setFormular((alt) => ({ ...alt, note: ereignis.target.value }))
}
/>
)}
</Field>
</form>
</Modal>
);
}
+40
View File
@@ -0,0 +1,40 @@
/** Umschalter zwischen dunkler und heller Oberfläche. Vorgabe ist dunkel. */
import { create } from "zustand";
export type Theme = "dark" | "light";
const SPEICHER_SCHLUESSEL = "moneyfy.theme";
function gespeichertesTheme(): Theme {
try {
return localStorage.getItem(SPEICHER_SCHLUESSEL) === "light" ? "light" : "dark";
} catch {
// Privater Modus ohne Speicher: es bleibt beim dunklen Standard.
return "dark";
}
}
function anwenden(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
try {
localStorage.setItem(SPEICHER_SCHLUESSEL, theme);
} catch {
/* Ohne Speicher gilt die Wahl nur für diese Sitzung. */
}
}
interface ThemeStore {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeStore>((set, get) => ({
theme: gespeichertesTheme(),
setTheme: (theme) => {
anwenden(theme);
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === "dark" ? "light" : "dark"),
}));
+51
View File
@@ -0,0 +1,51 @@
/** Kurze Rückmeldungen am unteren Bildschirmrand. */
import { create } from "zustand";
export type ToastKind = "success" | "error" | "info";
export interface Toast {
id: number;
kind: ToastKind;
title: string;
description?: string;
}
const ANZEIGEDAUER_MS: Record<ToastKind, number> = {
success: 3500,
info: 4000,
error: 7000,
};
let naechsteId = 1;
interface ToastStore {
toasts: Toast[];
push: (toast: Omit<Toast, "id">) => number;
dismiss: (id: number) => void;
clear: () => void;
}
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
push: (toast) => {
const id = naechsteId++;
set((zustand) => ({ toasts: [...zustand.toasts, { ...toast, id }] }));
setTimeout(() => {
set((zustand) => ({ toasts: zustand.toasts.filter((eintrag) => eintrag.id !== id) }));
}, ANZEIGEDAUER_MS[toast.kind]);
return id;
},
dismiss: (id) =>
set((zustand) => ({ toasts: zustand.toasts.filter((eintrag) => eintrag.id !== id) })),
clear: () => set({ toasts: [] }),
}));
export const toast = {
success: (title: string, description?: string) =>
useToastStore.getState().push({ kind: "success", title, description }),
error: (title: string, description?: string) =>
useToastStore.getState().push({ kind: "error", title, description }),
info: (title: string, description?: string) =>
useToastStore.getState().push({ kind: "info", title, description }),
};
+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;
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Typen der moneyfy-API.
*
* Sie spiegeln die Pydantic-Schemata des Backends. Geldbeträge kommen als
* String, damit unterwegs keine Genauigkeit verloren geht zum Rechnen und
* Anzeigen dienen die Helfer aus `lib/format`.
*/
export type Money = string;
export type IsoDate = string;
export type IsoDateTime = string;
export type EntryKind = "expense" | "income";
export type AccountType = "checking" | "credit_card" | "savings" | "cash";
export type BusinessDayShift = "none" | "next" | "previous";
export type OccurrenceStatus = "planned" | "confirmed" | "skipped";
export type LogoStatus = "pending" | "resolved" | "failed" | "manual";
export type LogoSource =
| "simple-icons"
| "logodev"
| "brandfetch"
| "favicon"
| "upload"
| "generated";
export interface ProblemDetail {
detail: string;
code: string;
errors?: { loc: string[]; msg: string; type: string }[];
}
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface MessageResponse {
detail: string;
}
/* --- Benutzer ------------------------------------------------------------- */
export interface User {
id: number;
username: string;
email: string | null;
must_change_password: boolean;
last_login_at: IsoDateTime | null;
}
/* --- Konten --------------------------------------------------------------- */
export interface Account {
id: number;
name: string;
type: AccountType;
iban_last4: string | null;
opening_balance: Money;
opening_balance_date: IsoDate;
color: string;
icon: string;
is_active: boolean;
sort_order: number;
created_at: IsoDateTime;
updated_at: IsoDateTime;
}
export type AccountInput = Partial<Omit<Account, "id" | "created_at" | "updated_at">> & {
name: string;
opening_balance_date: IsoDate;
};
export interface AccountBalance {
account_id: number;
as_of: IsoDate;
opening_balance: Money;
booked_transactions: Money;
booked_occurrences: Money;
balance: Money;
}
/* --- Kategorien ----------------------------------------------------------- */
export interface Category {
id: number;
parent_id: number | null;
name: string;
kind: EntryKind;
color: string;
icon: string;
is_fixed_cost: boolean;
sort_order: number;
is_archived: boolean;
}
export interface CategoryTree extends Category {
children: Category[];
}
export interface CategoryInput {
name: string;
kind: EntryKind;
parent_id?: number | null;
color?: string;
icon?: string;
is_fixed_cost?: boolean;
sort_order?: number;
is_archived?: boolean;
}
/* --- Firmen --------------------------------------------------------------- */
export interface Merchant {
id: number;
name: string;
normalized_name: string;
domain: string | null;
aliases: string[];
logo_asset_id: number | null;
brand_color: string | null;
brand_color_dark: string | null;
logo_source: LogoSource | null;
logo_status: LogoStatus;
created_at: IsoDateTime;
}
export interface MerchantInput {
name: string;
domain?: string | null;
aliases?: string[];
brand_color?: string | null;
brand_color_dark?: string | null;
}
export interface LogoCandidate {
candidate_id: number;
source: LogoSource;
title: string;
score: number;
mime: string;
width: number | null;
height: number | null;
brand_color: string | null;
is_preselected: boolean;
}
export interface LogoSearchResult {
merchant_id: number;
candidates: LogoCandidate[];
}
/* --- Wiederkehrende Posten ------------------------------------------------ */
export interface Recurrence {
id: number;
kind: EntryKind;
title: string;
merchant_id: number | null;
category_id: number;
account_id: number;
amount: Money;
is_variable: boolean;
currency: string;
rrule: string;
dtstart: IsoDate;
until: IsoDate | null;
business_day_shift: BusinessDayShift;
holiday_region: string;
installments_total: number | null;
principal_amount: Money | null;
contract_start: IsoDate | null;
contract_min_term_months: number | null;
contract_notice_period_days: number | null;
contract_auto_renew_months: number | null;
contract_cancelled_at: IsoDate | null;
reserve_enabled: boolean;
notes: string | null;
tags: string[];
is_active: boolean;
created_at: IsoDateTime;
updated_at: IsoDateTime;
}
export interface AmountVersion {
id: number;
recurrence_id: number;
amount: Money;
valid_from: IsoDate;
note: string | null;
created_at: IsoDateTime;
}
export interface ContractTerm {
term_start: IsoDate;
term_end: IsoDate;
notice_deadline: IsoDate | null;
renews_on: IsoDate | null;
is_cancelled: boolean;
}
export interface InstallmentStatus {
total: number;
paid: number;
remaining: number;
paid_amount: Money;
remaining_amount: Money;
final_due_date: IsoDate | null;
}
export interface RecurrenceDetail extends Recurrence {
merchant: Merchant | null;
amount_versions: AmountVersion[];
next_dates: IsoDate[];
monthly_reserve: Money | null;
annual_burden: Money;
contract_term: ContractTerm | null;
installments: InstallmentStatus | null;
}
export interface RecurrenceInput {
kind: EntryKind;
title: string;
merchant_id?: number | null;
category_id: number;
account_id: number;
amount: Money;
is_variable?: boolean;
currency?: string;
rrule: string;
dtstart: IsoDate;
until?: IsoDate | null;
business_day_shift?: BusinessDayShift;
holiday_region?: string;
installments_total?: number | null;
principal_amount?: Money | null;
contract_start?: IsoDate | null;
contract_min_term_months?: number | null;
contract_notice_period_days?: number | null;
contract_auto_renew_months?: number | null;
contract_cancelled_at?: IsoDate | null;
reserve_enabled?: boolean;
notes?: string | null;
tags?: string[];
is_active?: boolean;
}
/* --- Fälligkeiten --------------------------------------------------------- */
export interface Occurrence {
recurrence_id: number;
recurrence_title: string;
kind: EntryKind;
category_id: number;
merchant_id: number | null;
account_id: number | null;
nominal_date: IsoDate;
due_date: IsoDate;
effective_date: IsoDate;
amount: Money;
actual_amount: Money | null;
effective_amount: Money;
status: OccurrenceStatus;
is_variable: boolean;
occurrence_id: number | null;
note: string | null;
installment_number: number | null;
installments_total: number | null;
}
export interface OccurrenceConfirmInput {
recurrence_id: number;
occurrence_date: IsoDate;
actual_amount?: Money | null;
actual_date?: IsoDate | null;
account_id?: number | null;
note?: string | null;
}
/* --- Buchungen ------------------------------------------------------------ */
export interface Transaction {
id: number;
kind: EntryKind;
title: string;
merchant_id: number | null;
category_id: number;
account_id: number;
amount: Money;
booking_date: IsoDate;
note: string | null;
tags: string[];
created_at: IsoDateTime;
merchant: Merchant | null;
}
export interface TransactionInput {
kind: EntryKind;
title: string;
merchant_id?: number | null;
category_id: number;
account_id: number;
amount: Money;
booking_date: IsoDate;
note?: string | null;
tags?: string[];
}
/* --- Auswertungen --------------------------------------------------------- */
export interface Totals {
income: Money;
expenses: Money;
balance: Money;
}
export interface MonthReport {
month: IsoDate;
planned: Totals;
actual: Totals;
previous_planned: Totals;
previous_actual: Totals;
delta_to_previous: Totals;
fixed_costs: Money;
variable_costs: Money;
reserves: Money;
available_after_fixed: Money;
confirmed_count: number;
open_count: number;
skipped_count: number;
}
+58
View File
@@ -0,0 +1,58 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
// Umschaltbar über die Klasse `dark` am <html>-Element; Vorgabe ist dunkel.
darkMode: "class",
theme: {
extend: {
colors: {
// Die Palette wird über CSS-Variablen gespeist, damit der Wechsel
// zwischen hell und dunkel ohne doppelte Klassen auskommt.
ground: "rgb(var(--color-ground) / <alpha-value>)",
surface: "rgb(var(--color-surface) / <alpha-value>)",
raised: "rgb(var(--color-raised) / <alpha-value>)",
line: "rgb(var(--color-line) / <alpha-value>)",
ink: "rgb(var(--color-ink) / <alpha-value>)",
muted: "rgb(var(--color-muted) / <alpha-value>)",
faint: "rgb(var(--color-faint) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)",
"accent-ink": "rgb(var(--color-accent-ink) / <alpha-value>)",
positive: "rgb(var(--color-positive) / <alpha-value>)",
negative: "rgb(var(--color-negative) / <alpha-value>)",
warning: "rgb(var(--color-warning) / <alpha-value>)",
},
fontFamily: {
sans: [
"Inter",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"sans-serif",
],
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
},
borderRadius: {
card: "0.875rem",
},
keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
"slide-up": {
from: { opacity: "0", transform: "translateY(0.5rem)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"slide-in-right": {
from: { transform: "translateX(100%)" },
to: { transform: "translateX(0)" },
},
},
animation: {
"fade-in": "fade-in 150ms ease-out",
"slide-up": "slide-up 180ms ease-out",
"slide-in-right": "slide-in-right 220ms cubic-bezier(0.22, 1, 0.36, 1)",
},
},
},
plugins: [],
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts", "eslint.config.js"]
}
+32
View File
@@ -0,0 +1,32 @@
import path from "node:path";
import react from "@vitejs/plugin-react";
// `vitest/config` erweitert Vites defineConfig um den Testabschnitt.
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
port: 5173,
// Im Entwicklungsbetrieb übernimmt Vite die Rolle, die in Produktion nginx hat.
proxy: {
"/api": {
target: process.env.VITE_API_PROXY ?? "http://localhost:8000",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: false,
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
},
});