feat: einstellbarer Monatsbeginn zum Gehaltstag
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
998d5867df
commit
0a6261fc55
@@ -1,8 +1,8 @@
|
||||
/** Einstellungen: Konten, Kategorien und das eigene Konto. */
|
||||
/** Einstellungen: Konten, Kategorien, Monatsbeginn und das eigene Konto. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||
import { CalendarRange, KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
@@ -11,6 +11,7 @@ import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feed
|
||||
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,
|
||||
@@ -21,7 +22,14 @@ import {
|
||||
useSaveAccount,
|
||||
useSaveCategory,
|
||||
} from "@/hooks/useEntities";
|
||||
import { formatDate, formatMoney, todayIso } from "@/lib/format";
|
||||
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";
|
||||
|
||||
@@ -32,11 +40,12 @@ const KONTOARTEN: Record<AccountType, string> = {
|
||||
cash: "Bargeld",
|
||||
};
|
||||
|
||||
type Reiter = "accounts" | "categories" | "notifications" | "account";
|
||||
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" },
|
||||
];
|
||||
@@ -69,12 +78,107 @@ export function SettingsPage() {
|
||||
|
||||
{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() {
|
||||
|
||||
Reference in New Issue
Block a user