Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats, in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint. Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die Kategorienauswertung, der Monatsexport und die Benachrichtigung über überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten. Die Einstellung liegt in einer einzeiligen Tabelle hinter GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich period_start und period_end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
795 lines
26 KiB
TypeScript
795 lines
26 KiB
TypeScript
/** Einstellungen: Konten, Kategorien, Monatsbeginn und das eigene Konto. */
|
||
|
||
import { type FormEvent, useState } from "react";
|
||
|
||
import { CalendarRange, KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||
|
||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||
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 { useAppSettings, useSaveAppSettings } from "@/hooks/useAppSettings";
|
||
import { useChangePassword, useMe } from "@/hooks/useAuth";
|
||
import {
|
||
useAccountBalance,
|
||
useAccounts,
|
||
useCategoryTree,
|
||
useDeleteAccount,
|
||
useDeleteCategory,
|
||
useSaveAccount,
|
||
useSaveCategory,
|
||
} from "@/hooks/useEntities";
|
||
import { addMonthsIso, formatDate, formatMoney, formatMonth, todayIso } from "@/lib/format";
|
||
import {
|
||
DEFAULT_MONTH_START_DAY,
|
||
MAX_MONTH_START_DAY,
|
||
MIN_MONTH_START_DAY,
|
||
periodBounds,
|
||
periodKey,
|
||
} from "@/lib/period";
|
||
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" | "period" | "notifications" | "account";
|
||
|
||
const REITER: { id: Reiter; label: string }[] = [
|
||
{ id: "accounts", label: "Konten" },
|
||
{ id: "categories", label: "Kategorien" },
|
||
{ id: "period", label: "Monatsbeginn" },
|
||
{ id: "notifications", label: "Benachrichtigungen" },
|
||
{ 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 === "period" && <PeriodSection />}
|
||
{reiter === "notifications" && <NotificationSettings />}
|
||
{reiter === "account" && <UserSection />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* --- Monatsbeginn --------------------------------------------------------- */
|
||
|
||
/** Auswahl 1 bis 31; der 31. meint verlässlich den letzten Tag des Monats. */
|
||
const STARTTAGE = Array.from(
|
||
{ length: MAX_MONTH_START_DAY - MIN_MONTH_START_DAY + 1 },
|
||
(_, index) => MIN_MONTH_START_DAY + index,
|
||
);
|
||
|
||
function PeriodSection() {
|
||
const { data, isLoading } = useAppSettings();
|
||
const speichern = useSaveAppSettings();
|
||
const [entwurf, setEntwurf] = useState<number | null>(null);
|
||
|
||
const gespeichert = data?.month_start_day ?? DEFAULT_MONTH_START_DAY;
|
||
const gewaehlt = entwurf ?? gespeichert;
|
||
const geaendert = data !== undefined && gewaehlt !== gespeichert;
|
||
|
||
const laufend = periodKey(gewaehlt);
|
||
|
||
function absenden(ereignis: FormEvent) {
|
||
ereignis.preventDefault();
|
||
if (!geaendert) return;
|
||
speichern.mutate({ month_start_day: gewaehlt }, { onSuccess: () => setEntwurf(null) });
|
||
}
|
||
|
||
if (isLoading) return <Skeleton className="h-64 w-full" />;
|
||
|
||
return (
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<section className="card p-4">
|
||
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||
<CalendarRange aria-hidden className="h-4 w-4" />
|
||
Erster Tag des Monats
|
||
</h2>
|
||
<p className="mt-0.5 text-xs text-muted">
|
||
Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Dashboard,
|
||
Kalender, Budgets, Vorschau und Export richten sich nach diesem Tag.
|
||
</p>
|
||
|
||
<form onSubmit={absenden} className="mt-3 space-y-3">
|
||
<Field
|
||
label="Monatsbeginn"
|
||
hint="Ein Tag jenseits der Monatslänge rückt auf den Monatsletzten – 31 meint also
|
||
den letzten Tag jedes Monats."
|
||
>
|
||
{(id) => (
|
||
<Select
|
||
id={id}
|
||
className="w-40"
|
||
value={gewaehlt}
|
||
onChange={(ereignis) => setEntwurf(Number(ereignis.target.value))}
|
||
>
|
||
{STARTTAGE.map((tag) => (
|
||
<option key={tag} value={tag}>
|
||
{tag}.
|
||
</option>
|
||
))}
|
||
</Select>
|
||
)}
|
||
</Field>
|
||
|
||
<Button type="submit" variant="primary" disabled={!geaendert || speichern.isPending}>
|
||
Speichern
|
||
</Button>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="card p-4">
|
||
<h2 className="text-sm font-semibold text-ink">Vorschau</h2>
|
||
<p className="mt-0.5 text-xs text-muted">
|
||
So werden die Zeiträume mit dem gewählten Tag geschnitten.
|
||
</p>
|
||
|
||
<dl className="mt-3 space-y-2 text-sm">
|
||
{[laufend, addMonthsIso(laufend, 1)].map((schluessel, index) => {
|
||
const grenzen = periodBounds(gewaehlt, schluessel);
|
||
return (
|
||
<div key={schluessel} className="flex items-baseline justify-between gap-3">
|
||
<dt className="text-muted">
|
||
{formatMonth(schluessel)}
|
||
{index === 0 && <Badge className="ml-2">laufend</Badge>}
|
||
</dt>
|
||
<dd className="tabular text-ink">
|
||
{formatDate(grenzen.start)} – {formatDate(grenzen.end)}
|
||
</dd>
|
||
</div>
|
||
);
|
||
})}
|
||
</dl>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* --- 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>
|
||
);
|
||
}
|