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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 16:23:04 +02:00
co-authored by Claude Opus 5
parent 8edb69fe6a
commit 8d6fcfeb58
57 changed files with 13064 additions and 2 deletions
+687
View File
@@ -0,0 +1,687 @@
/** Einstellungen: Konten, Kategorien und das eigene Konto. */
import { type FormEvent, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useChangePassword, useMe } from "@/hooks/useAuth";
import {
useAccountBalance,
useAccounts,
useCategoryTree,
useDeleteAccount,
useDeleteCategory,
useSaveAccount,
useSaveCategory,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, todayIso } from "@/lib/format";
import { useThemeStore } from "@/store/theme";
import type { Account, AccountType, Category, CategoryTree, EntryKind } from "@/types/api";
const KONTOARTEN: Record<AccountType, string> = {
checking: "Girokonto",
credit_card: "Kreditkarte",
savings: "Sparkonto",
cash: "Bargeld",
};
type Reiter = "accounts" | "categories" | "account";
const REITER: { id: Reiter; label: string }[] = [
{ id: "accounts", label: "Konten" },
{ id: "categories", label: "Kategorien" },
{ id: "account", label: "Konto & Darstellung" },
];
export function SettingsPage() {
const [reiter, setReiter] = useState<Reiter>("accounts");
return (
<>
<PageHeader title="Einstellungen" />
<div className="mb-5 flex gap-1 border-b border-line" role="tablist">
{REITER.map((eintrag) => (
<button
key={eintrag.id}
type="button"
role="tab"
aria-selected={reiter === eintrag.id}
onClick={() => setReiter(eintrag.id)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium transition ${
reiter === eintrag.id
? "border-accent text-accent"
: "border-transparent text-muted hover:text-ink"
}`}
>
{eintrag.label}
</button>
))}
</div>
{reiter === "accounts" && <AccountsSection />}
{reiter === "categories" && <CategoriesSection />}
{reiter === "account" && <UserSection />}
</>
);
}
/* --- Konten --------------------------------------------------------------- */
function AccountsSection() {
const { data: konten = [], isLoading } = useAccounts();
const [bearbeiten, setBearbeiten] = useState<Account | null | undefined>(undefined);
const [loeschen, setLoeschen] = useState<Account | null>(null);
const entfernen = useDeleteAccount();
return (
<section>
<div className="mb-3 flex justify-end">
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Konto
</Button>
</div>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : konten.length === 0 ? (
<EmptyState
icon={Wallet}
title="Noch keine Konten"
description="Ohne Konto lassen sich keine Buchungen oder Posten anlegen."
action={
<Button variant="primary" onClick={() => setBearbeiten(null)}>
<Plus aria-hidden className="h-4 w-4" />
Erstes Konto anlegen
</Button>
}
/>
) : (
<ul className="space-y-2">
{konten.map((konto) => (
<AccountRow
key={konto.id}
account={konto}
onEdit={() => setBearbeiten(konto)}
onDelete={() => setLoeschen(konto)}
/>
))}
</ul>
)}
<AccountDialog
account={bearbeiten}
open={bearbeiten !== undefined}
onClose={() => setBearbeiten(undefined)}
/>
<ConfirmDialog
open={loeschen !== null}
title="Konto löschen"
description={`${loeschen?.name}“ wird gelöscht. Das geht nur, solange keine Buchungen oder Posten darauf verweisen.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</section>
);
}
function AccountRow({
account,
onEdit,
onDelete,
}: {
account: Account;
onEdit: () => void;
onDelete: () => void;
}) {
const { data: saldo } = useAccountBalance(account.id);
return (
<li className="card group flex items-center gap-3 p-3">
<span
aria-hidden
className="h-9 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: account.color }}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate font-medium text-ink">{account.name}</span>
<Badge>{KONTOARTEN[account.type]}</Badge>
{!account.is_active && <Badge tone="warning">Inaktiv</Badge>}
</div>
<p className="text-xs text-faint">
{account.iban_last4 ? `IBAN …${account.iban_last4} · ` : ""}
Eröffnet {formatDate(account.opening_balance_date)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-xs text-muted">Saldo heute</p>
<p className="tabular font-medium text-ink">
{saldo ? formatMoney(saldo.balance) : "…"}
</p>
</div>
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${account.name} bearbeiten`}>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${account.name} löschen`}>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
);
}
function AccountDialog({
account,
open,
onClose,
}: {
account: Account | null | undefined;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveAccount();
const [name, setName] = useState("");
const [art, setArt] = useState<AccountType>("checking");
const [iban, setIban] = useState("");
const [saldo, setSaldo] = useState("0.00");
const [stichtag, setStichtag] = useState(todayIso());
const [farbe, setFarbe] = useState("#3b82f6");
const [aktiv, setAktiv] = useState(true);
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (account?.id ?? null)) {
setName(account?.name ?? "");
setArt(account?.type ?? "checking");
setIban(account?.iban_last4 ?? "");
setSaldo(account?.opening_balance ?? "0.00");
setStichtag(account?.opening_balance_date ?? todayIso());
setFarbe(account?.color ?? "#3b82f6");
setAktiv(account?.is_active ?? true);
setInitialisiert(account?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: account?.id,
daten: {
name: name.trim(),
type: art,
iban_last4: iban.trim() || null,
opening_balance: saldo || "0.00",
opening_balance_date: stichtag,
color: farbe,
is_active: aktiv,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
title={account ? "Konto bearbeiten" : "Neues Konto"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim()}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
<Field label="Name" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
placeholder="Girokonto"
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Art">
{(id) => (
<Select
id={id}
value={art}
onChange={(ereignis) => setArt(ereignis.target.value as AccountType)}
>
{Object.entries(KONTOARTEN).map(([wert, bezeichnung]) => (
<option key={wert} value={wert}>
{bezeichnung}
</option>
))}
</Select>
)}
</Field>
<Field label="Letzte vier IBAN-Stellen">
{(id) => (
<Input
id={id}
inputMode="numeric"
maxLength={4}
pattern="\d{4}"
value={iban}
placeholder="4711"
onChange={(ereignis) => setIban(ereignis.target.value.replace(/\D/g, ""))}
/>
)}
</Field>
<Field label="Eröffnungssaldo" hint="Saldo zum Stichtag.">
{(id) => <MoneyInput id={id} value={saldo} onValueChange={setSaldo} />}
</Field>
<Field label="Stichtag" required>
{(id) => (
<Input
id={id}
type="date"
required
value={stichtag}
onChange={(ereignis) => setStichtag(ereignis.target.value)}
/>
)}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
<div className="flex items-end pb-2">
<Checkbox
label="Aktiv"
hint="Inaktive Konten erscheinen nicht mehr zur Auswahl."
checked={aktiv}
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
/>
</div>
</form>
</Modal>
);
}
/* --- Kategorien ----------------------------------------------------------- */
function CategoriesSection() {
const { data: baum = [], isLoading } = useCategoryTree();
const [bearbeiten, setBearbeiten] = useState<
{ kategorie: Category | null; parent: CategoryTree | null; kind: EntryKind } | undefined
>(undefined);
const [loeschen, setLoeschen] = useState<Category | null>(null);
const entfernen = useDeleteCategory();
if (isLoading) return <Skeleton className="h-64 w-full" />;
return (
<section className="space-y-4">
{(["expense", "income"] as EntryKind[]).map((richtung) => {
const gruppen = baum.filter((oberkategorie) => oberkategorie.kind === richtung);
return (
<div key={richtung}>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">
{richtung === "expense" ? "Ausgaben" : "Einkünfte"}
</h2>
<Button
size="sm"
onClick={() => setBearbeiten({ kategorie: null, parent: null, kind: richtung })}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Oberkategorie
</Button>
</div>
<ul className="space-y-2">
{gruppen.map((oberkategorie) => (
<li key={oberkategorie.id} className="card p-3">
<div className="group flex items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: oberkategorie.color }}
/>
<span className="flex-1 truncate font-medium text-ink">
{oberkategorie.name}
</span>
{oberkategorie.is_fixed_cost && <Badge tone="accent">Fixkosten</Badge>}
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
aria-label={`Unterkategorie zu ${oberkategorie.name} anlegen`}
onClick={() =>
setBearbeiten({
kategorie: null,
parent: oberkategorie,
kind: richtung,
})
}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${oberkategorie.name} bearbeiten`}
onClick={() =>
setBearbeiten({ kategorie: oberkategorie, parent: null, kind: richtung })
}
>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${oberkategorie.name} löschen`}
onClick={() => setLoeschen(oberkategorie)}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{oberkategorie.children.length > 0 && (
<ul className="mt-2 space-y-1 border-l border-line pl-4">
{oberkategorie.children.map((unterkategorie) => (
<li
key={unterkategorie.id}
className="group flex items-center gap-2 py-0.5 text-sm"
>
<span className="flex-1 truncate text-muted">{unterkategorie.name}</span>
{unterkategorie.is_fixed_cost && <Badge tone="accent">Fix</Badge>}
<div className="flex gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
aria-label={`${unterkategorie.name} bearbeiten`}
onClick={() =>
setBearbeiten({
kategorie: unterkategorie,
parent: oberkategorie,
kind: richtung,
})
}
>
<Pencil aria-hidden className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`${unterkategorie.name} löschen`}
onClick={() => setLoeschen(unterkategorie)}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
})}
{bearbeiten && (
<CategoryDialog
category={bearbeiten.kategorie}
parent={bearbeiten.parent}
kind={bearbeiten.kind}
open
onClose={() => setBearbeiten(undefined)}
/>
)}
<ConfirmDialog
open={loeschen !== null}
title="Kategorie löschen"
description={`${loeschen?.name}“ wird gelöscht. Das geht nur, solange nichts darauf verweist andernfalls archiviere sie.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</section>
);
}
function CategoryDialog({
category,
parent,
kind,
open,
onClose,
}: {
category: Category | null;
parent: CategoryTree | null;
kind: EntryKind;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveCategory();
const [name, setName] = useState(category?.name ?? "");
const [farbe, setFarbe] = useState(category?.color ?? parent?.color ?? "#64748b");
const [fixkosten, setFixkosten] = useState(category?.is_fixed_cost ?? parent?.is_fixed_cost ?? false);
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: category?.id,
daten: category
? { name: name.trim(), color: farbe, is_fixed_cost: fixkosten }
: {
name: name.trim(),
kind,
parent_id: parent?.id ?? null,
color: farbe,
is_fixed_cost: fixkosten,
},
},
{ onSuccess: onClose },
);
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={category ? "Kategorie bearbeiten" : parent ? "Neue Unterkategorie" : "Neue Oberkategorie"}
description={parent ? `Unter „${parent.name}` : undefined}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!name.trim()}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Name" required>
{(id) => (
<Input
id={id}
value={name}
required
autoFocus
onChange={(ereignis) => setName(ereignis.target.value)}
/>
)}
</Field>
<Field label="Farbe">
{(id) => (
<Input
id={id}
type="color"
value={farbe}
className="h-10 p-1"
onChange={(ereignis) => setFarbe(ereignis.target.value)}
/>
)}
</Field>
<Checkbox
label="Zählt zu den Fixkosten"
hint="Fließt in die Kennzahl „verfügbar nach Fixkosten“ ein."
checked={fixkosten}
onChange={(ereignis) => setFixkosten(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
/* --- Konto und Darstellung ------------------------------------------------ */
function UserSection() {
const { data: benutzer } = useMe();
const theme = useThemeStore((zustand) => zustand.theme);
const setTheme = useThemeStore((zustand) => zustand.setTheme);
const wechseln = useChangePassword();
const [aktuell, setAktuell] = useState("");
const [neu, setNeu] = useState("");
const [wiederholung, setWiederholung] = useState("");
const absendbar = Boolean(aktuell) && neu.length >= 10 && neu === wiederholung;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
wechseln.mutate({ current_password: aktuell, new_password: neu });
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<section className="card p-4">
<h2 className="text-sm font-semibold text-ink">Darstellung</h2>
<p className="mt-0.5 text-xs text-muted">
Die Wahl wird im Browser gespeichert und gilt für dieses Gerät.
</p>
<div className="mt-3 flex gap-2">
{(["dark", "light"] as const).map((wert) => (
<Button
key={wert}
variant={theme === wert ? "primary" : "secondary"}
onClick={() => setTheme(wert)}
>
{wert === "dark" ? "Dunkel" : "Hell"}
</Button>
))}
</div>
</section>
<section className="card p-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<KeyRound aria-hidden className="h-4 w-4" />
Passwort ändern
</h2>
{benutzer && (
<p className="mt-0.5 text-xs text-muted">
Angemeldet als {benutzer.username}. Ein Wechsel beendet alle Sitzungen.
</p>
)}
<form onSubmit={absenden} className="mt-3 space-y-3">
<Field label="Aktuelles Passwort" required>
{(id) => (
<Input
id={id}
type="password"
autoComplete="current-password"
value={aktuell}
onChange={(ereignis) => setAktuell(ereignis.target.value)}
/>
)}
</Field>
<Field label="Neues Passwort" required hint="Mindestens 10 Zeichen.">
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
value={neu}
onChange={(ereignis) => setNeu(ereignis.target.value)}
/>
)}
</Field>
<Field
label="Wiederholen"
required
error={wiederholung && neu !== wiederholung ? "Die Eingaben stimmen nicht überein." : null}
>
{(id) => (
<Input
id={id}
type="password"
autoComplete="new-password"
value={wiederholung}
onChange={(ereignis) => setWiederholung(ereignis.target.value)}
/>
)}
</Field>
<Button
type="submit"
variant="primary"
loading={wechseln.isPending}
disabled={!absendbar}
>
Passwort ändern
</Button>
</form>
</section>
</div>
);
}