/** 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 = { 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("accounts"); return ( <>
{REITER.map((eintrag) => ( ))}
{reiter === "accounts" && } {reiter === "categories" && } {reiter === "period" && } {reiter === "notifications" && } {reiter === "account" && } ); } /* --- 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(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 ; return (

Erster Tag des Monats

Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Dashboard, Kalender, Budgets, Vorschau und Export richten sich nach diesem Tag.

{(id) => ( )}

Vorschau

So werden die Zeiträume mit dem gewählten Tag geschnitten.

{[laufend, addMonthsIso(laufend, 1)].map((schluessel, index) => { const grenzen = periodBounds(gewaehlt, schluessel); return (
{formatMonth(schluessel)} {index === 0 && laufend}
{formatDate(grenzen.start)} – {formatDate(grenzen.end)}
); })}
); } /* --- Konten --------------------------------------------------------------- */ function AccountsSection() { const { data: konten = [], isLoading } = useAccounts(); const [bearbeiten, setBearbeiten] = useState(undefined); const [loeschen, setLoeschen] = useState(null); const entfernen = useDeleteAccount(); return (
{isLoading ? ( ) : konten.length === 0 ? ( setBearbeiten(null)}> Erstes Konto anlegen } /> ) : (
    {konten.map((konto) => ( setBearbeiten(konto)} onDelete={() => setLoeschen(konto)} /> ))}
)} setBearbeiten(undefined)} /> setLoeschen(null)} onConfirm={() => { if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) }); }} />
); } function AccountRow({ account, onEdit, onDelete, }: { account: Account; onEdit: () => void; onDelete: () => void; }) { const { data: saldo } = useAccountBalance(account.id); return (
  • {account.name} {KONTOARTEN[account.type]} {!account.is_active && Inaktiv}

    {account.iban_last4 ? `IBAN …${account.iban_last4} · ` : ""} Eröffnet {formatDate(account.opening_balance_date)}

    Saldo heute

    {saldo ? formatMoney(saldo.balance) : "…"}

  • ); } function AccountDialog({ account, open, onClose, }: { account: Account | null | undefined; open: boolean; onClose: () => void; }) { const speichern = useSaveAccount(); const [name, setName] = useState(""); const [art, setArt] = useState("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(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 ( } >
    {(id) => ( setName(ereignis.target.value)} /> )} {(id) => ( )} {(id) => ( setIban(ereignis.target.value.replace(/\D/g, ""))} /> )} {(id) => } {(id) => ( setStichtag(ereignis.target.value)} /> )} {(id) => ( setFarbe(ereignis.target.value)} /> )}
    setAktiv(ereignis.target.checked)} />
    ); } /* --- 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(null); const entfernen = useDeleteCategory(); if (isLoading) return ; return (
    {(["expense", "income"] as EntryKind[]).map((richtung) => { const gruppen = baum.filter((oberkategorie) => oberkategorie.kind === richtung); return (

    {richtung === "expense" ? "Ausgaben" : "Einkünfte"}

      {gruppen.map((oberkategorie) => (
    • {oberkategorie.name} {oberkategorie.is_fixed_cost && Fixkosten}
      {oberkategorie.children.length > 0 && (
        {oberkategorie.children.map((unterkategorie) => (
      • {unterkategorie.name} {unterkategorie.is_fixed_cost && Fix}
      • ))}
      )}
    • ))}
    ); })} {bearbeiten && ( setBearbeiten(undefined)} /> )} setLoeschen(null)} onConfirm={() => { if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) }); }} />
    ); } 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 ( } >
    {(id) => ( setName(ereignis.target.value)} /> )} {(id) => ( setFarbe(ereignis.target.value)} /> )} setFixkosten(ereignis.target.checked)} />
    ); } /* --- 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 (

    Darstellung

    Die Wahl wird im Browser gespeichert und gilt für dieses Gerät.

    {(["dark", "light"] as const).map((wert) => ( ))}

    Passwort ändern

    {benutzer && (

    Angemeldet als {benutzer.username}. Ein Wechsel beendet alle Sitzungen.

    )}
    {(id) => ( setAktuell(ereignis.target.value)} /> )} {(id) => ( setNeu(ereignis.target.value)} /> )} {(id) => ( setWiederholung(ereignis.target.value)} /> )}
    ); }