feat(frontend): Oberfläche mit RRULE-Editor und Logo-Auswahl
- Vite, React 18, TypeScript und Tailwind mit dunklem Standard-Theme über CSS-Variablen, heller Modus umschaltbar und in localStorage gemerkt - Anmeldung, erzwungener Passwortwechsel, Layout mit Seitenleiste - API-Client mit stiller Token-Erneuerung, TanStack Query mit Fehler-Toasts - Seiten Recurrences (inkl. Detail-Drawer), Transactions, Merchants, Settings - Geführter RRULE-Editor mit Vorlagen, Expertenmodus, deutschem Klartext und Live-Vorschau der nächsten Termine vom preview-Endpunkt - Firmen-Kachelgrid mit Logo, Markenfarbe und Logo-Auswahldialog samt Upload - Beträge durchgängig in de-DE, Eingabe in beiden Schreibweisen - 49 Tests, tsc --noEmit und eslint sauber, Produktionsbundle baut Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user