feat: einstellbarer Monatsbeginn zum Gehaltstag
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m26s
Images bauen / build (frontend) (push) Successful in 4m4s
CI / frontend (push) Successful in 6m32s

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:
Jonas Menzel
2026-09-10 09:52:51 +02:00
co-authored by Claude Opus 5
parent 998d5867df
commit 0a6261fc55
31 changed files with 1153 additions and 119 deletions
+15 -11
View File
@@ -11,6 +11,7 @@ import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Select } from "@/components/ui/Field";
import { useMonthStartDay } from "@/hooks/useAppSettings";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import {
exportUrl,
@@ -19,7 +20,8 @@ import {
useYearComparison,
} from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
import { formatDate, formatMoney, toNumber } from "@/lib/format";
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
import { describeRRule } from "@/lib/rrule";
import type { Subscription } from "@/types/api";
@@ -230,13 +232,13 @@ function SubscriptionRow({
function CategoriesTab() {
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
const startDay = useMonthStartDay();
const heute = new Date();
const von =
zeitraum === "month" ? firstOfMonth() : `${heute.getFullYear()}-01-01`;
const bis =
zeitraum === "month"
? new Date(heute.getFullYear(), heute.getMonth() + 1, 0).toISOString().slice(0, 10)
: `${heute.getFullYear()}-12-31`;
const laufend = periodKey(startDay);
const grenzen = periodBounds(startDay, laufend);
const von = zeitraum === "month" ? grenzen.start : `${heute.getFullYear()}-01-01`;
const bis = zeitraum === "month" ? grenzen.end : `${heute.getFullYear()}-12-31`;
const { data, isLoading } = useCategoryReport(von, bis);
@@ -247,7 +249,7 @@ function CategoriesTab() {
<div className="flex gap-1">
{(
[
["month", formatMonth(von)],
["month", periodLabel(startDay, laufend)],
["year", String(heute.getFullYear())],
] as const
).map(([wert, beschriftung]) => (
@@ -300,7 +302,9 @@ function YearTab() {
/* --- Export --------------------------------------------------------------- */
function ExportTab() {
const [monat, setMonat] = useState(() => firstOfMonth());
const startDay = useMonthStartDay();
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const monat = gewaehlt ?? periodKey(startDay);
const jahr = new Date().getFullYear();
return (
@@ -325,13 +329,13 @@ function ExportTab() {
<ExportCard
title="Monatsauswertung"
description="Alle Bewegungen des Monats samt Kennzahlen."
description={`Alle Bewegungen samt Kennzahlen: ${periodLabel(startDay, monat)}.`}
extra={
<input
type="month"
aria-label="Monat der Auswertung"
value={monat.slice(0, 7)}
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
onChange={(ereignis) => setGewaehlt(`${ereignis.target.value}-01`)}
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
/>
}