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
+35
View File
@@ -0,0 +1,35 @@
/** Anwendungseinstellungen derzeit der Monatsbeginn (Gehaltstag). */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { DEFAULT_MONTH_START_DAY } from "@/lib/period";
import { toast } from "@/store/toast";
import type { AppSettings, AppSettingsInput } from "@/types/api";
export function useAppSettings() {
return useQuery({
queryKey: ["settings"],
queryFn: () => api.get<AppSettings>("/settings"),
// Der Monatsbeginn ändert sich selten und bestimmt jede Monatsansicht.
staleTime: 5 * 60 * 1000,
});
}
/** Der eingestellte Monatsbeginn; bis die Abfrage lädt, der Monatserste. */
export function useMonthStartDay(): number {
return useAppSettings().data?.month_start_day ?? DEFAULT_MONTH_START_DAY;
}
export function useSaveAppSettings() {
const client = useQueryClient();
return useMutation({
mutationFn: (daten: AppSettingsInput) => api.put<AppSettings>("/settings", daten),
onSuccess: (einstellungen) => {
client.setQueryData(["settings"], einstellungen);
// Jede Monatsansicht rechnet ab jetzt anders.
void client.invalidateQueries({ queryKey: ["reports"] });
toast.success("Monatsbeginn gespeichert.");
},
});
}
@@ -0,0 +1,85 @@
/** Die Monatsansichten müssen den Abrechnungsmonat abfragen, nicht den Kalendermonat. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { mockFetch, renderWithProviders } from "@/test/utils";
function Seite() {
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
return (
<div>
<p data-testid="monat">{monat}</p>
<p data-testid="beschriftung">{beschriftung}</p>
<button onClick={zurueck}>zurück</button>
<button onClick={vor}>vor</button>
<button onClick={heute}>heute</button>
</div>
);
}
function einstellung(tag: number) {
mockFetch({
"/api/settings": {
month_start_day: tag,
current_month: "2026-09-01",
current_period_start: "2026-09-01",
current_period_end: "2026-09-30",
},
});
}
describe("Monatsnavigation", () => {
beforeEach(() => {
// Fester Stichtag: der 10. liegt vor einem Gehaltstag am 25.
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 10, 12));
});
afterEach(() => {
vi.useRealTimers();
});
it("zeigt ohne abweichende Einstellung den Kalendermonat", async () => {
einstellung(1);
renderWithProviders(<Seite />);
await waitFor(() => {
expect(screen.getByTestId("beschriftung")).toHaveTextContent("September 2026");
});
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
});
it("springt bei Gehaltstag 25 auf den laufenden Zeitraum", async () => {
einstellung(25);
renderWithProviders(<Seite />);
// Der 10.09. gehört noch zum Zeitraum, der am 25.08. begonnen hat.
await waitFor(() => {
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
});
expect(screen.getByTestId("beschriftung")).toHaveTextContent(
"August 2026 · 25.08.2026 24.09.2026",
);
});
it("blättert monatsweise und kehrt zum laufenden Zeitraum zurück", async () => {
const nutzer = userEvent.setup();
einstellung(25);
renderWithProviders(<Seite />);
await waitFor(() => expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01"));
await nutzer.click(screen.getByText("vor"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-09-01");
await nutzer.click(screen.getByText("zurück"));
await nutzer.click(screen.getByText("zurück"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-07-01");
await nutzer.click(screen.getByText("heute"));
expect(screen.getByTestId("monat")).toHaveTextContent("2026-08-01");
});
});
+37
View File
@@ -0,0 +1,37 @@
/** Monatsauswahl der Monatsansichten, ausgerichtet am eingestellten Monatsbeginn. */
import { useState } from "react";
import { useMonthStartDay } from "@/hooks/useAppSettings";
import { addMonthsIso } from "@/lib/format";
import { periodKey, periodLabel } from "@/lib/period";
export interface MonthNavigation {
/** Bezeichner des angezeigten Abrechnungsmonats immer ein Monatserster. */
monat: string;
/** Der eingestellte Monatsbeginn. */
startDay: number;
/** „September 2026“, bei abweichendem Monatsbeginn samt Zeitraumgrenzen. */
beschriftung: string;
zurueck: () => void;
vor: () => void;
heute: () => void;
}
export function useMonthNavigation(): MonthNavigation {
const startDay = useMonthStartDay();
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
// Ohne eigene Auswahl folgt die Ansicht dem laufenden Zeitraum. Trifft die
// Einstellung später ein, rückt sie ohne Zutun auf den richtigen Monat.
const monat = gewaehlt ?? periodKey(startDay);
return {
monat,
startDay,
beschriftung: periodLabel(startDay, monat),
zurueck: () => setGewaehlt(addMonthsIso(monat, -1)),
vor: () => setGewaehlt(addMonthsIso(monat, 1)),
heute: () => setGewaehlt(null),
};
}
+1
View File
@@ -146,6 +146,7 @@ export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>) => request<T>(path, { params }),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: "POST", body, params }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
upload: <T>(path: string, formData: FormData) =>
+28
View File
@@ -0,0 +1,28 @@
/** Abrechnungsmonate dieselben Fälle prüft das Backend in Python. */
import { describe, expect, it } from "vitest";
import { periodBounds, periodKey, periodLabel } from "@/lib/period";
describe("Abrechnungsmonate", () => {
it("lässt den Monatsersten den Kalendermonat sein", () => {
expect(periodBounds(1, "2026-09-01")).toEqual({ start: "2026-09-01", end: "2026-09-30" });
expect(periodKey(1, new Date(2026, 8, 30))).toBe("2026-09-01");
expect(periodLabel(1, "2026-09-01")).toBe("September 2026");
});
it("schneidet den Zeitraum am Gehaltstag", () => {
expect(periodBounds(25, "2026-09-01")).toEqual({ start: "2026-09-25", end: "2026-10-24" });
expect(periodKey(25, new Date(2026, 8, 24))).toBe("2026-08-01");
expect(periodKey(25, new Date(2026, 8, 25))).toBe("2026-09-01");
});
it("kürzt einen Starttag jenseits der Monatslänge auf den Letzten", () => {
expect(periodBounds(31, "2026-01-01")).toEqual({ start: "2026-01-31", end: "2026-02-27" });
expect(periodBounds(31, "2026-02-01")).toEqual({ start: "2026-02-28", end: "2026-03-30" });
});
it("nennt bei abweichendem Beginn die Grenzen des Zeitraums", () => {
expect(periodLabel(25, "2026-09-01")).toBe("September 2026 · 25.09.2026 24.10.2026");
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Abrechnungsmonate im Frontend.
*
* Ein Abrechnungsmonat beginnt am eingestellten Gehaltstag und endet am Vortag
* des nächsten. Benannt wird er nach dem Monat, in dem er beginnt: Der 25.09.
* eröffnet den Zeitraum „September“, der bis zum 24.10. läuft. Liegt der
* Starttag jenseits der Monatslänge, rutscht er auf den Monatsletzten so
* bedeutet 31 verlässlich „letzter Tag des Monats“.
*
* Dieselbe Rechnung steht im Backend; hier steht sie, damit Seitenköpfe den
* Zeitraum schon vor der ersten Antwort benennen können.
*/
import { formatDate, formatMonth, toIsoDate } from "@/lib/format";
export const DEFAULT_MONTH_START_DAY = 1;
export const MIN_MONTH_START_DAY = 1;
export const MAX_MONTH_START_DAY = 31;
/** Der Gehaltstag im Monat von `jahr`/`monat`, gekürzt auf den Monatsletzten. */
function ankerTag(jahr: number, monat: number, startTag: number): Date {
const letzter = new Date(jahr, monat + 1, 0).getDate();
return new Date(jahr, monat, Math.min(startTag, letzter));
}
function ausIso(monatsErster: string): Date {
const teile = monatsErster.split("-").map(Number);
return new Date(teile[0] ?? 1970, (teile[1] ?? 1) - 1, 1);
}
/** Bezeichner des Abrechnungsmonats, in dem `datum` liegt immer ein Monatserster. */
export function periodKey(startDay: number, datum: Date = new Date()): string {
const tag = new Date(datum.getFullYear(), datum.getMonth(), datum.getDate());
const anker = ankerTag(tag.getFullYear(), tag.getMonth(), startDay);
const versatz = tag >= anker ? 0 : -1;
return toIsoDate(new Date(tag.getFullYear(), tag.getMonth() + versatz, 1));
}
/** Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `monthKey`. */
export function periodBounds(startDay: number, monthKey: string): { start: string; end: string } {
const schluessel = ausIso(monthKey);
const beginn = ankerTag(schluessel.getFullYear(), schluessel.getMonth(), startDay);
const naechster = ankerTag(schluessel.getFullYear(), schluessel.getMonth() + 1, startDay);
naechster.setDate(naechster.getDate() - 1);
return { start: toIsoDate(beginn), end: toIsoDate(naechster) };
}
/**
* Beschriftung für Seitenköpfe: „September 2026“, bei abweichendem Monatsbeginn
* ergänzt um die Grenzen des Zeitraums.
*/
export function periodLabel(startDay: number, monthKey: string): string {
const name = formatMonth(monthKey);
if (startDay === DEFAULT_MONTH_START_DAY) return name;
const { start, end } = periodBounds(startDay, monthKey);
return `${name} · ${formatDate(start)} ${formatDate(end)}`;
}
+7 -6
View File
@@ -21,12 +21,13 @@ import {
useSaveBudget,
useSaveBudgetTemplate,
} from "@/hooks/useBudgets";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useBudgetStatus } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
import { formatDate, formatMoney, formatMonth } from "@/lib/format";
import type { Budget, BudgetTemplate } from "@/types/api";
export function BudgetsPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
const [loeschen, setLoeschen] = useState<Budget | null>(null);
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
@@ -42,23 +43,23 @@ export function BudgetsPage() {
<>
<PageHeader
title="Budgets"
description={formatMonth(monat)}
description={beschriftung}
actions={
<>
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
onClick={zurueck}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
<Button size="sm" onClick={heute}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
onClick={vor}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
+4 -2
View File
@@ -90,6 +90,8 @@ function mockApi() {
if (url.includes("/api/reports/calendar")) {
return json({
month: "2026-03-01",
period_start: "2026-03-01",
period_end: "2026-03-31",
days: maerz(),
opening_balance: "1000.00",
closing_balance: "3250.00",
@@ -115,11 +117,11 @@ describe("Cashflow-Kalender", () => {
mockApi();
});
it("zeigt die Kennzahlen des Monats", async () => {
it("zeigt die Kennzahlen des Zeitraums", async () => {
renderWithProviders(<CalendarPage />);
await waitFor(() => {
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
expect(screen.getByText("Stand zu Beginn")).toBeInTheDocument();
});
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
+25 -16
View File
@@ -1,8 +1,9 @@
/**
* Cashflow-Kalender.
*
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
* Kontostands über den Monat.
* Tagesraster des Abrechnungsmonats mit den Fälligkeiten je Tag; darunter der
* Verlauf des Kontostands über den Zeitraum. Beginnt der Monat am Gehaltstag,
* reicht das Raster über zwei Kalendermonate.
*/
import { useState } from "react";
@@ -26,21 +27,23 @@ import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useConfirmOccurrence, useMerchants, useSkipOccurrence } from "@/hooks/useEntities";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useCalendar } from "@/hooks/useReports";
import { cn } from "@/lib/cn";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, todayIso, toNumber } from "@/lib/format";
import { formatDate, formatMoney, todayIso, toNumber } from "@/lib/format";
import type { CalendarDay, CalendarEntry, Merchant } from "@/types/api";
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
/** Führende Leerfelder, damit der Monat am richtigen Wochentag beginnt. */
function fuehrendeLeerfelder(erster: string): number {
/** Führende Leerfelder, damit der erste Tag im richtigen Wochentag steht. */
function fuehrendeLeerfelder(erster: string | undefined): number {
if (!erster) return 0;
const datum = new Date(erster);
return (datum.getDay() + 6) % 7;
}
export function CalendarPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute: aufHeute } = useMonthNavigation();
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
const { data, isLoading } = useCalendar(monat);
@@ -55,13 +58,13 @@ export function CalendarPage() {
<>
<PageHeader
title="Cashflow-Kalender"
description={formatMonth(monat)}
description={beschriftung}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, -1));
zurueck();
setGewaehlterTag(null);
}}
aria-label="Vorheriger Monat"
@@ -71,7 +74,7 @@ export function CalendarPage() {
<Button
size="sm"
onClick={() => {
setMonat(firstOfMonth());
aufHeute();
setGewaehlterTag(null);
}}
>
@@ -80,7 +83,7 @@ export function CalendarPage() {
<Button
size="sm"
onClick={() => {
setMonat((alt) => addMonthsIso(alt, 1));
vor();
setGewaehlterTag(null);
}}
aria-label="Nächster Monat"
@@ -99,11 +102,16 @@ export function CalendarPage() {
) : (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Stand zu Monatsbeginn" value={formatMoney(data.opening_balance)} />
<StatTile
label="Stand zum Monatsende"
label="Stand zu Beginn"
value={formatMoney(data.opening_balance)}
hint={formatDate(data.period_start)}
/>
<StatTile
label="Stand am Ende"
value={formatMoney(data.closing_balance)}
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
hint={formatDate(data.period_end)}
/>
<StatTile
label="Tiefster Stand"
@@ -123,7 +131,7 @@ export function CalendarPage() {
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
{Array.from({ length: fuehrendeLeerfelder(tage[0]?.date) }, (_, index) => (
<div key={`leer-${index}`} aria-hidden />
))}
@@ -150,13 +158,14 @@ export function CalendarPage() {
<ChartCard
title="Verlauf des Kontostands"
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
description="Fortgeschrieben aus dem Stand zu Beginn des Zeitraums."
>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={tage.map((tag) => ({
tag: new Date(tag.date).getDate(),
datum: tag.date,
stand: toNumber(tag.running_balance),
}))}
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
@@ -182,10 +191,10 @@ export function CalendarPage() {
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
<Tooltip
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
content={({ active, payload, label }) =>
content={({ active, payload }) =>
active && payload?.length ? (
<ChartTooltip
title={`${label}. ${formatMonth(monat)}`}
title={formatDate(payload[0]?.payload.datum)}
rows={[
{
label: "Kontostand",
+8 -8
View File
@@ -1,6 +1,5 @@
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
/** Dashboard: die Kennzahlen des Abrechnungsmonats auf einen Blick. */
import { useState } from "react";
import { Link } from "react-router-dom";
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
@@ -15,11 +14,12 @@ import { Button } from "@/components/ui/Button";
import { Badge, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { useMerchants } from "@/hooks/useEntities";
import { useMonthNavigation } from "@/hooks/useMonthNavigation";
import { useDashboard } from "@/hooks/useReports";
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
import { formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
export function DashboardPage() {
const [monat, setMonat] = useState(() => firstOfMonth());
const { monat, beschriftung, zurueck, vor, heute } = useMonthNavigation();
const { data, isLoading } = useDashboard(monat);
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
@@ -30,22 +30,22 @@ export function DashboardPage() {
<>
<PageHeader
title="Dashboard"
description={formatMonth(monat)}
description={beschriftung}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
onClick={zurueck}
aria-label="Vorheriger Monat"
>
<ChevronLeft aria-hidden className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
<Button size="sm" onClick={heute}>
Heute
</Button>
<Button
size="sm"
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
onClick={vor}
aria-label="Nächster Monat"
>
<ChevronRight aria-hidden className="h-4 w-4" />
+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"
/>
}
+83
View File
@@ -0,0 +1,83 @@
/** Test des Abschnitts „Monatsbeginn“ in den Einstellungen. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPage } from "@/pages/SettingsPage";
import { renderWithProviders } from "@/test/utils";
function mockApi() {
const anfragen: { url: string; method: string; body: unknown }[] = [];
let monatsbeginn = 1;
const json = (daten: unknown) =>
new Response(JSON.stringify(daten), {
status: 200,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const body = typeof init?.body === "string" ? JSON.parse(init.body) : null;
anfragen.push({ url, method: init?.method ?? "GET", body });
if (url.includes("/api/settings")) {
if (init?.method === "PUT") monatsbeginn = body.month_start_day;
return json({
month_start_day: monatsbeginn,
current_month: "2026-09-01",
current_period_start: "2026-09-01",
current_period_end: "2026-09-30",
});
}
if (url.includes("/api/accounts")) return json([]);
return json({});
}),
);
return anfragen;
}
describe("Einstellungen: Monatsbeginn", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 10, 12));
});
afterEach(() => {
vi.useRealTimers();
});
it("zeigt die Zeiträume zur Auswahl und speichert den Gehaltstag", async () => {
const nutzer = userEvent.setup();
const anfragen = mockApi();
renderWithProviders(<SettingsPage />);
await nutzer.click(screen.getByRole("tab", { name: "Monatsbeginn" }));
const auswahl = await screen.findByLabelText("Monatsbeginn");
expect(auswahl).toHaveValue("1");
// Vorgabe: der Zeitraum deckt sich mit dem Kalendermonat.
expect(screen.getByText("01.09.2026 30.09.2026")).toBeInTheDocument();
await nutzer.selectOptions(auswahl, "25");
// Die Vorschau rechnet sofort, noch vor dem Speichern.
expect(screen.getByText("25.08.2026 24.09.2026")).toBeInTheDocument();
expect(screen.getByText("25.09.2026 24.10.2026")).toBeInTheDocument();
await nutzer.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => {
expect(
anfragen.some(
(anfrage) => anfrage.method === "PUT" && anfrage.url.includes("/api/settings"),
),
).toBe(true);
});
const gespeichert = anfragen.find((anfrage) => anfrage.method === "PUT");
expect(gespeichert?.body).toEqual({ month_start_day: 25 });
});
});
+108 -4
View File
@@ -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() {
+20
View File
@@ -368,7 +368,10 @@ export interface SavingsGoalInput {
}
export interface MonthReport {
/** Bezeichner des Abrechnungsmonats immer ein Monatserster. */
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
planned: Totals;
actual: Totals;
previous_planned: Totals;
@@ -385,6 +388,8 @@ export interface MonthReport {
export interface ForecastMonth {
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
income: Money;
expenses: Money;
balance: Money;
@@ -478,6 +483,8 @@ export interface CalendarDay {
export interface CalendarMonth {
month: IsoDate;
period_start: IsoDate;
period_end: IsoDate;
days: CalendarDay[];
opening_balance: Money;
closing_balance: Money;
@@ -520,6 +527,7 @@ export interface SavingsGoalProgress {
}
export interface Dashboard {
month_start_day: number;
month: MonthReport;
total_balance: Money;
forecast: ForecastMonth[];
@@ -601,3 +609,15 @@ export interface NotificationRunResult {
skipped: number;
failed: number;
}
export interface AppSettings {
/** Tag, an dem der Abrechnungsmonat beginnt der Gehaltstag. */
month_start_day: number;
current_month: IsoDate;
current_period_start: IsoDate;
current_period_end: IsoDate;
}
export interface AppSettingsInput {
month_start_day: number;
}