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
@@ -9,6 +9,17 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Einstellbarer Monatsbeginn: Wer am Gehaltstag rechnet, setzt unter
|
||||
*Einstellungen › Monatsbeginn* den Tag, 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 – der 25. September eröffnet also
|
||||
„September“ und schließt am 24. Oktober. Ein Starttag jenseits der
|
||||
Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich „letzter Tag
|
||||
des Monats“ bedeutet. Dashboard, Cashflow-Kalender, Budgets, Vorschau,
|
||||
Kategorien, Monatsexport und die Budget-Benachrichtigung rechnen mit diesem
|
||||
Zeitraum. Neue Endpunkte `GET`/`PUT /api/settings`; die Monatsauswertungen
|
||||
liefern zusätzlich `period_start` und `period_end`. Bestandsinstallationen
|
||||
bleiben beim Monatsersten.
|
||||
- `python -m app.scripts.reset_password` setzt das Passwort eines Benutzers oder
|
||||
legt ihn an. Nötig, weil die Erstanlage beim Start nur bei leerer
|
||||
Benutzertabelle greift und ein nachträglich geändertes
|
||||
|
||||
@@ -26,6 +26,8 @@ echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
|
||||
- Ratenzahlungen mit Restschuld- und Restratenberechnung.
|
||||
- Rücklagenbildung für nicht-monatliche Posten.
|
||||
- Firmenlogos und Markenfarben, lokal zwischengespeichert (siehe unten).
|
||||
- Einstellbarer Monatsbeginn: Der Monat startet wahlweise am Ersten oder am
|
||||
Gehaltstag; alle Monatsansichten folgen diesem Zeitraum (siehe unten).
|
||||
- Auswertungen: Monatsübersicht, Cashflow-Kalender, 12-Monats-Forecast,
|
||||
Kategorien, Abo-Übersicht, Jahresvergleich, Budgets, Sparziele.
|
||||
- Benachrichtigungen per SMTP und Apprise.
|
||||
@@ -113,6 +115,24 @@ Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||
|
||||
Kürzel greifen nur, solange kein Eingabefeld den Fokus hat.
|
||||
|
||||
### Monatsbeginn
|
||||
|
||||
Standardmäßig ist ein Monat der Kalendermonat. Wer nach dem Gehaltseingang
|
||||
plant, stellt unter *Einstellungen › Monatsbeginn* den Tag ein, ab dem ein neuer
|
||||
Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten
|
||||
und heißt nach dem Monat, in dem er beginnt: Mit dem 25. als Beginn umfasst
|
||||
„September 2026“ die Tage vom 25.09. bis zum 24.10.
|
||||
|
||||
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – die 31
|
||||
bedeutet also verlässlich „letzter Tag des Monats“, auch im Februar.
|
||||
|
||||
Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die
|
||||
Kategorienauswertung, der Monatsexport und die Benachrichtigung über
|
||||
überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben dabei je
|
||||
Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt
|
||||
verschiebt sich. Die Einstellung gilt für die gesamte Installation und wirkt
|
||||
sofort, ohne die Daten anzufassen.
|
||||
|
||||
## Barrierefreiheit
|
||||
|
||||
Die Farbtoken sind gegen die WCAG-Kontraste geprüft – in beiden Modi erreicht
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""app setting
|
||||
|
||||
Revision ID: 5f2a91c0d7e4
|
||||
Revises: 07d8d62011b3
|
||||
Create Date: 2026-09-10 09:00:00.000000+02:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '5f2a91c0d7e4'
|
||||
down_revision: str | None = '07d8d62011b3'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('app_setting',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('month_start_day', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint('id = 1', name=op.f('ck_app_setting_single_row')),
|
||||
sa.CheckConstraint('month_start_day BETWEEN 1 AND 31', name=op.f('ck_app_setting_month_start_day_range')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_app_setting'))
|
||||
)
|
||||
# Bestandsinstallationen rechnen bisher ab dem Monatsersten – dabei bleibt es,
|
||||
# bis der Gehaltstag in den Einstellungen geändert wird.
|
||||
op.execute(
|
||||
"INSERT INTO app_setting (id, month_start_day, created_at, updated_at) "
|
||||
"VALUES (1, 1, now(), now())"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('app_setting')
|
||||
@@ -11,6 +11,7 @@ from app.core.security import decode_token
|
||||
from app.db.session import get_session
|
||||
from app.models import AppUser
|
||||
from app.services.auth import get_user
|
||||
from app.services.settings import month_start_day
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
@@ -53,3 +54,11 @@ async def get_active_user(user: CurrentUser) -> AppUser:
|
||||
|
||||
|
||||
ActiveUser = Annotated[AppUser, Depends(get_active_user)]
|
||||
|
||||
|
||||
async def get_month_start_day(session: DbSession) -> int:
|
||||
"""Der eingestellte Monatsbeginn – jede monatsbezogene Auswertung richtet sich danach."""
|
||||
return await month_start_day(session)
|
||||
|
||||
|
||||
MonthStartDay = Annotated[int, Depends(get_month_start_day)]
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.api.routes import (
|
||||
recurrences,
|
||||
reports,
|
||||
savings_goals,
|
||||
settings,
|
||||
system,
|
||||
transactions,
|
||||
)
|
||||
@@ -47,5 +48,6 @@ protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
protected.include_router(export.router)
|
||||
protected.include_router(notifications.router)
|
||||
protected.include_router(settings.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import Literal
|
||||
from fastapi import APIRouter, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.api.deps import DbSession, MonthStartDay
|
||||
from app.core.clock import today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import Account, Merchant, Transaction
|
||||
from app.models.enums import EntryKind
|
||||
@@ -15,9 +15,11 @@ from app.schemas.common import ErrorResponse
|
||||
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
|
||||
from app.services.reports import (
|
||||
category_names,
|
||||
current_period,
|
||||
flow_rows,
|
||||
flows,
|
||||
month_report,
|
||||
period_of,
|
||||
recurrence_rows,
|
||||
reserve_total,
|
||||
)
|
||||
@@ -156,15 +158,16 @@ async def export_recurrences(
|
||||
)
|
||||
async def export_month(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
fmt: ExportFormat = Query(default="xlsx", alias="format"),
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> Response:
|
||||
monat = month_start(month or today())
|
||||
zeitraum = period_of(month, start_day) if month else current_period(start_day)
|
||||
namen = await category_names(session)
|
||||
|
||||
bewegungen = await flows(session, monat, month_end(monat))
|
||||
bewegungen = await flows(session, zeitraum.start, zeitraum.end)
|
||||
zeilen = flow_rows(bewegungen, namen)
|
||||
bericht = await month_report(session, monat)
|
||||
bericht = await month_report(session, zeitraum.key, start_day)
|
||||
|
||||
kennzahlen: list[dict[str, object]] = [
|
||||
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
||||
@@ -175,11 +178,11 @@ async def export_month(
|
||||
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
|
||||
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
|
||||
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
|
||||
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, monat)},
|
||||
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)},
|
||||
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
|
||||
]
|
||||
|
||||
kennung = monat.strftime("%Y-%m")
|
||||
kennung = zeitraum.key.strftime("%Y-%m")
|
||||
if fmt == "csv":
|
||||
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
||||
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
||||
|
||||
@@ -6,8 +6,8 @@ from decimal import Decimal
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.api.deps import DbSession, MonthStartDay
|
||||
from app.core.clock import today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import SavingsGoal
|
||||
from app.models.enums import EntryKind
|
||||
@@ -43,10 +43,12 @@ from app.services.reports import (
|
||||
budget_status,
|
||||
calendar_month,
|
||||
category_breakdown,
|
||||
current_period,
|
||||
flows,
|
||||
forecast,
|
||||
month_report,
|
||||
months_between,
|
||||
period_of,
|
||||
required_monthly_rate,
|
||||
subscriptions,
|
||||
year_comparison,
|
||||
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
|
||||
def _month(report: MonthReport) -> MonthReportOut:
|
||||
return MonthReportOut(
|
||||
month=report.month,
|
||||
period_start=report.period.start,
|
||||
period_end=report.period.end,
|
||||
planned=_totals(report.planned),
|
||||
actual=_totals(report.actual),
|
||||
previous_planned=_totals(report.previous_planned),
|
||||
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
|
||||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||||
return ForecastMonthOut(
|
||||
month=entry.month,
|
||||
period_start=entry.period_start,
|
||||
period_end=entry.period_end,
|
||||
income=entry.income,
|
||||
expenses=entry.expenses,
|
||||
balance=entry.balance,
|
||||
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
|
||||
)
|
||||
async def read_month_report(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(
|
||||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||||
default=None,
|
||||
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
|
||||
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
|
||||
),
|
||||
) -> MonthReportOut:
|
||||
return _month(await month_report(session, month or today()))
|
||||
monat = month or current_period(start_day).key
|
||||
return _month(await month_report(session, monat, start_day))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -232,10 +242,13 @@ async def read_month_report(
|
||||
)
|
||||
async def read_forecast(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||||
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
|
||||
start: date | None = Query(
|
||||
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
|
||||
),
|
||||
) -> ForecastOut:
|
||||
monate = await forecast(session, months, start)
|
||||
monate = await forecast(session, months, start, start_day)
|
||||
return ForecastOut(
|
||||
months=[_forecast_month(monat) for monat in monate],
|
||||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||||
@@ -251,13 +264,14 @@ async def read_forecast(
|
||||
)
|
||||
async def read_categories(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||||
) -> CategoryReportOut:
|
||||
heute = today()
|
||||
start = date_from or month_start(heute)
|
||||
ende = date_to or month_end(heute)
|
||||
laufend = current_period(start_day)
|
||||
start = date_from or laufend.start
|
||||
ende = date_to or laufend.end
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
@@ -327,11 +341,15 @@ async def read_year_comparison(
|
||||
)
|
||||
async def read_calendar(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> CalendarMonthOut:
|
||||
raster = await calendar_month(session, month or today())
|
||||
monat = month or current_period(start_day).key
|
||||
raster = await calendar_month(session, monat, start_day=start_day)
|
||||
return CalendarMonthOut(
|
||||
month=raster.month,
|
||||
period_start=raster.period_start,
|
||||
period_end=raster.period_end,
|
||||
days=[
|
||||
CalendarDayOut(
|
||||
date=tag.on,
|
||||
@@ -358,9 +376,11 @@ async def read_calendar(
|
||||
)
|
||||
async def read_budget_status(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> list[BudgetStatusOut]:
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
|
||||
monat = month or current_period(start_day).key
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -382,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
|
||||
)
|
||||
async def read_dashboard(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> DashboardOut:
|
||||
heute = today()
|
||||
monat = month_start(month or heute)
|
||||
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
|
||||
|
||||
bericht = await month_report(session, monat)
|
||||
vorschau = await forecast(session, 12, monat)
|
||||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
||||
bericht = await month_report(session, zeitraum.key, start_day)
|
||||
vorschau = await forecast(session, 12, zeitraum.key, start_day)
|
||||
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
|
||||
abos = await subscriptions(session)
|
||||
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
|
||||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||||
|
||||
return DashboardOut(
|
||||
month_start_day=start_day,
|
||||
month=_month(bericht),
|
||||
total_balance=await total_balance(session, heute),
|
||||
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
||||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||||
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
|
||||
budgets=[
|
||||
_budget(eintrag) for eintrag in await budget_status(session, zeitraum.key, start_day)
|
||||
],
|
||||
goals=await _goals(session, heute),
|
||||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Anwendungseinstellungen."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import period_bounds, period_key, today
|
||||
from app.schemas.settings import AppSettingsIn, AppSettingsOut
|
||||
from app.services.settings import load_settings, save_settings
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _out(month_start_day: int) -> AppSettingsOut:
|
||||
schluessel = period_key(today(), month_start_day)
|
||||
beginn, ende = period_bounds(schluessel, month_start_day)
|
||||
return AppSettingsOut(
|
||||
month_start_day=month_start_day,
|
||||
current_month=schluessel,
|
||||
current_period_start=beginn,
|
||||
current_period_end=ende,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=AppSettingsOut,
|
||||
summary="Einstellungen lesen",
|
||||
description="Liefert den eingestellten Monatsbeginn und den daraus folgenden "
|
||||
"laufenden Abrechnungsmonat.",
|
||||
)
|
||||
async def read_settings(session: DbSession) -> AppSettingsOut:
|
||||
return _out((await load_settings(session)).month_start_day)
|
||||
|
||||
|
||||
@router.put(
|
||||
"",
|
||||
response_model=AppSettingsOut,
|
||||
summary="Einstellungen ändern",
|
||||
description="Ein geänderter Monatsbeginn wirkt sofort auf Dashboard, Kalender, "
|
||||
"Budgets, Vorschau und Export.",
|
||||
)
|
||||
async def update_settings(session: DbSession, payload: AppSettingsIn) -> AppSettingsOut:
|
||||
eintrag = await save_settings(session, month_start_day=payload.month_start_day)
|
||||
await session.commit()
|
||||
return _out(eintrag.month_start_day)
|
||||
@@ -44,3 +44,45 @@ def add_months(day: date, months: int) -> date:
|
||||
month = total % 12 + 1
|
||||
last_day = month_end(date(year, month, 1)).day
|
||||
return date(year, month, min(day.day, last_day))
|
||||
|
||||
|
||||
# --- Abrechnungsmonat ----------------------------------------------------------
|
||||
#
|
||||
# Wer am Gehaltstag rechnet, für den beginnt der Monat nicht am Ersten. Ein
|
||||
# Abrechnungsmonat läuft vom Gehaltstag bis zum Vortag des nächsten und trägt
|
||||
# den Namen des Monats, in dem er beginnt: Startet er am 25. September, heißt
|
||||
# er „September“ und endet am 24. Oktober.
|
||||
|
||||
MIN_MONTH_START_DAY = 1
|
||||
MAX_MONTH_START_DAY = 31
|
||||
DEFAULT_MONTH_START_DAY = 1
|
||||
|
||||
|
||||
def month_anchor(day: date, start_day: int) -> date:
|
||||
"""Der Gehaltstag im Monat von `day`.
|
||||
|
||||
Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten – so
|
||||
trifft der 31. in jedem Monat den letzten Tag.
|
||||
"""
|
||||
return day.replace(day=min(start_day, month_end(day).day))
|
||||
|
||||
|
||||
def period_start(day: date, start_day: int) -> date:
|
||||
"""Beginn des Abrechnungsmonats, in dem `day` liegt."""
|
||||
anchor = month_anchor(day, start_day)
|
||||
if day >= anchor:
|
||||
return anchor
|
||||
return month_anchor(add_months(month_start(day), -1), start_day)
|
||||
|
||||
|
||||
def period_key(day: date, start_day: int) -> date:
|
||||
"""Bezeichner des Abrechnungsmonats, in dem `day` liegt – immer ein Monatserster."""
|
||||
return month_start(period_start(day, start_day))
|
||||
|
||||
|
||||
def period_bounds(month: date, start_day: int) -> tuple[date, date]:
|
||||
"""Erster und letzter Tag des Abrechnungsmonats mit dem Bezeichner `month`."""
|
||||
schluessel = month_start(month)
|
||||
beginn = month_anchor(schluessel, start_day)
|
||||
ende = month_anchor(add_months(schluessel, 1), start_day) - timedelta(days=1)
|
||||
return beginn, ende
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""SQLAlchemy-Modelle. Import hier hält Alembics Autogenerate vollständig."""
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.core import Account, AppUser, Category, LogoAsset, Merchant, RefreshToken
|
||||
from app.models.core import (
|
||||
Account,
|
||||
AppSetting,
|
||||
AppUser,
|
||||
Category,
|
||||
LogoAsset,
|
||||
Merchant,
|
||||
RefreshToken,
|
||||
)
|
||||
from app.models.enums import (
|
||||
AccountType,
|
||||
BusinessDayShift,
|
||||
@@ -29,6 +37,7 @@ __all__ = [
|
||||
"Account",
|
||||
"AccountType",
|
||||
"AmountVersion",
|
||||
"AppSetting",
|
||||
"AppUser",
|
||||
"Base",
|
||||
"Budget",
|
||||
|
||||
@@ -129,6 +129,24 @@ class Merchant(Base, CreatedAtMixin):
|
||||
__table_args__ = (Index("ix_merchant_normalized_name", "normalized_name"),)
|
||||
|
||||
|
||||
class AppSetting(Base, TimestampMixin):
|
||||
"""Anwendungsweite Einstellungen. Es gibt genau eine Zeile mit `id = 1`.
|
||||
|
||||
`month_start_day` legt den Gehaltstag fest: Ab diesem Tag rechnet moneyfy
|
||||
einen neuen Monat. Ein Wert jenseits der Monatslänge trifft den Monatsletzten.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_setting"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
month_start_day: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("id = 1", name="single_row"),
|
||||
CheckConstraint("month_start_day BETWEEN 1 AND 31", name="month_start_day_range"),
|
||||
)
|
||||
|
||||
|
||||
class AppUser(Base, CreatedAtMixin):
|
||||
"""Single-User-Betrieb; `external_subject` ist für eine spätere OIDC-Anbindung vorgesehen."""
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ class MonthComparisonOut(ApiModel):
|
||||
class MonthReportOut(ApiModel):
|
||||
"""Monatsübersicht mit Plan-Ist-Vergleich."""
|
||||
|
||||
month: date = Field(description="Immer der Monatserste.")
|
||||
month: date = Field(
|
||||
description="Bezeichner des Abrechnungsmonats – immer der Monatserste des Startmonats."
|
||||
)
|
||||
period_start: date = Field(description="Erster Tag des Zeitraums, also der Monatsbeginn.")
|
||||
period_end: date = Field(description="Letzter Tag des Zeitraums.")
|
||||
planned: TotalsOut = Field(description="Soll aus Fälligkeiten und Buchungen.")
|
||||
actual: TotalsOut = Field(
|
||||
description="Ist aus bestätigten Fälligkeiten und allen einmaligen Buchungen."
|
||||
@@ -48,14 +52,16 @@ class MonthReportOut(ApiModel):
|
||||
|
||||
|
||||
class ForecastMonthOut(ApiModel):
|
||||
"""Ein Monat der Vorschau."""
|
||||
"""Ein Abrechnungsmonat der Vorschau."""
|
||||
|
||||
month: date
|
||||
period_start: date
|
||||
period_end: date
|
||||
income: Money
|
||||
expenses: Money
|
||||
balance: Money
|
||||
cumulative_balance: Money = Field(
|
||||
description="Prognostizierter Kontostand am Monatsende über alle Konten."
|
||||
description="Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."
|
||||
)
|
||||
|
||||
|
||||
@@ -159,8 +165,13 @@ class CalendarDayOut(ApiModel):
|
||||
|
||||
|
||||
class CalendarMonthOut(ApiModel):
|
||||
month: date
|
||||
days: list[CalendarDayOut]
|
||||
month: date = Field(description="Bezeichner des Abrechnungsmonats – immer der Monatserste.")
|
||||
period_start: date
|
||||
period_end: date
|
||||
days: list[CalendarDayOut] = Field(
|
||||
description="Alle Tage des Zeitraums; bei abweichendem Monatsbeginn über zwei "
|
||||
"Kalendermonate hinweg."
|
||||
)
|
||||
opening_balance: Money
|
||||
closing_balance: Money
|
||||
lowest_balance: Money
|
||||
@@ -210,6 +221,7 @@ class SavingsGoalProgressOut(ApiModel):
|
||||
class DashboardOut(ApiModel):
|
||||
"""Alles, was das Dashboard in einem Aufruf braucht."""
|
||||
|
||||
month_start_day: int = Field(description="Der eingestellte Monatsbeginn.")
|
||||
month: MonthReportOut
|
||||
total_balance: Money
|
||||
forecast: list[ForecastMonthOut]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Schemata der Anwendungseinstellungen."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.core.clock import MAX_MONTH_START_DAY, MIN_MONTH_START_DAY
|
||||
from app.schemas.common import ApiModel, InputModel
|
||||
|
||||
MonthStartDay = Annotated[
|
||||
int,
|
||||
Field(
|
||||
ge=MIN_MONTH_START_DAY,
|
||||
le=MAX_MONTH_START_DAY,
|
||||
description="Tag, an dem der Abrechnungsmonat beginnt – üblicherweise der Gehaltstag. "
|
||||
"Ein Wert jenseits der Monatslänge trifft den Monatsletzten.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class AppSettingsOut(ApiModel):
|
||||
"""Die gültigen Einstellungen samt aktuellem Abrechnungsmonat."""
|
||||
|
||||
month_start_day: MonthStartDay
|
||||
current_month: date = Field(
|
||||
description="Bezeichner des laufenden Abrechnungsmonats – immer ein Monatserster."
|
||||
)
|
||||
current_period_start: date
|
||||
current_period_end: date
|
||||
|
||||
|
||||
class AppSettingsIn(InputModel):
|
||||
"""Änderbare Einstellungen."""
|
||||
|
||||
month_start_day: MonthStartDay
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today, utcnow
|
||||
from app.core.clock import add_months, month_end, today, utcnow
|
||||
from app.core.config import settings
|
||||
from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule
|
||||
from app.models.enums import (
|
||||
@@ -29,7 +29,8 @@ from app.models.enums import (
|
||||
from app.services.channels import Attachment, ChannelError, Notification, get_channel
|
||||
from app.services.occurrences import load_recurrences
|
||||
from app.services.recurrence import contract_term
|
||||
from app.services.reports import budget_status, flows
|
||||
from app.services.reports import budget_status, current_period, flows
|
||||
from app.services.settings import month_start_day
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -169,14 +170,15 @@ async def collect_notice_deadlines(
|
||||
async def collect_budget_exceeded(
|
||||
session: AsyncSession, rule: NotificationRule, as_of: date
|
||||
) -> list[Event]:
|
||||
"""Überschrittene Budgets – höchstens einmal je Monat und Kategorie."""
|
||||
monat = month_start(as_of)
|
||||
"""Überschrittene Budgets – höchstens einmal je Abrechnungsmonat und Kategorie."""
|
||||
monatsbeginn = await month_start_day(session)
|
||||
zeitraum = current_period(monatsbeginn, as_of)
|
||||
|
||||
return [
|
||||
Event(
|
||||
ref_type=REF_BUDGET,
|
||||
ref_id=str(eintrag.category_id),
|
||||
dedupe_day=monat,
|
||||
dedupe_day=zeitraum.key,
|
||||
headline=eintrag.category_name,
|
||||
detail=(
|
||||
f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht "
|
||||
@@ -184,9 +186,9 @@ async def collect_budget_exceeded(
|
||||
f"{_money(abs(eintrag.remaining))} zu viel"
|
||||
),
|
||||
amount=eintrag.spent,
|
||||
on=month_end(monat),
|
||||
on=zeitraum.end,
|
||||
)
|
||||
for eintrag in await budget_status(session, monat)
|
||||
for eintrag in await budget_status(session, zeitraum.key, monatsbeginn)
|
||||
if eintrag.state == "exceeded"
|
||||
]
|
||||
|
||||
|
||||
+100
-35
@@ -14,7 +14,14 @@ from typing import Literal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import add_months, month_end, month_start, today
|
||||
from app.core.clock import (
|
||||
DEFAULT_MONTH_START_DAY,
|
||||
add_months,
|
||||
month_start,
|
||||
period_bounds,
|
||||
period_key,
|
||||
today,
|
||||
)
|
||||
from app.models import Budget, BudgetTemplate, Category, Merchant, Recurrence, Transaction
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.services.balances import total_balance
|
||||
@@ -83,6 +90,33 @@ class Totals:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Period:
|
||||
"""Ein Abrechnungsmonat.
|
||||
|
||||
Er läuft vom eingestellten Monatsbeginn – dem Gehaltstag – bis zum Vortag des
|
||||
nächsten und trägt den Namen des Monats, in dem er beginnt. Bei Monatsbeginn 1
|
||||
ist er deckungsgleich mit dem Kalendermonat.
|
||||
"""
|
||||
|
||||
key: date
|
||||
"""Bezeichner: der Erste des Monats, in dem der Zeitraum beginnt."""
|
||||
start: date
|
||||
end: date
|
||||
|
||||
|
||||
def period_of(month: date, start_day: int = DEFAULT_MONTH_START_DAY) -> Period:
|
||||
"""Der Abrechnungsmonat mit dem Bezeichner `month`."""
|
||||
schluessel = month_start(month)
|
||||
beginn, ende = period_bounds(schluessel, start_day)
|
||||
return Period(key=schluessel, start=beginn, end=ende)
|
||||
|
||||
|
||||
def current_period(start_day: int = DEFAULT_MONTH_START_DAY, as_of: date | None = None) -> Period:
|
||||
"""Der Abrechnungsmonat, in dem `as_of` liegt – Vorgabe ist heute."""
|
||||
return period_of(period_key(as_of or today(), start_day), start_day)
|
||||
|
||||
|
||||
def totals_of(
|
||||
entries: Iterable[FlowEntry],
|
||||
*,
|
||||
@@ -180,9 +214,9 @@ async def flows(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
"""Kennzahlen eines Abrechnungsmonats."""
|
||||
|
||||
month: date
|
||||
period: Period
|
||||
planned: Totals
|
||||
actual: Totals
|
||||
previous_planned: Totals
|
||||
@@ -194,6 +228,11 @@ class MonthReport:
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
|
||||
@property
|
||||
def month(self) -> date:
|
||||
"""Bezeichner des Zeitraums – immer ein Monatserster."""
|
||||
return self.period.key
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
"""Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard."""
|
||||
@@ -213,27 +252,29 @@ class MonthReport:
|
||||
|
||||
|
||||
async def _month_flows(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
session: AsyncSession, period: Period, fixed_costs: dict[int, bool]
|
||||
) -> list[FlowEntry]:
|
||||
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
|
||||
return await flows(session, period.start, period.end, fixed_costs=fixed_costs)
|
||||
|
||||
|
||||
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
async def reserve_total(session: AsyncSession, period: Period) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
if recurrence.reserve_enabled:
|
||||
total += monthly_reserve(
|
||||
recurrence, month_start(month), amount_versions=recurrence.amount_versions
|
||||
recurrence, period.start, amount_versions=recurrence.amount_versions
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
async def month_report(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fix = await fixed_cost_map(session)
|
||||
aktuell = month_start(month)
|
||||
vormonat = add_months(aktuell, -1)
|
||||
aktuell = period_of(month, start_day)
|
||||
vormonat = period_of(add_months(aktuell.key, -1), start_day)
|
||||
|
||||
bewegungen = await _month_flows(session, aktuell, fix)
|
||||
vorherige = await _month_flows(session, vormonat, fix)
|
||||
@@ -245,13 +286,13 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
# Ausgelassene Fälligkeiten fehlen in `flows` und werden separat gezählt.
|
||||
ausgelassen = sum(
|
||||
1
|
||||
for item in await due_items(session, month_start(month), month_end(month))
|
||||
for item in await due_items(session, aktuell.start, aktuell.end)
|
||||
if item.planned.status is OccurrenceStatus.SKIPPED
|
||||
)
|
||||
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
||||
|
||||
return MonthReport(
|
||||
month=aktuell,
|
||||
period=aktuell,
|
||||
planned=totals_of(bewegungen),
|
||||
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
||||
previous_planned=totals_of(vorherige),
|
||||
@@ -270,13 +311,15 @@ async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForecastMonth:
|
||||
"""Ein Monat der Vorschau."""
|
||||
"""Ein Abrechnungsmonat der Vorschau."""
|
||||
|
||||
month: date
|
||||
period_start: date
|
||||
period_end: date
|
||||
income: Decimal
|
||||
expenses: Decimal
|
||||
cumulative_balance: Decimal
|
||||
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
|
||||
"""Prognostizierter Kontostand am Ende des Zeitraums über alle Konten."""
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
@@ -284,28 +327,33 @@ class ForecastMonth:
|
||||
|
||||
|
||||
async def forecast(
|
||||
session: AsyncSession, months: int = 12, start: date | None = None
|
||||
session: AsyncSession,
|
||||
months: int = 12,
|
||||
start: date | None = None,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
) -> list[ForecastMonth]:
|
||||
"""Vorschau über mehrere Monate.
|
||||
|
||||
Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil die Reihe
|
||||
aus der tatsächlichen Expansion entsteht und nicht aus einem Durchschnitt.
|
||||
"""
|
||||
beginn = month_start(start or today())
|
||||
erster = period_of(start, start_day) if start is not None else current_period(start_day)
|
||||
fix = await fixed_cost_map(session)
|
||||
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Monat.
|
||||
laufend = await total_balance(session, beginn - timedelta(days=1))
|
||||
# Ausgangspunkt ist der bestätigte Kontostand am Tag vor dem ersten Zeitraum.
|
||||
laufend = await total_balance(session, erster.start - timedelta(days=1))
|
||||
|
||||
ergebnis: list[ForecastMonth] = []
|
||||
for versatz in range(max(1, months)):
|
||||
monat = add_months(beginn, versatz)
|
||||
bewegungen = await flows(session, month_start(monat), month_end(monat), fixed_costs=fix)
|
||||
zeitraum = period_of(add_months(erster.key, versatz), start_day)
|
||||
bewegungen = await flows(session, zeitraum.start, zeitraum.end, fixed_costs=fix)
|
||||
summen = totals_of(bewegungen, basis="effective")
|
||||
laufend += summen.balance
|
||||
ergebnis.append(
|
||||
ForecastMonth(
|
||||
month=monat,
|
||||
month=zeitraum.key,
|
||||
period_start=zeitraum.start,
|
||||
period_end=zeitraum.end,
|
||||
income=summen.income,
|
||||
expenses=summen.expenses,
|
||||
cumulative_balance=laufend,
|
||||
@@ -567,6 +615,9 @@ class CalendarDay:
|
||||
@dataclass(slots=True)
|
||||
class CalendarMonth:
|
||||
month: date
|
||||
"""Bezeichner des Abrechnungsmonats – immer ein Monatserster."""
|
||||
period_start: date
|
||||
period_end: date
|
||||
days: list[CalendarDay]
|
||||
opening_balance: Decimal
|
||||
closing_balance: Decimal
|
||||
@@ -575,11 +626,16 @@ class CalendarMonth:
|
||||
|
||||
|
||||
async def calendar_month(
|
||||
session: AsyncSession, month: date, *, holiday_region: str = "DE-NW"
|
||||
session: AsyncSession,
|
||||
month: date,
|
||||
*,
|
||||
start_day: int = DEFAULT_MONTH_START_DAY,
|
||||
holiday_region: str = "DE-NW",
|
||||
) -> CalendarMonth:
|
||||
"""Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand."""
|
||||
beginn = month_start(month)
|
||||
ende = month_end(month)
|
||||
"""Tagesraster des Abrechnungsmonats mit den Fälligkeiten und dem laufenden Kontostand."""
|
||||
zeitraum = period_of(month, start_day)
|
||||
beginn = zeitraum.start
|
||||
ende = zeitraum.end
|
||||
|
||||
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
||||
bewegungen = await flows(session, beginn, ende)
|
||||
@@ -614,7 +670,9 @@ async def calendar_month(
|
||||
tag += timedelta(days=1)
|
||||
|
||||
return CalendarMonth(
|
||||
month=beginn,
|
||||
month=zeitraum.key,
|
||||
period_start=beginn,
|
||||
period_end=ende,
|
||||
days=tage,
|
||||
opening_balance=eroeffnung,
|
||||
closing_balance=laufend,
|
||||
@@ -697,14 +755,17 @@ async def _effective_limits(
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
async def _spent_by_category(
|
||||
session: AsyncSession, month: date, start_day: int
|
||||
) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Zeitraums je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie.parent_id
|
||||
for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
zeitraum = period_of(month, start_day)
|
||||
ergebnis: dict[int, Decimal] = {}
|
||||
for entry in await flows(session, month_start(month), month_end(month)):
|
||||
for entry in await flows(session, zeitraum.start, zeitraum.end):
|
||||
if entry.kind is not EntryKind.EXPENSE:
|
||||
continue
|
||||
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
|
||||
@@ -715,8 +776,10 @@ async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, De
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
|
||||
"""Budgets eines Monats samt Verbrauch und Übertrag."""
|
||||
async def budget_status(
|
||||
session: AsyncSession, month: date, start_day: int = DEFAULT_MONTH_START_DAY
|
||||
) -> list[BudgetStatus]:
|
||||
"""Budgets eines Abrechnungsmonats samt Verbrauch und Übertrag."""
|
||||
monat = month_start(month)
|
||||
limits = await _effective_limits(session, monat)
|
||||
if not limits:
|
||||
@@ -725,7 +788,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
namen = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
ausgaben = await _spent_by_category(session, monat)
|
||||
ausgaben = await _spent_by_category(session, monat, start_day)
|
||||
|
||||
ergebnis: list[BudgetStatus] = []
|
||||
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
|
||||
@@ -733,7 +796,7 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
if kategorie is None:
|
||||
continue
|
||||
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat, start_day) if rollover else ZERO
|
||||
ergebnis.append(
|
||||
BudgetStatus(
|
||||
category_id=kategorie_id,
|
||||
@@ -752,7 +815,9 @@ async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
|
||||
async def _carry_over(
|
||||
session: AsyncSession, category_id: int, month: date, start_day: int
|
||||
) -> Decimal:
|
||||
"""Nicht verbrauchtes Budget aus den Vormonaten.
|
||||
|
||||
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
|
||||
@@ -772,7 +837,7 @@ async def _carry_over(session: AsyncSession, category_id: int, month: date) -> D
|
||||
uebertrag = ZERO
|
||||
continue
|
||||
|
||||
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
|
||||
ausgaben = (await _spent_by_category(session, vormonat, start_day)).get(category_id, ZERO)
|
||||
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
|
||||
return uebertrag
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Anwendungsweite Einstellungen.
|
||||
|
||||
Die Tabelle enthält genau eine Zeile. Fehlt sie – etwa direkt nach der
|
||||
Migration –, liefert `load_settings` die Vorgaben, ohne sie zu schreiben.
|
||||
Erst ein Speichern legt die Zeile an.
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.clock import DEFAULT_MONTH_START_DAY
|
||||
from app.models import AppSetting
|
||||
|
||||
SETTING_ID = 1
|
||||
|
||||
|
||||
async def load_settings(session: AsyncSession) -> AppSetting:
|
||||
"""Die Einstellungen; ohne gespeicherte Zeile ein Objekt mit den Vorgaben."""
|
||||
vorhanden = await session.get(AppSetting, SETTING_ID)
|
||||
if vorhanden is not None:
|
||||
return vorhanden
|
||||
return AppSetting(id=SETTING_ID, month_start_day=DEFAULT_MONTH_START_DAY)
|
||||
|
||||
|
||||
async def month_start_day(session: AsyncSession) -> int:
|
||||
"""Der eingestellte Gehaltstag – ab ihm beginnt der Abrechnungsmonat."""
|
||||
return (await load_settings(session)).month_start_day
|
||||
|
||||
|
||||
async def save_settings(session: AsyncSession, *, month_start_day: int) -> AppSetting:
|
||||
"""Schreibt die Einstellungen und legt die Zeile bei Bedarf an."""
|
||||
eintrag = await session.get(AppSetting, SETTING_ID)
|
||||
if eintrag is None:
|
||||
eintrag = AppSetting(id=SETTING_ID)
|
||||
session.add(eintrag)
|
||||
eintrag.month_start_day = month_start_day
|
||||
await session.flush()
|
||||
return eintrag
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Der einstellbare Monatsbeginn – der Gehaltstag statt des Monatsersten."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.clock import month_anchor, period_bounds, period_key, period_start
|
||||
from app.services.reports import current_period, period_of
|
||||
|
||||
# --- Zeitrechnung ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monatserster_bleibt_der_kalendermonat() -> None:
|
||||
assert period_bounds(date(2026, 9, 1), 1) == (date(2026, 9, 1), date(2026, 9, 30))
|
||||
assert period_key(date(2026, 9, 30), 1) == date(2026, 9, 1)
|
||||
|
||||
|
||||
def test_gehaltstag_verschiebt_den_zeitraum() -> None:
|
||||
"""Der Zeitraum trägt den Namen des Monats, in dem er beginnt."""
|
||||
assert period_bounds(date(2026, 9, 1), 25) == (date(2026, 9, 25), date(2026, 10, 24))
|
||||
assert period_key(date(2026, 9, 24), 25) == date(2026, 8, 1)
|
||||
assert period_key(date(2026, 9, 25), 25) == date(2026, 9, 1)
|
||||
|
||||
|
||||
def test_starttag_jenseits_der_monatslaenge_trifft_den_letzten() -> None:
|
||||
"""Der 31. bedeutet „letzter Tag des Monats“ – auch im Februar."""
|
||||
assert month_anchor(date(2026, 2, 10), 31) == date(2026, 2, 28)
|
||||
assert period_bounds(date(2026, 1, 1), 31) == (date(2026, 1, 31), date(2026, 2, 27))
|
||||
assert period_bounds(date(2026, 2, 1), 31) == (date(2026, 2, 28), date(2026, 3, 30))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start_day", [1, 5, 15, 25, 28, 31])
|
||||
def test_zeitraeume_schliessen_lueckenlos_aneinander_an(start_day: int) -> None:
|
||||
"""Kein Tag fällt zwischen zwei Zeiträume, und keiner zählt doppelt."""
|
||||
monat = date(2025, 12, 1)
|
||||
_, vorher_ende = period_bounds(monat, start_day)
|
||||
for versatz in range(1, 18):
|
||||
schluessel = date(
|
||||
monat.year + (monat.month - 1 + versatz) // 12,
|
||||
(monat.month - 1 + versatz) % 12 + 1,
|
||||
1,
|
||||
)
|
||||
beginn, ende = period_bounds(schluessel, start_day)
|
||||
assert (beginn - vorher_ende).days == 1
|
||||
assert period_start(beginn, start_day) == beginn
|
||||
assert period_start(ende, start_day) == beginn
|
||||
vorher_ende = ende
|
||||
|
||||
|
||||
def test_current_period_richtet_sich_nach_dem_stichtag() -> None:
|
||||
zeitraum = current_period(25, date(2026, 9, 10))
|
||||
assert (zeitraum.key, zeitraum.start, zeitraum.end) == (
|
||||
date(2026, 8, 1),
|
||||
date(2026, 8, 25),
|
||||
date(2026, 9, 24),
|
||||
)
|
||||
|
||||
|
||||
# --- Einstellung über die API ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_einstellung_lesen_und_speichern(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.get("/api/settings")
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.json()["month_start_day"] == 1
|
||||
|
||||
antwort = await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["month_start_day"] == 25
|
||||
assert daten["current_period_start"].endswith("-25")
|
||||
|
||||
assert (await auth_client.get("/api/settings")).json()["month_start_day"] == 25
|
||||
|
||||
|
||||
async def test_einstellung_weist_ungueltige_tage_ab(auth_client: AsyncClient) -> None:
|
||||
for tag in (0, 32):
|
||||
antwort = await auth_client.put("/api/settings", json={"month_start_day": tag})
|
||||
assert antwort.status_code == 422, antwort.text
|
||||
|
||||
|
||||
# --- Wirkung auf die Auswertungen -----------------------------------------------
|
||||
|
||||
|
||||
async def _buchung(client: AsyncClient, seeded: dict, tag: str, betrag: str) -> None:
|
||||
antwort = await client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": f"Einkauf {tag}",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": betrag,
|
||||
"booking_date": tag,
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
|
||||
|
||||
async def test_monatsuebersicht_folgt_dem_gehaltstag(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 20. September gehört zum August, der 26. zum September."""
|
||||
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
|
||||
await _buchung(auth_client, seeded, "2026-09-26", "60.00")
|
||||
|
||||
await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
|
||||
antwort = await auth_client.get("/api/reports/month", params={"month": "2026-09-01"})
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["period_start"] == "2026-09-25"
|
||||
assert daten["period_end"] == "2026-10-24"
|
||||
assert daten["planned"]["expenses"] == "60.00"
|
||||
# Der 20.09. liegt im Vormonat, also im Zeitraum August.
|
||||
assert daten["previous_planned"]["expenses"] == "40.00"
|
||||
|
||||
|
||||
async def test_kalender_laeuft_vom_gehaltstag_bis_zum_vortag(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
|
||||
antwort = await auth_client.get("/api/reports/calendar", params={"month": "2026-09-01"})
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
|
||||
assert daten["month"] == "2026-09-01"
|
||||
assert daten["period_start"] == "2026-09-25"
|
||||
assert daten["period_end"] == "2026-10-24"
|
||||
assert daten["days"][0]["date"] == "2026-09-25"
|
||||
assert daten["days"][-1]["date"] == "2026-10-24"
|
||||
assert len(daten["days"]) == 30
|
||||
|
||||
|
||||
async def test_vorschau_beginnt_mit_dem_gehaltsmonat(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
|
||||
antwort = await auth_client.get(
|
||||
"/api/reports/forecast", params={"months": 3, "start": "2026-09-01"}
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
monate = antwort.json()["months"]
|
||||
|
||||
assert [monat["month"] for monat in monate] == ["2026-09-01", "2026-10-01", "2026-11-01"]
|
||||
assert monate[0]["period_start"] == "2026-09-25"
|
||||
assert monate[2]["period_end"] == "2026-12-24"
|
||||
|
||||
|
||||
async def test_budget_zaehlt_nur_ausgaben_des_gehaltsmonats(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-09-01",
|
||||
"limit_amount": "500.00",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
|
||||
await _buchung(auth_client, seeded, "2026-09-20", "40.00")
|
||||
await _buchung(auth_client, seeded, "2026-10-05", "60.00")
|
||||
|
||||
await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
|
||||
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-09-01"})
|
||||
assert antwort.status_code == 200
|
||||
stand = next(
|
||||
eintrag for eintrag in antwort.json() if eintrag["category_id"] == seeded["lebensmittel"]
|
||||
)
|
||||
# 20.09. liegt vor dem Gehaltstag, 05.10. danach.
|
||||
assert stand["spent"] == "60.00"
|
||||
|
||||
|
||||
async def test_dashboard_liefert_den_eingestellten_monatsbeginn(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await auth_client.put("/api/settings", json={"month_start_day": 25})
|
||||
|
||||
antwort = await auth_client.get("/api/reports/dashboard", params={"month": "2026-09-01"})
|
||||
assert antwort.status_code == 200
|
||||
daten = antwort.json()
|
||||
assert daten["month_start_day"] == 25
|
||||
assert daten["month"]["period_start"] == "2026-09-25"
|
||||
|
||||
|
||||
def test_period_of_kuerzt_beliebige_tage_auf_den_monatsersten() -> None:
|
||||
zeitraum = period_of(date(2026, 9, 17), 25)
|
||||
assert zeitraum.key == date(2026, 9, 1)
|
||||
assert zeitraum.start == date(2026, 9, 25)
|
||||
@@ -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