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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user