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