feat(reports): Auswertungen, Kalender, Budgets und Export
Backend: - Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und Ist bleiben dabei getrennt - Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich, Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard - Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen Unterkategorien ein - Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit typisierten Beträgen Frontend: - Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel- und Auswertungsseite - Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit geprüft; Grün/Rot als Serienpaar verworfen - Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf zwei Größenachsen - Recharts in einen eigenen Chunk ausgelagert 27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -78,6 +78,23 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Logo-Auswahldialog mit Vorauswahl, erneuter Suche über eine Domain und Upload.
|
||||
- Beträge durchgängig über `Intl.NumberFormat('de-DE')`; Eingaben akzeptieren
|
||||
deutsche wie englische Schreibweise.
|
||||
- Auswertungen: Monatsübersicht, 12-Monats-Forecast, Kategorienaufteilung mit
|
||||
Drilldown, Abo-Übersicht mit Jahreskosten, Jahresvergleich, Cashflow-Kalender,
|
||||
Budget-Ampel und Fortschritt der Sparziele.
|
||||
- Alle Berichte bauen auf einer gemeinsamen Bewegungsschicht auf, sodass
|
||||
Monatsübersicht, Forecast und Kalender nachweislich dieselben Zahlen zeigen.
|
||||
- Budgetübertrag über Monatsgrenzen und Budgets auf Oberkategorien, die deren
|
||||
Unterkategorien einschließen.
|
||||
- `GET /api/reports/dashboard` bündelt alles, was die Startseite braucht.
|
||||
- Export als CSV (Semikolon, Komma als Dezimaltrenner, UTF-8 mit BOM) und XLSX
|
||||
mit typisierten Beträgen und deutschem Zahlenformat – für Buchungen, Posten
|
||||
und die Monatsauswertung.
|
||||
- Dashboard, Cashflow-Kalender, Budget-, Sparziel- und Auswertungsseite im
|
||||
Frontend; Diagramme mit Recharts.
|
||||
- Die Farbpalette der Diagramme wurde gegen die hellen und dunklen Flächen der
|
||||
Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft. Grün/Rot schied als
|
||||
Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den
|
||||
Vorzeichen im Text vorbehalten.
|
||||
|
||||
### Geändert
|
||||
|
||||
|
||||
@@ -83,6 +83,19 @@ vollständige, kommentierte Liste. Die wichtigsten:
|
||||
| `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` |
|
||||
| `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 |
|
||||
|
||||
## Diagramme
|
||||
|
||||
Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
||||
Flächen der Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde; ihre
|
||||
Reihenfolge ist Teil dieser Absicherung. Grün und Rot bleiben den Vorzeichen im
|
||||
Text vorbehalten – als Serienpaar wären sie bei Deuteranopie nicht zu
|
||||
unterscheiden. Jedes Diagramm mit mehr als einer Serie führt eine Legende, und
|
||||
zu den Balkendiagrammen lässt sich die Wertetabelle aufklappen.
|
||||
|
||||
Einnahmen, Ausgaben und der kumulierte Kontostand stehen bewusst in zwei
|
||||
getrennten Diagrammen untereinander: eine gemeinsame Größenachse würde bei so
|
||||
verschiedenen Größenordnungen einen Zusammenhang vortäuschen.
|
||||
|
||||
## Logos und Markenfarben
|
||||
|
||||
Jede Firma bekommt ein Bild. Die Provider-Kette arbeitet der Reihe nach:
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.api.routes import (
|
||||
auth,
|
||||
budgets,
|
||||
categories,
|
||||
export,
|
||||
logos,
|
||||
me,
|
||||
merchants,
|
||||
@@ -43,5 +44,6 @@ protected.include_router(budgets.router)
|
||||
protected.include_router(budgets.templates)
|
||||
protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
protected.include_router(export.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Export von Buchungen, Posten und Monatsauswertung als CSV oder XLSX."""
|
||||
|
||||
from datetime import date
|
||||
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.core.errors import ValidationError
|
||||
from app.models import Account, Merchant, Transaction
|
||||
from app.models.enums import EntryKind
|
||||
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,
|
||||
flow_rows,
|
||||
flows,
|
||||
month_report,
|
||||
recurrence_rows,
|
||||
reserve_total,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["export"])
|
||||
|
||||
ExportFormat = Literal["csv", "xlsx"]
|
||||
|
||||
TRANSACTION_COLUMNS = (
|
||||
"Datum",
|
||||
"Titel",
|
||||
"Richtung",
|
||||
"Betrag",
|
||||
"Kategorie",
|
||||
"Firma",
|
||||
"Konto",
|
||||
"Notiz",
|
||||
)
|
||||
|
||||
RECURRENCE_COLUMNS = (
|
||||
"Titel",
|
||||
"Richtung",
|
||||
"Betrag",
|
||||
"Wiederholung",
|
||||
"Beginn",
|
||||
"Ende",
|
||||
"Kategorie",
|
||||
"Firma",
|
||||
"Jahreskosten",
|
||||
"Raten",
|
||||
"Gekündigt zum",
|
||||
"Aktiv",
|
||||
)
|
||||
|
||||
FLOW_COLUMNS = ("Datum", "Titel", "Kategorie", "Richtung", "Betrag", "Herkunft", "Bestätigt")
|
||||
SUMMARY_COLUMNS = ("Kennzahl", "Betrag")
|
||||
|
||||
# Starlette benennt die 422-Konstante gerade um – fester Wert vermeidet die Abhängigkeit.
|
||||
BAD_REQUEST = {422: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
def _download(content: bytes, name: str, fmt: ExportFormat) -> Response:
|
||||
"""Antwort mit passendem Typ und Dateinamen."""
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=CONTENT_TYPES[fmt],
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/transactions",
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"content": {"text/csv": {}, CONTENT_TYPES["xlsx"]: {}},
|
||||
"description": "Die Buchungen als Datei.",
|
||||
},
|
||||
**BAD_REQUEST,
|
||||
},
|
||||
summary="Buchungen exportieren",
|
||||
description="Ohne Zeitraum wird das laufende Jahr ausgegeben.",
|
||||
)
|
||||
async def export_transactions(
|
||||
session: DbSession,
|
||||
fmt: ExportFormat = Query(default="csv", alias="format"),
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
) -> Response:
|
||||
heute = today()
|
||||
start = date_from or date(heute.year, 1, 1)
|
||||
ende = date_to or date(heute.year, 12, 31)
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
namen = await category_names(session)
|
||||
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
|
||||
konten = await _account_names(session)
|
||||
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.where(Transaction.booking_date >= start, Transaction.booking_date <= ende)
|
||||
.order_by(Transaction.booking_date, Transaction.id)
|
||||
)
|
||||
zeilen = [
|
||||
{
|
||||
"Datum": buchung.booking_date.isoformat(),
|
||||
"Titel": buchung.title,
|
||||
"Richtung": "Einkunft" if buchung.kind is EntryKind.INCOME else "Ausgabe",
|
||||
"Betrag": buchung.amount,
|
||||
"Kategorie": namen.get(buchung.category_id, ""),
|
||||
"Firma": firmen.get(buchung.merchant_id or -1, ""),
|
||||
"Konto": konten.get(buchung.account_id, ""),
|
||||
"Notiz": buchung.note or "",
|
||||
}
|
||||
for buchung in (await session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
zeitraum = f"{start.isoformat()}_{ende.isoformat()}"
|
||||
if fmt == "csv":
|
||||
return _download(
|
||||
to_csv(zeilen, TRANSACTION_COLUMNS), filename("buchungen", "csv", zeitraum), fmt
|
||||
)
|
||||
return _download(
|
||||
to_xlsx({"Buchungen": zeilen}, {"Buchungen": TRANSACTION_COLUMNS}),
|
||||
filename("buchungen", "xlsx", zeitraum),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recurrences",
|
||||
responses={status.HTTP_200_OK: {"description": "Die Posten als Datei."}},
|
||||
summary="Wiederkehrende Posten exportieren",
|
||||
)
|
||||
async def export_recurrences(
|
||||
session: DbSession,
|
||||
fmt: ExportFormat = Query(default="csv", alias="format"),
|
||||
) -> Response:
|
||||
zeilen = await recurrence_rows(session)
|
||||
|
||||
if fmt == "csv":
|
||||
return _download(to_csv(zeilen, RECURRENCE_COLUMNS), filename("posten", "csv"), fmt)
|
||||
return _download(
|
||||
to_xlsx({"Posten": zeilen}, {"Posten": RECURRENCE_COLUMNS}),
|
||||
filename("posten", "xlsx"),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/month",
|
||||
responses={status.HTTP_200_OK: {"description": "Die Monatsauswertung als Datei."}},
|
||||
summary="Monatsauswertung exportieren",
|
||||
description="Enthält alle Bewegungen des Monats und eine Kennzahlenübersicht. "
|
||||
"Im XLSX-Format stehen beide auf getrennten Blättern.",
|
||||
)
|
||||
async def export_month(
|
||||
session: DbSession,
|
||||
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())
|
||||
namen = await category_names(session)
|
||||
|
||||
bewegungen = await flows(session, monat, month_end(monat))
|
||||
zeilen = flow_rows(bewegungen, namen)
|
||||
bericht = await month_report(session, monat)
|
||||
|
||||
kennzahlen: list[dict[str, object]] = [
|
||||
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
||||
{"Kennzahl": "Ausgaben (Plan)", "Betrag": bericht.planned.expenses},
|
||||
{"Kennzahl": "Saldo (Plan)", "Betrag": bericht.planned.balance},
|
||||
{"Kennzahl": "Einnahmen (Ist)", "Betrag": bericht.actual.income},
|
||||
{"Kennzahl": "Ausgaben (Ist)", "Betrag": bericht.actual.expenses},
|
||||
{"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": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
|
||||
]
|
||||
|
||||
kennung = monat.strftime("%Y-%m")
|
||||
if fmt == "csv":
|
||||
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
||||
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
||||
inhalt += b"\r\n" + to_csv(kennzahlen, SUMMARY_COLUMNS).removeprefix("".encode())
|
||||
return _download(inhalt, filename("monat", "csv", kennung), fmt)
|
||||
|
||||
return _download(
|
||||
to_xlsx(
|
||||
{"Bewegungen": zeilen, "Kennzahlen": kennzahlen},
|
||||
{"Bewegungen": FLOW_COLUMNS, "Kennzahlen": SUMMARY_COLUMNS},
|
||||
),
|
||||
filename("monat", "xlsx", kennung),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
async def _account_names(session: DbSession) -> dict[int, str]:
|
||||
return {konto.id: konto.name for konto in (await session.execute(select(Account))).scalars()}
|
||||
@@ -1,35 +1,70 @@
|
||||
"""Auswertungen."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import today
|
||||
from app.schemas.report import MonthComparisonOut, MonthReportOut, TotalsOut
|
||||
from app.services.reports import Totals, month_report
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import SavingsGoal
|
||||
from app.models.enums import EntryKind
|
||||
from app.schemas.recurrence import ContractTermOut
|
||||
from app.schemas.report import (
|
||||
BudgetStatusOut,
|
||||
CalendarDayOut,
|
||||
CalendarEntryOut,
|
||||
CalendarMonthOut,
|
||||
CategoryReportOut,
|
||||
CategorySliceOut,
|
||||
DashboardOut,
|
||||
ForecastMonthOut,
|
||||
ForecastOut,
|
||||
MonthComparisonOut,
|
||||
MonthReportOut,
|
||||
SavingsGoalProgressOut,
|
||||
SubscriptionOut,
|
||||
SubscriptionReportOut,
|
||||
TotalsOut,
|
||||
YearComparisonOut,
|
||||
YearComparisonRowOut,
|
||||
)
|
||||
from app.services.balances import total_balance
|
||||
from app.services.reports import (
|
||||
BudgetStatus,
|
||||
CategorySlice,
|
||||
FlowEntry,
|
||||
ForecastMonth,
|
||||
MonthReport,
|
||||
SubscriptionEntry,
|
||||
Totals,
|
||||
budget_status,
|
||||
calendar_month,
|
||||
category_breakdown,
|
||||
flows,
|
||||
forecast,
|
||||
month_report,
|
||||
months_between,
|
||||
required_monthly_rate,
|
||||
subscriptions,
|
||||
year_comparison,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
MAX_FORECAST_MONTHS = 60
|
||||
|
||||
|
||||
# --- Umsetzung in Schemata -----------------------------------------------------
|
||||
|
||||
|
||||
def _totals(value: Totals) -> TotalsOut:
|
||||
return TotalsOut(income=value.income, expenses=value.expenses, balance=value.balance)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/month",
|
||||
response_model=MonthReportOut,
|
||||
summary="Monatsübersicht",
|
||||
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
|
||||
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
|
||||
)
|
||||
async def read_month_report(
|
||||
session: DbSession,
|
||||
month: date | None = Query(
|
||||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||||
),
|
||||
) -> MonthReportOut:
|
||||
report = await month_report(session, month or today())
|
||||
def _month(report: MonthReport) -> MonthReportOut:
|
||||
return MonthReportOut(
|
||||
month=report.month,
|
||||
planned=_totals(report.planned),
|
||||
@@ -49,3 +84,323 @@ async def read_month_report(
|
||||
open_count=report.open_count,
|
||||
skipped_count=report.skipped_count,
|
||||
)
|
||||
|
||||
|
||||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||||
return ForecastMonthOut(
|
||||
month=entry.month,
|
||||
income=entry.income,
|
||||
expenses=entry.expenses,
|
||||
balance=entry.balance,
|
||||
cumulative_balance=entry.cumulative_balance,
|
||||
)
|
||||
|
||||
|
||||
def _slice(value: CategorySlice) -> CategorySliceOut:
|
||||
return CategorySliceOut(
|
||||
category_id=value.category_id,
|
||||
name=value.name,
|
||||
color=value.color,
|
||||
icon=value.icon,
|
||||
amount=value.amount,
|
||||
count=value.count,
|
||||
children=[_slice(kind) for kind in value.children],
|
||||
)
|
||||
|
||||
|
||||
def _subscription(entry: SubscriptionEntry) -> SubscriptionOut:
|
||||
return SubscriptionOut(
|
||||
recurrence_id=entry.recurrence_id,
|
||||
title=entry.title,
|
||||
merchant_id=entry.merchant_id,
|
||||
merchant_name=entry.merchant_name,
|
||||
category_id=entry.category_id,
|
||||
amount=entry.amount,
|
||||
annual_cost=entry.annual_cost,
|
||||
monthly_cost=entry.monthly_cost,
|
||||
rrule=entry.rrule,
|
||||
is_installment=entry.is_installment,
|
||||
is_cancelled=entry.is_cancelled,
|
||||
contract_term=ContractTermOut.model_validate(entry.term) if entry.term else None,
|
||||
days_until_notice=entry.days_until_notice,
|
||||
)
|
||||
|
||||
|
||||
def _budget(entry: BudgetStatus) -> BudgetStatusOut:
|
||||
return BudgetStatusOut(
|
||||
category_id=entry.category_id,
|
||||
category_name=entry.category_name,
|
||||
color=entry.color,
|
||||
period_month=entry.period_month,
|
||||
limit_amount=entry.limit_amount,
|
||||
carried_over=entry.carried_over,
|
||||
available=entry.available,
|
||||
spent=entry.spent,
|
||||
remaining=entry.remaining,
|
||||
ratio=float(entry.ratio),
|
||||
state=entry.state,
|
||||
rollover=entry.rollover,
|
||||
is_template=entry.is_template,
|
||||
)
|
||||
|
||||
|
||||
def _entry(flow: FlowEntry) -> CalendarEntryOut:
|
||||
return CalendarEntryOut(
|
||||
title=flow.title,
|
||||
kind=flow.kind,
|
||||
amount=flow.amount,
|
||||
category_id=flow.category_id,
|
||||
merchant_id=flow.merchant_id,
|
||||
account_id=flow.account_id,
|
||||
source=flow.source,
|
||||
recurrence_id=flow.recurrence_id,
|
||||
occurrence_date=flow.occurrence_date,
|
||||
status=flow.status,
|
||||
is_variable=flow.is_variable,
|
||||
)
|
||||
|
||||
|
||||
async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut]:
|
||||
"""Fortschritt aller nicht archivierten Sparziele."""
|
||||
stmt = (
|
||||
select(SavingsGoal)
|
||||
.where(SavingsGoal.is_archived.is_(False))
|
||||
.order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
|
||||
)
|
||||
|
||||
ergebnis: list[SavingsGoalProgressOut] = []
|
||||
for ziel in (await session.execute(stmt)).scalars():
|
||||
offen = max(ziel.target_amount - ziel.current_amount, 0)
|
||||
noetig = required_monthly_rate(
|
||||
ziel.target_amount, ziel.current_amount, ziel.target_date, as_of
|
||||
)
|
||||
monate = months_between(as_of, ziel.target_date) if ziel.target_date else None
|
||||
|
||||
ergebnis.append(
|
||||
SavingsGoalProgressOut(
|
||||
goal_id=ziel.id,
|
||||
name=ziel.name,
|
||||
color=ziel.color,
|
||||
icon=ziel.icon,
|
||||
target_amount=ziel.target_amount,
|
||||
current_amount=ziel.current_amount,
|
||||
remaining_amount=offen,
|
||||
ratio=(
|
||||
float(ziel.current_amount / ziel.target_amount)
|
||||
if ziel.target_amount > 0
|
||||
else 0.0
|
||||
),
|
||||
target_date=ziel.target_date,
|
||||
months_left=monate,
|
||||
required_monthly=noetig,
|
||||
monthly_contribution=ziel.monthly_contribution,
|
||||
is_on_track=(
|
||||
None
|
||||
if noetig is None or ziel.monthly_contribution is None
|
||||
else ziel.monthly_contribution >= noetig
|
||||
),
|
||||
)
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Endpunkte -----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/month",
|
||||
response_model=MonthReportOut,
|
||||
summary="Monatsübersicht",
|
||||
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
|
||||
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
|
||||
)
|
||||
async def read_month_report(
|
||||
session: DbSession,
|
||||
month: date | None = Query(
|
||||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||||
),
|
||||
) -> MonthReportOut:
|
||||
return _month(await month_report(session, month or today()))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/forecast",
|
||||
response_model=ForecastOut,
|
||||
summary="Vorschau über mehrere Monate",
|
||||
description="Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil "
|
||||
"die Reihe aus der tatsächlichen Expansion entsteht.",
|
||||
)
|
||||
async def read_forecast(
|
||||
session: DbSession,
|
||||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||||
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
|
||||
) -> ForecastOut:
|
||||
monate = await forecast(session, months, start)
|
||||
return ForecastOut(
|
||||
months=[_forecast_month(monat) for monat in monate],
|
||||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||||
total_expenses=sum((monat.expenses for monat in monate), Decimal("0.00")),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/categories",
|
||||
response_model=CategoryReportOut,
|
||||
summary="Aufteilung nach Kategorien",
|
||||
description="Summen je Oberkategorie mit Drilldown auf die Unterkategorien.",
|
||||
)
|
||||
async def read_categories(
|
||||
session: DbSession,
|
||||
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)
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
gruppen = await category_breakdown(session, start, ende, kind)
|
||||
return CategoryReportOut(
|
||||
date_from=start,
|
||||
date_to=ende,
|
||||
kind=kind,
|
||||
total=sum((gruppe.amount for gruppe in gruppen), Decimal("0.00")),
|
||||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/subscriptions",
|
||||
response_model=SubscriptionReportOut,
|
||||
summary="Abo-Übersicht",
|
||||
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
|
||||
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
|
||||
)
|
||||
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||||
bericht = await subscriptions(session)
|
||||
return SubscriptionReportOut(
|
||||
entries=[_subscription(eintrag) for eintrag in bericht.entries],
|
||||
total_annual=bericht.total_annual,
|
||||
total_monthly=bericht.total_monthly,
|
||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in bericht.upcoming_deadlines],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/year-comparison",
|
||||
response_model=YearComparisonOut,
|
||||
summary="Jahresvergleich",
|
||||
description="Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie.",
|
||||
)
|
||||
async def read_year_comparison(
|
||||
session: DbSession,
|
||||
year: int | None = Query(default=None, ge=1970, le=2200),
|
||||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||||
) -> YearComparisonOut:
|
||||
vergleich = await year_comparison(session, year or today().year, kind)
|
||||
return YearComparisonOut(
|
||||
year=vergleich.year,
|
||||
rows=[
|
||||
YearComparisonRowOut(
|
||||
category_id=zeile.category_id,
|
||||
name=zeile.name,
|
||||
color=zeile.color,
|
||||
current=zeile.current,
|
||||
previous=zeile.previous,
|
||||
delta=zeile.delta,
|
||||
)
|
||||
for zeile in vergleich.rows
|
||||
],
|
||||
current_total=vergleich.current_total,
|
||||
previous_total=vergleich.previous_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/calendar",
|
||||
response_model=CalendarMonthOut,
|
||||
summary="Cashflow-Kalender",
|
||||
description="Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand.",
|
||||
)
|
||||
async def read_calendar(
|
||||
session: DbSession,
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> CalendarMonthOut:
|
||||
raster = await calendar_month(session, month or today())
|
||||
return CalendarMonthOut(
|
||||
month=raster.month,
|
||||
days=[
|
||||
CalendarDayOut(
|
||||
date=tag.on,
|
||||
entries=[_entry(eintrag) for eintrag in tag.entries],
|
||||
net=tag.net,
|
||||
running_balance=tag.running_balance,
|
||||
is_business_day=tag.is_business_day,
|
||||
)
|
||||
for tag in raster.days
|
||||
],
|
||||
opening_balance=raster.opening_balance,
|
||||
closing_balance=raster.closing_balance,
|
||||
lowest_balance=raster.lowest_balance,
|
||||
lowest_balance_on=raster.lowest_balance_on,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/budgets",
|
||||
response_model=list[BudgetStatusOut],
|
||||
summary="Budget-Ampel",
|
||||
description="Verbrauch je Budget im Monat. Grün unter 80 %, gelb unter 100 %, "
|
||||
"darüber rot. Budgets auf einer Oberkategorie umfassen deren Unterkategorien.",
|
||||
)
|
||||
async def read_budget_status(
|
||||
session: DbSession,
|
||||
month: date | None = Query(default=None),
|
||||
) -> list[BudgetStatusOut]:
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/savings-goals",
|
||||
response_model=list[SavingsGoalProgressOut],
|
||||
summary="Fortschritt der Sparziele",
|
||||
description="Fortschritt und die bis zum Zieldatum nötige Monatsrate.",
|
||||
)
|
||||
async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]:
|
||||
return await _goals(session, today())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/dashboard",
|
||||
response_model=DashboardOut,
|
||||
summary="Dashboard",
|
||||
description="Bündelt Monatsübersicht, Vorschau, Kategorien, Budgets, Sparziele "
|
||||
"und die nächsten Fälligkeiten in einem Aufruf.",
|
||||
)
|
||||
async def read_dashboard(
|
||||
session: DbSession,
|
||||
month: date | None = Query(default=None),
|
||||
) -> DashboardOut:
|
||||
heute = today()
|
||||
monat = month_start(month or heute)
|
||||
|
||||
bericht = await month_report(session, monat)
|
||||
vorschau = await forecast(session, 12, monat)
|
||||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
||||
abos = await subscriptions(session)
|
||||
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
|
||||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||||
|
||||
return DashboardOut(
|
||||
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)],
|
||||
goals=await _goals(session, heute),
|
||||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ from datetime import date
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.models.enums import EntryKind, OccurrenceStatus
|
||||
from app.schemas.common import ApiModel, Money
|
||||
from app.schemas.recurrence import ContractTermOut
|
||||
|
||||
|
||||
class TotalsOut(ApiModel):
|
||||
@@ -43,3 +45,178 @@ class MonthReportOut(ApiModel):
|
||||
confirmed_count: int
|
||||
open_count: int
|
||||
skipped_count: int
|
||||
|
||||
|
||||
class ForecastMonthOut(ApiModel):
|
||||
"""Ein Monat der Vorschau."""
|
||||
|
||||
month: date
|
||||
income: Money
|
||||
expenses: Money
|
||||
balance: Money
|
||||
cumulative_balance: Money = Field(
|
||||
description="Prognostizierter Kontostand am Monatsende über alle Konten."
|
||||
)
|
||||
|
||||
|
||||
class ForecastOut(ApiModel):
|
||||
months: list[ForecastMonthOut]
|
||||
total_income: Money
|
||||
total_expenses: Money
|
||||
|
||||
|
||||
class CategorySliceOut(ApiModel):
|
||||
"""Summe einer Kategorie; Oberkategorien tragen ihre Unterkategorien."""
|
||||
|
||||
category_id: int
|
||||
name: str
|
||||
color: str
|
||||
icon: str
|
||||
amount: Money
|
||||
count: int
|
||||
children: list["CategorySliceOut"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CategoryReportOut(ApiModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
kind: EntryKind
|
||||
total: Money
|
||||
categories: list[CategorySliceOut]
|
||||
|
||||
|
||||
class SubscriptionOut(ApiModel):
|
||||
"""Ein laufender Posten mit Jahreskosten."""
|
||||
|
||||
recurrence_id: int
|
||||
title: str
|
||||
merchant_id: int | None
|
||||
merchant_name: str | None
|
||||
category_id: int
|
||||
amount: Money
|
||||
annual_cost: Money
|
||||
monthly_cost: Money
|
||||
rrule: str
|
||||
is_installment: bool = Field(
|
||||
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
|
||||
)
|
||||
is_cancelled: bool
|
||||
contract_term: ContractTermOut | None = None
|
||||
days_until_notice: int | None = Field(
|
||||
default=None, description="Tage bis zum letzten Kündigungstermin."
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionReportOut(ApiModel):
|
||||
entries: list[SubscriptionOut]
|
||||
total_annual: Money = Field(description="Summe über alle Posten ohne Ratenzahlungen.")
|
||||
total_monthly: Money
|
||||
upcoming_deadlines: list[SubscriptionOut] = Field(
|
||||
description="Kündigungsfristen, die in den nächsten 60 Tagen ablaufen."
|
||||
)
|
||||
|
||||
|
||||
class YearComparisonRowOut(ApiModel):
|
||||
category_id: int
|
||||
name: str
|
||||
color: str
|
||||
current: Money
|
||||
previous: Money
|
||||
delta: Money
|
||||
|
||||
|
||||
class YearComparisonOut(ApiModel):
|
||||
year: int
|
||||
rows: list[YearComparisonRowOut]
|
||||
current_total: Money
|
||||
previous_total: Money
|
||||
|
||||
|
||||
class CalendarEntryOut(ApiModel):
|
||||
"""Ein fälliger Posten im Kalender."""
|
||||
|
||||
title: str
|
||||
kind: EntryKind
|
||||
amount: Money
|
||||
category_id: int
|
||||
merchant_id: int | None
|
||||
account_id: int | None
|
||||
source: str = Field(description="'recurrence' oder 'transaction'.")
|
||||
recurrence_id: int | None = None
|
||||
occurrence_date: date | None = Field(
|
||||
default=None, description="Nominales Datum – Schlüssel für Bestätigen und Auslassen."
|
||||
)
|
||||
status: OccurrenceStatus | None = None
|
||||
is_variable: bool = False
|
||||
|
||||
|
||||
class CalendarDayOut(ApiModel):
|
||||
date: date
|
||||
entries: list[CalendarEntryOut]
|
||||
net: Money = Field(description="Saldo des Tages, Ausgaben negativ.")
|
||||
running_balance: Money
|
||||
is_business_day: bool
|
||||
|
||||
|
||||
class CalendarMonthOut(ApiModel):
|
||||
month: date
|
||||
days: list[CalendarDayOut]
|
||||
opening_balance: Money
|
||||
closing_balance: Money
|
||||
lowest_balance: Money
|
||||
lowest_balance_on: date | None
|
||||
|
||||
|
||||
class BudgetStatusOut(ApiModel):
|
||||
"""Stand eines Budgets samt Ampel."""
|
||||
|
||||
category_id: int
|
||||
category_name: str
|
||||
color: str
|
||||
period_month: date
|
||||
limit_amount: Money
|
||||
carried_over: Money = Field(description="Übertrag aus Vormonaten bei aktivem Rollover.")
|
||||
available: Money = Field(description="Limit zuzüglich Übertrag.")
|
||||
spent: Money
|
||||
remaining: Money
|
||||
ratio: float = Field(description="Verbrauchsanteil; 1.0 entspricht 100 %.")
|
||||
state: str = Field(description="'ok' unter 80 %, 'warning' unter 100 %, sonst 'exceeded'.")
|
||||
rollover: bool
|
||||
is_template: bool
|
||||
|
||||
|
||||
class SavingsGoalProgressOut(ApiModel):
|
||||
"""Fortschritt eines Sparziels."""
|
||||
|
||||
goal_id: int
|
||||
name: str
|
||||
color: str
|
||||
icon: str
|
||||
target_amount: Money
|
||||
current_amount: Money
|
||||
remaining_amount: Money
|
||||
ratio: float
|
||||
target_date: date | None
|
||||
months_left: int | None
|
||||
required_monthly: Money | None = Field(
|
||||
default=None, description="Nötige Rate bis zum Zieldatum."
|
||||
)
|
||||
monthly_contribution: Money | None
|
||||
is_on_track: bool | None = Field(
|
||||
default=None, description="Reicht die geplante Rate bis zum Zieldatum?"
|
||||
)
|
||||
|
||||
|
||||
class DashboardOut(ApiModel):
|
||||
"""Alles, was das Dashboard in einem Aufruf braucht."""
|
||||
|
||||
month: MonthReportOut
|
||||
total_balance: Money
|
||||
forecast: list[ForecastMonthOut]
|
||||
categories: list[CategorySliceOut]
|
||||
budgets: list[BudgetStatusOut]
|
||||
goals: list[SavingsGoalProgressOut]
|
||||
upcoming: list[CalendarEntryOut] = Field(
|
||||
description="Die nächsten Fälligkeiten der kommenden 14 Tage."
|
||||
)
|
||||
upcoming_deadlines: list[SubscriptionOut]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Export als CSV und XLSX.
|
||||
|
||||
CSV verwendet Semikolon als Trennzeichen, Komma als Dezimaltrennzeichen und
|
||||
UTF-8 mit BOM – so öffnet Excel die Datei in deutscher Umgebung ohne Nachfrage.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# Excel erkennt UTF-8 in CSV nur zuverlässig anhand der Byte Order Mark.
|
||||
BOM = ""
|
||||
CSV_DELIMITER = ";"
|
||||
|
||||
# Deutsches Zahlenformat mit Tausenderpunkt und zwei Nachkommastellen.
|
||||
XLSX_MONEY_FORMAT = '#,##0.00\\ "€"'
|
||||
XLSX_HEADER_FILL = PatternFill("solid", fgColor="1F2937")
|
||||
|
||||
|
||||
def _format_value(value: Any) -> str:
|
||||
"""Werte für CSV aufbereiten; Beträge in deutscher Schreibweise."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, Decimal):
|
||||
return f"{value:.2f}".replace(".", ",")
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def to_csv(rows: Sequence[dict[str, Any]], columns: Sequence[str] | None = None) -> bytes:
|
||||
"""Erzeugt eine CSV-Datei aus Zeilen gleicher Struktur."""
|
||||
spalten = list(columns or (rows[0].keys() if rows else []))
|
||||
|
||||
puffer = io.StringIO()
|
||||
puffer.write(BOM)
|
||||
schreiber = csv.writer(puffer, delimiter=CSV_DELIMITER, lineterminator="\r\n")
|
||||
schreiber.writerow(spalten)
|
||||
for zeile in rows:
|
||||
schreiber.writerow([_format_value(zeile.get(spalte)) for spalte in spalten])
|
||||
|
||||
return puffer.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def to_xlsx(
|
||||
sheets: dict[str, Sequence[dict[str, Any]]],
|
||||
columns: dict[str, Sequence[str]] | None = None,
|
||||
) -> bytes:
|
||||
"""Erzeugt eine Arbeitsmappe mit einem Blatt je Eintrag."""
|
||||
mappe = Workbook()
|
||||
mappe.remove(mappe.active)
|
||||
|
||||
for name, zeilen in sheets.items():
|
||||
blatt = mappe.create_sheet(title=name[:31])
|
||||
spalten = list((columns or {}).get(name) or (zeilen[0].keys() if zeilen else []))
|
||||
if not spalten:
|
||||
blatt["A1"] = "Keine Daten"
|
||||
continue
|
||||
|
||||
blatt.append(spalten)
|
||||
for zelle in blatt[1]:
|
||||
zelle.font = Font(bold=True, color="FFFFFF")
|
||||
zelle.fill = XLSX_HEADER_FILL
|
||||
zelle.alignment = Alignment(vertical="center")
|
||||
|
||||
for zeile in zeilen:
|
||||
blatt.append([_xlsx_value(zeile.get(spalte)) for spalte in spalten])
|
||||
|
||||
_style_columns(blatt, spalten, zeilen)
|
||||
blatt.freeze_panes = "A2"
|
||||
|
||||
puffer = io.BytesIO()
|
||||
mappe.save(puffer)
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
def _xlsx_value(value: Any) -> Any:
|
||||
"""Beträge und Daten bleiben typisiert, damit Excel damit rechnen kann."""
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _style_columns(blatt, spalten: Sequence[str], zeilen: Sequence[dict[str, Any]]) -> None:
|
||||
"""Spaltenbreite nach Inhalt und Zahlenformat für Geldspalten."""
|
||||
for index, spalte in enumerate(spalten, start=1):
|
||||
buchstabe = get_column_letter(index)
|
||||
breite = max(
|
||||
len(str(spalte)),
|
||||
*(len(_format_value(zeile.get(spalte))) for zeile in zeilen[:200] or [{}]),
|
||||
)
|
||||
blatt.column_dimensions[buchstabe].width = min(max(breite + 3, 10), 42)
|
||||
|
||||
ist_geld = any(isinstance(zeile.get(spalte), Decimal) for zeile in zeilen[:50])
|
||||
if not ist_geld:
|
||||
continue
|
||||
for zelle in blatt[buchstabe][1:]:
|
||||
zelle.number_format = XLSX_MONEY_FORMAT
|
||||
|
||||
|
||||
CONTENT_TYPES = {
|
||||
"csv": "text/csv; charset=utf-8",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}
|
||||
|
||||
|
||||
def filename(base: str, fmt: str, suffix: str | None = None) -> str:
|
||||
"""Dateiname für den Download, etwa `moneyfy-buchungen-2026-03.xlsx`."""
|
||||
teile = ["moneyfy", base]
|
||||
if suffix:
|
||||
teile.append(suffix)
|
||||
return f"{'-'.join(teile)}.{fmt}"
|
||||
+792
-100
@@ -1,19 +1,74 @@
|
||||
"""Auswertungen. In dieser Phase die Monatsübersicht."""
|
||||
"""Auswertungen.
|
||||
|
||||
Alle Berichte bauen auf `flows()` auf: einer einheitlichen Liste aus expandierten
|
||||
Fälligkeiten und einmaligen Buchungen. Dadurch rechnen Monatsübersicht, Forecast,
|
||||
Kalender und Jahresvergleich nachweislich mit denselben Zahlen.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
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
|
||||
from app.models import Category, Transaction
|
||||
from app.core.clock import add_months, month_end, month_start, 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
|
||||
from app.services.occurrences import due_items, load_recurrences
|
||||
from app.services.recurrence import monthly_reserve
|
||||
from app.services.recurrence import (
|
||||
ContractTerm,
|
||||
annual_burden,
|
||||
contract_term,
|
||||
is_business_day,
|
||||
monthly_reserve,
|
||||
)
|
||||
|
||||
ZERO = Decimal("0.00")
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
# Rücklaufweite für die Budgetübertragung – begrenzt die Rekursion.
|
||||
ROLLOVER_LOOKBACK_MONTHS = 12
|
||||
|
||||
FlowSource = Literal["recurrence", "transaction"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlowEntry:
|
||||
"""Eine Geldbewegung, unabhängig davon, ob sie aus einer Serie oder einer
|
||||
einmaligen Buchung stammt."""
|
||||
|
||||
on: date
|
||||
"""Tag, an dem das Geld fließt (Ist-Datum, sonst Zahltag)."""
|
||||
kind: EntryKind
|
||||
amount: Decimal
|
||||
"""Bester bekannter Wert: Ist bei Bestätigung, sonst Soll. Immer positiv."""
|
||||
planned_amount: Decimal
|
||||
"""Sollbetrag laut Preishistorie. Bei einmaligen Buchungen gleich `amount`."""
|
||||
title: str
|
||||
category_id: int
|
||||
account_id: int | None
|
||||
merchant_id: int | None
|
||||
source: FlowSource
|
||||
is_fixed_cost: bool
|
||||
recurrence_id: int | None = None
|
||||
occurrence_date: date | None = None
|
||||
status: OccurrenceStatus | None = None
|
||||
is_variable: bool = False
|
||||
installment_number: int | None = None
|
||||
installments_total: int | None = None
|
||||
|
||||
@property
|
||||
def is_confirmed(self) -> bool:
|
||||
"""Einmalige Buchungen sind immer Ist, Fälligkeiten nur nach Bestätigung."""
|
||||
return self.source == "transaction" or self.status is OccurrenceStatus.CONFIRMED
|
||||
|
||||
@property
|
||||
def signed(self) -> Decimal:
|
||||
return -self.amount if self.kind is EntryKind.EXPENSE else self.amount
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -28,6 +83,101 @@ class Totals:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
def totals_of(
|
||||
entries: Iterable[FlowEntry],
|
||||
*,
|
||||
basis: Literal["planned", "effective"] = "planned",
|
||||
only_confirmed: bool = False,
|
||||
) -> Totals:
|
||||
"""Summiert Einnahmen und Ausgaben einer Auswahl.
|
||||
|
||||
`basis="planned"` rechnet mit dem Soll, `basis="effective"` mit dem besten
|
||||
bekannten Wert. Beides zusammen ergibt den Plan-Ist-Vergleich.
|
||||
"""
|
||||
income = expenses = ZERO
|
||||
for entry in entries:
|
||||
if only_confirmed and not entry.is_confirmed:
|
||||
continue
|
||||
betrag = entry.planned_amount if basis == "planned" else entry.amount
|
||||
if entry.kind is EntryKind.INCOME:
|
||||
income += betrag
|
||||
else:
|
||||
expenses += betrag
|
||||
return Totals(income=income, expenses=expenses)
|
||||
|
||||
|
||||
async def fixed_cost_map(session: AsyncSession) -> dict[int, bool]:
|
||||
"""Kategorie-ID -> gehört zu den Fixkosten."""
|
||||
rows = await session.execute(select(Category.id, Category.is_fixed_cost))
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def flows(
|
||||
session: AsyncSession,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
*,
|
||||
fixed_costs: dict[int, bool] | None = None,
|
||||
) -> list[FlowEntry]:
|
||||
"""Alle Geldbewegungen eines Zeitraums, nach Datum sortiert.
|
||||
|
||||
Ausgelassene Fälligkeiten sind bereits herausgefiltert – sie fließen nirgends
|
||||
mehr ein.
|
||||
"""
|
||||
fix = fixed_costs if fixed_costs is not None else await fixed_cost_map(session)
|
||||
ergebnis: list[FlowEntry] = []
|
||||
|
||||
for item in await due_items(session, date_from, date_to):
|
||||
planned = item.planned
|
||||
if planned.status is OccurrenceStatus.SKIPPED:
|
||||
continue
|
||||
ergebnis.append(
|
||||
FlowEntry(
|
||||
on=planned.effective_date,
|
||||
kind=planned.kind,
|
||||
amount=planned.effective_amount,
|
||||
planned_amount=planned.amount,
|
||||
title=item.recurrence.title,
|
||||
category_id=item.recurrence.category_id,
|
||||
account_id=planned.account_id,
|
||||
merchant_id=item.recurrence.merchant_id,
|
||||
source="recurrence",
|
||||
is_fixed_cost=fix.get(item.recurrence.category_id, False),
|
||||
recurrence_id=item.recurrence.id,
|
||||
occurrence_date=planned.nominal_date,
|
||||
status=planned.status,
|
||||
is_variable=planned.is_variable,
|
||||
installment_number=planned.installment_number,
|
||||
installments_total=planned.installments_total,
|
||||
)
|
||||
)
|
||||
|
||||
stmt = select(Transaction).where(
|
||||
Transaction.booking_date >= date_from, Transaction.booking_date <= date_to
|
||||
)
|
||||
for transaction in (await session.execute(stmt)).scalars():
|
||||
ergebnis.append(
|
||||
FlowEntry(
|
||||
on=transaction.booking_date,
|
||||
kind=transaction.kind,
|
||||
amount=transaction.amount,
|
||||
planned_amount=transaction.amount,
|
||||
title=transaction.title,
|
||||
category_id=transaction.category_id,
|
||||
account_id=transaction.account_id,
|
||||
merchant_id=transaction.merchant_id,
|
||||
source="transaction",
|
||||
is_fixed_cost=fix.get(transaction.category_id, False),
|
||||
)
|
||||
)
|
||||
|
||||
ergebnis.sort(key=lambda entry: (entry.on, entry.title))
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Monatsübersicht -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MonthReport:
|
||||
"""Kennzahlen eines Monats."""
|
||||
@@ -43,7 +193,6 @@ class MonthReport:
|
||||
confirmed_count: int = 0
|
||||
open_count: int = 0
|
||||
skipped_count: int = 0
|
||||
categories: dict[int, Decimal] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def available_after_fixed(self) -> Decimal:
|
||||
@@ -63,84 +212,13 @@ class MonthReport:
|
||||
return self.planned.balance - self.previous_planned.balance
|
||||
|
||||
|
||||
async def _fixed_cost_map(session: AsyncSession) -> dict[int, bool]:
|
||||
"""Kategorie-ID -> ist Fixkostenkategorie."""
|
||||
rows = await session.execute(select(Category.id, Category.is_fixed_cost))
|
||||
return dict(rows.all())
|
||||
|
||||
|
||||
async def _month_totals(
|
||||
async def _month_flows(
|
||||
session: AsyncSession, month: date, fixed_costs: dict[int, bool]
|
||||
) -> tuple[Totals, Totals, Decimal, Decimal, dict[int, int], dict[int, Decimal]]:
|
||||
"""Rechnet einen Monat aus Fälligkeiten und Buchungen zusammen."""
|
||||
start = month_start(month)
|
||||
end = month_end(month)
|
||||
|
||||
planned_income = planned_expenses = ZERO
|
||||
actual_income = actual_expenses = ZERO
|
||||
fixed = variable = ZERO
|
||||
counts = {"confirmed": 0, "open": 0, "skipped": 0}
|
||||
per_category: dict[int, Decimal] = {}
|
||||
|
||||
for item in await due_items(session, start, end):
|
||||
planned = item.planned
|
||||
if planned.status is OccurrenceStatus.SKIPPED:
|
||||
counts["skipped"] += 1
|
||||
continue
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
counts["confirmed"] += 1
|
||||
else:
|
||||
counts["open"] += 1
|
||||
|
||||
if planned.kind is EntryKind.INCOME:
|
||||
planned_income += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_income += planned.effective_amount
|
||||
continue
|
||||
|
||||
planned_expenses += planned.amount
|
||||
if planned.status is OccurrenceStatus.CONFIRMED:
|
||||
actual_expenses += planned.effective_amount
|
||||
# Für die Fix/Variabel-Aufteilung zählt der beste bekannte Wert.
|
||||
betrag = planned.effective_amount
|
||||
if fixed_costs.get(item.recurrence.category_id, False):
|
||||
fixed += betrag
|
||||
else:
|
||||
variable += betrag
|
||||
per_category[item.recurrence.category_id] = (
|
||||
per_category.get(item.recurrence.category_id, ZERO) + betrag
|
||||
)
|
||||
|
||||
# Einmalige Buchungen sind immer Ist und zugleich Teil des Plans.
|
||||
stmt = select(Transaction).where(
|
||||
Transaction.booking_date >= start, Transaction.booking_date <= end
|
||||
)
|
||||
for transaction in (await session.execute(stmt)).scalars():
|
||||
if transaction.kind is EntryKind.INCOME:
|
||||
planned_income += transaction.amount
|
||||
actual_income += transaction.amount
|
||||
continue
|
||||
planned_expenses += transaction.amount
|
||||
actual_expenses += transaction.amount
|
||||
if fixed_costs.get(transaction.category_id, False):
|
||||
fixed += transaction.amount
|
||||
else:
|
||||
variable += transaction.amount
|
||||
per_category[transaction.category_id] = (
|
||||
per_category.get(transaction.category_id, ZERO) + transaction.amount
|
||||
)
|
||||
|
||||
return (
|
||||
Totals(income=planned_income, expenses=planned_expenses),
|
||||
Totals(income=actual_income, expenses=actual_expenses),
|
||||
fixed,
|
||||
variable,
|
||||
counts,
|
||||
per_category,
|
||||
)
|
||||
) -> list[FlowEntry]:
|
||||
return await flows(session, month_start(month), month_end(month), fixed_costs=fixed_costs)
|
||||
|
||||
|
||||
async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
async def reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
"""Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung."""
|
||||
total = ZERO
|
||||
for recurrence in await load_recurrences(session):
|
||||
@@ -153,26 +231,640 @@ async def _reserve_total(session: AsyncSession, month: date) -> Decimal:
|
||||
|
||||
async def month_report(session: AsyncSession, month: date) -> MonthReport:
|
||||
"""Monatsübersicht inklusive Vergleich zum Vormonat."""
|
||||
fixed_costs = await _fixed_cost_map(session)
|
||||
current = month_start(month)
|
||||
previous = add_months(current, -1)
|
||||
fix = await fixed_cost_map(session)
|
||||
aktuell = month_start(month)
|
||||
vormonat = add_months(aktuell, -1)
|
||||
|
||||
planned, actual, fixed, variable, counts, per_category = await _month_totals(
|
||||
session, current, fixed_costs
|
||||
bewegungen = await _month_flows(session, aktuell, fix)
|
||||
vorherige = await _month_flows(session, vormonat, fix)
|
||||
|
||||
ausgaben = [entry for entry in bewegungen if entry.kind is EntryKind.EXPENSE]
|
||||
fixkosten = sum((entry.amount for entry in ausgaben if entry.is_fixed_cost), ZERO)
|
||||
variabel = sum((entry.amount for entry in ausgaben if not entry.is_fixed_cost), ZERO)
|
||||
|
||||
# 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))
|
||||
if item.planned.status is OccurrenceStatus.SKIPPED
|
||||
)
|
||||
previous_planned, previous_actual, *_ = await _month_totals(session, previous, fixed_costs)
|
||||
serien = [entry for entry in bewegungen if entry.source == "recurrence"]
|
||||
|
||||
return MonthReport(
|
||||
month=current,
|
||||
planned=planned,
|
||||
actual=actual,
|
||||
previous_planned=previous_planned,
|
||||
previous_actual=previous_actual,
|
||||
fixed_costs=fixed,
|
||||
variable_costs=variable,
|
||||
reserves=await _reserve_total(session, current),
|
||||
confirmed_count=counts["confirmed"],
|
||||
open_count=counts["open"],
|
||||
skipped_count=counts["skipped"],
|
||||
categories=per_category,
|
||||
month=aktuell,
|
||||
planned=totals_of(bewegungen),
|
||||
actual=totals_of(bewegungen, basis="effective", only_confirmed=True),
|
||||
previous_planned=totals_of(vorherige),
|
||||
previous_actual=totals_of(vorherige, basis="effective", only_confirmed=True),
|
||||
fixed_costs=fixkosten,
|
||||
variable_costs=variabel,
|
||||
reserves=await reserve_total(session, aktuell),
|
||||
confirmed_count=sum(1 for entry in serien if entry.is_confirmed),
|
||||
open_count=sum(1 for entry in serien if not entry.is_confirmed),
|
||||
skipped_count=ausgelassen,
|
||||
)
|
||||
|
||||
|
||||
# --- Forecast ------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForecastMonth:
|
||||
"""Ein Monat der Vorschau."""
|
||||
|
||||
month: date
|
||||
income: Decimal
|
||||
expenses: Decimal
|
||||
cumulative_balance: Decimal
|
||||
"""Prognostizierter Kontostand am Monatsende über alle Konten."""
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
return self.income - self.expenses
|
||||
|
||||
|
||||
async def forecast(
|
||||
session: AsyncSession, months: int = 12, start: date | None = None
|
||||
) -> 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())
|
||||
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))
|
||||
|
||||
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)
|
||||
summen = totals_of(bewegungen, basis="effective")
|
||||
laufend += summen.balance
|
||||
ergebnis.append(
|
||||
ForecastMonth(
|
||||
month=monat,
|
||||
income=summen.income,
|
||||
expenses=summen.expenses,
|
||||
cumulative_balance=laufend,
|
||||
)
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Kategorien ----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CategorySlice:
|
||||
"""Summe einer Kategorie, optional mit Unterkategorien."""
|
||||
|
||||
category_id: int
|
||||
name: str
|
||||
color: str
|
||||
icon: str
|
||||
amount: Decimal
|
||||
count: int
|
||||
children: list["CategorySlice"] = field(default_factory=list)
|
||||
|
||||
|
||||
async def category_breakdown(
|
||||
session: AsyncSession,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
kind: EntryKind = EntryKind.EXPENSE,
|
||||
) -> list[CategorySlice]:
|
||||
"""Summen je Oberkategorie mit Drilldown auf die Unterkategorien."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
bewegungen = [entry for entry in await flows(session, date_from, date_to) if entry.kind is kind]
|
||||
|
||||
summen: dict[int, Decimal] = {}
|
||||
anzahl: dict[int, int] = {}
|
||||
for entry in bewegungen:
|
||||
summen[entry.category_id] = summen.get(entry.category_id, ZERO) + entry.amount
|
||||
anzahl[entry.category_id] = anzahl.get(entry.category_id, 0) + 1
|
||||
|
||||
gruppen: dict[int, CategorySlice] = {}
|
||||
for kategorie_id, betrag in summen.items():
|
||||
kategorie = kategorien.get(kategorie_id)
|
||||
if kategorie is None:
|
||||
continue
|
||||
|
||||
wurzel_id = kategorie.parent_id or kategorie.id
|
||||
wurzel = kategorien.get(wurzel_id)
|
||||
if wurzel is None:
|
||||
continue
|
||||
|
||||
gruppe = gruppen.get(wurzel_id)
|
||||
if gruppe is None:
|
||||
gruppe = CategorySlice(
|
||||
category_id=wurzel.id,
|
||||
name=wurzel.name,
|
||||
color=wurzel.color,
|
||||
icon=wurzel.icon,
|
||||
amount=ZERO,
|
||||
count=0,
|
||||
)
|
||||
gruppen[wurzel_id] = gruppe
|
||||
|
||||
gruppe.amount += betrag
|
||||
gruppe.count += anzahl[kategorie_id]
|
||||
gruppe.children.append(
|
||||
CategorySlice(
|
||||
category_id=kategorie.id,
|
||||
name=kategorie.name,
|
||||
color=kategorie.color,
|
||||
icon=kategorie.icon,
|
||||
amount=betrag,
|
||||
count=anzahl[kategorie_id],
|
||||
)
|
||||
)
|
||||
|
||||
ergebnis = sorted(gruppen.values(), key=lambda gruppe: gruppe.amount, reverse=True)
|
||||
for gruppe in ergebnis:
|
||||
gruppe.children.sort(key=lambda kind_: kind_.amount, reverse=True)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Abo-Übersicht -------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubscriptionEntry:
|
||||
"""Ein laufender Posten mit Jahreskosten und Vertragsdaten."""
|
||||
|
||||
recurrence_id: int
|
||||
title: str
|
||||
merchant_id: int | None
|
||||
merchant_name: str | None
|
||||
category_id: int
|
||||
amount: Decimal
|
||||
annual_cost: Decimal
|
||||
monthly_cost: Decimal
|
||||
rrule: str
|
||||
is_installment: bool
|
||||
term: ContractTerm | None
|
||||
days_until_notice: int | None
|
||||
is_cancelled: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SubscriptionReport:
|
||||
entries: list[SubscriptionEntry]
|
||||
total_annual: Decimal
|
||||
total_monthly: Decimal
|
||||
"""Summe der Jahreskosten geteilt durch zwölf."""
|
||||
upcoming_deadlines: list[SubscriptionEntry]
|
||||
"""Verträge, deren Kündigungsfrist in den nächsten 60 Tagen abläuft."""
|
||||
|
||||
|
||||
NOTICE_WARNING_DAYS = 60
|
||||
|
||||
|
||||
async def subscriptions(session: AsyncSession, as_of: date | None = None) -> SubscriptionReport:
|
||||
"""Alle laufenden Ausgaben-Posten mit Jahreskosten.
|
||||
|
||||
Als Abo gilt jeder aktive wiederkehrende Ausgabenposten. Ratenzahlungen sind
|
||||
enthalten, aber als solche gekennzeichnet und zählen nicht in die Summe
|
||||
„Abos gesamt p. a.“ – ein Kredit ist keine dauerhafte Belastung.
|
||||
"""
|
||||
stichtag = as_of or today()
|
||||
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
|
||||
|
||||
eintraege: list[SubscriptionEntry] = []
|
||||
for recurrence in await load_recurrences(session, kind=EntryKind.EXPENSE):
|
||||
jahr = annual_burden(recurrence, stichtag, amount_versions=recurrence.amount_versions)
|
||||
term = contract_term(recurrence, stichtag)
|
||||
frist = term.notice_deadline if term and not term.is_cancelled else None
|
||||
|
||||
eintraege.append(
|
||||
SubscriptionEntry(
|
||||
recurrence_id=recurrence.id,
|
||||
title=recurrence.title,
|
||||
merchant_id=recurrence.merchant_id,
|
||||
merchant_name=firmen.get(recurrence.merchant_id or -1),
|
||||
category_id=recurrence.category_id,
|
||||
amount=recurrence.amount,
|
||||
annual_cost=jahr,
|
||||
monthly_cost=(jahr / 12).quantize(CENT, rounding=ROUND_HALF_UP),
|
||||
rrule=recurrence.rrule,
|
||||
is_installment=recurrence.installments_total is not None,
|
||||
term=term,
|
||||
days_until_notice=(frist - stichtag).days if frist else None,
|
||||
is_cancelled=recurrence.contract_cancelled_at is not None,
|
||||
)
|
||||
)
|
||||
|
||||
eintraege.sort(key=lambda eintrag: eintrag.annual_cost, reverse=True)
|
||||
laufend = [eintrag for eintrag in eintraege if not eintrag.is_installment]
|
||||
gesamt = sum((eintrag.annual_cost for eintrag in laufend), ZERO)
|
||||
|
||||
return SubscriptionReport(
|
||||
entries=eintraege,
|
||||
total_annual=gesamt,
|
||||
total_monthly=(gesamt / 12).quantize(CENT, rounding=ROUND_HALF_UP),
|
||||
upcoming_deadlines=sorted(
|
||||
(
|
||||
eintrag
|
||||
for eintrag in eintraege
|
||||
if eintrag.days_until_notice is not None
|
||||
and 0 <= eintrag.days_until_notice <= NOTICE_WARNING_DAYS
|
||||
),
|
||||
key=lambda eintrag: eintrag.days_until_notice or 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --- Jahresvergleich -----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class YearComparisonRow:
|
||||
category_id: int
|
||||
name: str
|
||||
color: str
|
||||
current: Decimal
|
||||
previous: Decimal
|
||||
|
||||
@property
|
||||
def delta(self) -> Decimal:
|
||||
return self.current - self.previous
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class YearComparison:
|
||||
year: int
|
||||
rows: list[YearComparisonRow]
|
||||
current_total: Decimal
|
||||
previous_total: Decimal
|
||||
|
||||
|
||||
async def year_comparison(
|
||||
session: AsyncSession, year: int, kind: EntryKind = EntryKind.EXPENSE
|
||||
) -> YearComparison:
|
||||
"""Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
|
||||
async def summen(jahr: int) -> dict[int, Decimal]:
|
||||
ergebnis: dict[int, Decimal] = {}
|
||||
for entry in await flows(session, date(jahr, 1, 1), date(jahr, 12, 31)):
|
||||
if entry.kind is not kind:
|
||||
continue
|
||||
kategorie = kategorien.get(entry.category_id)
|
||||
if kategorie is None:
|
||||
continue
|
||||
wurzel = kategorie.parent_id or kategorie.id
|
||||
ergebnis[wurzel] = ergebnis.get(wurzel, ZERO) + entry.amount
|
||||
return ergebnis
|
||||
|
||||
aktuell = await summen(year)
|
||||
vorher = await summen(year - 1)
|
||||
|
||||
zeilen: list[YearComparisonRow] = []
|
||||
for kategorie_id in set(aktuell) | set(vorher):
|
||||
kategorie = kategorien.get(kategorie_id)
|
||||
if kategorie is None:
|
||||
continue
|
||||
zeilen.append(
|
||||
YearComparisonRow(
|
||||
category_id=kategorie_id,
|
||||
name=kategorie.name,
|
||||
color=kategorie.color,
|
||||
current=aktuell.get(kategorie_id, ZERO),
|
||||
previous=vorher.get(kategorie_id, ZERO),
|
||||
)
|
||||
)
|
||||
|
||||
zeilen.sort(key=lambda zeile: zeile.current, reverse=True)
|
||||
return YearComparison(
|
||||
year=year,
|
||||
rows=zeilen,
|
||||
current_total=sum((zeile.current for zeile in zeilen), ZERO),
|
||||
previous_total=sum((zeile.previous for zeile in zeilen), ZERO),
|
||||
)
|
||||
|
||||
|
||||
# --- Cashflow-Kalender ---------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CalendarDay:
|
||||
"""Ein Tag im Monatsraster."""
|
||||
|
||||
on: date
|
||||
entries: list[FlowEntry]
|
||||
net: Decimal
|
||||
running_balance: Decimal
|
||||
is_business_day: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CalendarMonth:
|
||||
month: date
|
||||
days: list[CalendarDay]
|
||||
opening_balance: Decimal
|
||||
closing_balance: Decimal
|
||||
lowest_balance: Decimal
|
||||
lowest_balance_on: date | None
|
||||
|
||||
|
||||
async def calendar_month(
|
||||
session: AsyncSession, month: date, *, 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)
|
||||
|
||||
eroeffnung = await total_balance(session, beginn - timedelta(days=1))
|
||||
bewegungen = await flows(session, beginn, ende)
|
||||
|
||||
nach_tag: dict[date, list[FlowEntry]] = {}
|
||||
for entry in bewegungen:
|
||||
nach_tag.setdefault(entry.on, []).append(entry)
|
||||
|
||||
laufend = eroeffnung
|
||||
tiefstand = eroeffnung
|
||||
tiefstand_am: date | None = None
|
||||
tage: list[CalendarDay] = []
|
||||
|
||||
tag = beginn
|
||||
while tag <= ende:
|
||||
eintraege = nach_tag.get(tag, [])
|
||||
netto = sum((entry.signed for entry in eintraege), ZERO)
|
||||
laufend += netto
|
||||
if laufend < tiefstand:
|
||||
tiefstand = laufend
|
||||
tiefstand_am = tag
|
||||
|
||||
tage.append(
|
||||
CalendarDay(
|
||||
on=tag,
|
||||
entries=eintraege,
|
||||
net=netto,
|
||||
running_balance=laufend,
|
||||
is_business_day=is_business_day(tag, holiday_region),
|
||||
)
|
||||
)
|
||||
tag += timedelta(days=1)
|
||||
|
||||
return CalendarMonth(
|
||||
month=beginn,
|
||||
days=tage,
|
||||
opening_balance=eroeffnung,
|
||||
closing_balance=laufend,
|
||||
lowest_balance=tiefstand,
|
||||
lowest_balance_on=tiefstand_am,
|
||||
)
|
||||
|
||||
|
||||
# --- Budget-Ampel --------------------------------------------------------------
|
||||
|
||||
BudgetState = Literal["ok", "warning", "exceeded"]
|
||||
|
||||
# Schwellen der Ampel gemäß Fachspezifikation.
|
||||
WARNING_RATIO = Decimal("0.8")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BudgetStatus:
|
||||
"""Stand eines Budgets im Monat."""
|
||||
|
||||
category_id: int
|
||||
category_name: str
|
||||
color: str
|
||||
period_month: date
|
||||
limit_amount: Decimal
|
||||
carried_over: Decimal
|
||||
"""Übertrag aus Vormonaten, falls `rollover` gesetzt ist."""
|
||||
spent: Decimal
|
||||
rollover: bool
|
||||
is_template: bool
|
||||
"""True, wenn der Wert aus einer Vorlage stammt und nicht aus einem Einzelbudget."""
|
||||
|
||||
@property
|
||||
def available(self) -> Decimal:
|
||||
return self.limit_amount + self.carried_over
|
||||
|
||||
@property
|
||||
def remaining(self) -> Decimal:
|
||||
return self.available - self.spent
|
||||
|
||||
@property
|
||||
def ratio(self) -> Decimal:
|
||||
if self.available <= ZERO:
|
||||
return Decimal("1") if self.spent > ZERO else ZERO
|
||||
return self.spent / self.available
|
||||
|
||||
@property
|
||||
def state(self) -> BudgetState:
|
||||
"""Grün unter 80 %, gelb unter 100 %, darüber rot."""
|
||||
if self.ratio < WARNING_RATIO:
|
||||
return "ok"
|
||||
if self.ratio < Decimal("1"):
|
||||
return "warning"
|
||||
return "exceeded"
|
||||
|
||||
|
||||
async def _effective_limits(
|
||||
session: AsyncSession, month: date
|
||||
) -> dict[int, tuple[Decimal, bool, bool]]:
|
||||
"""Gültige Budgets eines Monats: Kategorie -> (Betrag, rollover, aus Vorlage).
|
||||
|
||||
Ein ausdrücklich gepflegtes Budget schlägt immer die Vorlage.
|
||||
"""
|
||||
monat = month_start(month)
|
||||
ergebnis: dict[int, tuple[Decimal, bool, bool]] = {}
|
||||
|
||||
stmt = select(BudgetTemplate).where(BudgetTemplate.valid_from <= monat)
|
||||
vorlagen = list((await session.execute(stmt)).scalars())
|
||||
# Die jüngste passende Vorlage je Kategorie gewinnt.
|
||||
vorlagen.sort(key=lambda vorlage: vorlage.valid_from)
|
||||
for vorlage in vorlagen:
|
||||
if vorlage.valid_until is not None and vorlage.valid_until < monat:
|
||||
continue
|
||||
ergebnis[vorlage.category_id] = (vorlage.limit_amount, vorlage.rollover, True)
|
||||
|
||||
stmt = select(Budget).where(Budget.period_month == monat)
|
||||
for budget in (await session.execute(stmt)).scalars():
|
||||
ergebnis[budget.category_id] = (budget.limit_amount, budget.rollover, False)
|
||||
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _spent_by_category(session: AsyncSession, month: date) -> dict[int, Decimal]:
|
||||
"""Ausgaben eines Monats je Kategorie, Unterkategorien auf die Oberkategorie gerollt."""
|
||||
kategorien = {
|
||||
kategorie.id: kategorie.parent_id
|
||||
for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
ergebnis: dict[int, Decimal] = {}
|
||||
for entry in await flows(session, month_start(month), month_end(month)):
|
||||
if entry.kind is not EntryKind.EXPENSE:
|
||||
continue
|
||||
# Ein Budget auf der Oberkategorie umfasst auch deren Unterkategorien.
|
||||
ergebnis[entry.category_id] = ergebnis.get(entry.category_id, ZERO) + entry.amount
|
||||
eltern = kategorien.get(entry.category_id)
|
||||
if eltern is not None:
|
||||
ergebnis[eltern] = ergebnis.get(eltern, ZERO) + entry.amount
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def budget_status(session: AsyncSession, month: date) -> list[BudgetStatus]:
|
||||
"""Budgets eines Monats samt Verbrauch und Übertrag."""
|
||||
monat = month_start(month)
|
||||
limits = await _effective_limits(session, monat)
|
||||
if not limits:
|
||||
return []
|
||||
|
||||
namen = {
|
||||
kategorie.id: kategorie for kategorie in (await session.execute(select(Category))).scalars()
|
||||
}
|
||||
ausgaben = await _spent_by_category(session, monat)
|
||||
|
||||
ergebnis: list[BudgetStatus] = []
|
||||
for kategorie_id, (limit, rollover, aus_vorlage) in limits.items():
|
||||
kategorie = namen.get(kategorie_id)
|
||||
if kategorie is None:
|
||||
continue
|
||||
|
||||
uebertrag = await _carry_over(session, kategorie_id, monat) if rollover else ZERO
|
||||
ergebnis.append(
|
||||
BudgetStatus(
|
||||
category_id=kategorie_id,
|
||||
category_name=kategorie.name,
|
||||
color=kategorie.color,
|
||||
period_month=monat,
|
||||
limit_amount=limit,
|
||||
carried_over=uebertrag,
|
||||
spent=ausgaben.get(kategorie_id, ZERO),
|
||||
rollover=rollover,
|
||||
is_template=aus_vorlage,
|
||||
)
|
||||
)
|
||||
|
||||
ergebnis.sort(key=lambda eintrag: eintrag.ratio, reverse=True)
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def _carry_over(session: AsyncSession, category_id: int, month: date) -> Decimal:
|
||||
"""Nicht verbrauchtes Budget aus den Vormonaten.
|
||||
|
||||
Es wird höchstens ein Jahr zurückgeschaut; ein Überschreiten setzt den
|
||||
Übertrag wieder auf null, statt eine Schuld weiterzureichen.
|
||||
"""
|
||||
uebertrag = ZERO
|
||||
for versatz in range(ROLLOVER_LOOKBACK_MONTHS, 0, -1):
|
||||
vormonat = add_months(month, -versatz)
|
||||
limits = await _effective_limits(session, vormonat)
|
||||
eintrag = limits.get(category_id)
|
||||
if eintrag is None:
|
||||
uebertrag = ZERO
|
||||
continue
|
||||
|
||||
limit, rollover, _ = eintrag
|
||||
if not rollover:
|
||||
uebertrag = ZERO
|
||||
continue
|
||||
|
||||
ausgaben = (await _spent_by_category(session, vormonat)).get(category_id, ZERO)
|
||||
uebertrag = max(limit + uebertrag - ausgaben, ZERO)
|
||||
return uebertrag
|
||||
|
||||
|
||||
# --- Sparziele -----------------------------------------------------------------
|
||||
|
||||
|
||||
def months_between(start: date, end: date) -> int:
|
||||
"""Volle Monate zwischen zwei Daten, mindestens null."""
|
||||
return max((end.year - start.year) * 12 + end.month - start.month, 0)
|
||||
|
||||
|
||||
def required_monthly_rate(
|
||||
target_amount: Decimal, current_amount: Decimal, target_date: date | None, as_of: date
|
||||
) -> Decimal | None:
|
||||
"""Rate, die bis zum Zieldatum monatlich nötig ist. `None` ohne Zieldatum."""
|
||||
if target_date is None:
|
||||
return None
|
||||
fehlend = target_amount - current_amount
|
||||
if fehlend <= ZERO:
|
||||
return ZERO
|
||||
monate = months_between(as_of, target_date)
|
||||
if monate <= 0:
|
||||
return fehlend
|
||||
return (fehlend / monate).quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
# --- Gemeinsame Helfer für den Export ------------------------------------------
|
||||
|
||||
|
||||
def flow_rows(entries: Sequence[FlowEntry], names: dict[int, str]) -> list[dict[str, object]]:
|
||||
"""Bereitet Bewegungen als Zeilen für den Export auf."""
|
||||
return [
|
||||
{
|
||||
"Datum": entry.on.isoformat(),
|
||||
"Titel": entry.title,
|
||||
"Kategorie": names.get(entry.category_id, ""),
|
||||
"Richtung": "Einkunft" if entry.kind is EntryKind.INCOME else "Ausgabe",
|
||||
"Betrag": entry.amount,
|
||||
"Herkunft": "Serie" if entry.source == "recurrence" else "Einmalig",
|
||||
"Bestätigt": "ja" if entry.is_confirmed else "nein",
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
|
||||
async def category_names(session: AsyncSession) -> dict[int, str]:
|
||||
"""Kategorie-ID -> „Oberkategorie · Unterkategorie“."""
|
||||
kategorien = list((await session.execute(select(Category))).scalars())
|
||||
nach_id = {kategorie.id: kategorie for kategorie in kategorien}
|
||||
|
||||
namen: dict[int, str] = {}
|
||||
for kategorie in kategorien:
|
||||
if kategorie.parent_id is None:
|
||||
namen[kategorie.id] = kategorie.name
|
||||
else:
|
||||
eltern = nach_id.get(kategorie.parent_id)
|
||||
namen[kategorie.id] = f"{eltern.name} · {kategorie.name}" if eltern else kategorie.name
|
||||
return namen
|
||||
|
||||
|
||||
async def recurrence_rows(
|
||||
session: AsyncSession, as_of: date | None = None
|
||||
) -> list[dict[str, object]]:
|
||||
"""Wiederkehrende Posten als Exportzeilen."""
|
||||
stichtag = as_of or today()
|
||||
namen = await category_names(session)
|
||||
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
|
||||
|
||||
stmt = select(Recurrence).order_by(Recurrence.title)
|
||||
zeilen: list[dict[str, object]] = []
|
||||
for recurrence in (await session.execute(stmt)).scalars():
|
||||
zeilen.append(
|
||||
{
|
||||
"Titel": recurrence.title,
|
||||
"Richtung": "Einkunft" if recurrence.kind is EntryKind.INCOME else "Ausgabe",
|
||||
"Betrag": recurrence.amount,
|
||||
"Wiederholung": recurrence.rrule,
|
||||
"Beginn": recurrence.dtstart.isoformat(),
|
||||
"Ende": recurrence.until.isoformat() if recurrence.until else "",
|
||||
"Kategorie": namen.get(recurrence.category_id, ""),
|
||||
"Firma": firmen.get(recurrence.merchant_id or -1, ""),
|
||||
"Jahreskosten": annual_burden(
|
||||
recurrence, stichtag, amount_versions=recurrence.amount_versions
|
||||
),
|
||||
"Raten": recurrence.installments_total or "",
|
||||
"Gekündigt zum": (
|
||||
recurrence.contract_cancelled_at.isoformat()
|
||||
if recurrence.contract_cancelled_at
|
||||
else ""
|
||||
),
|
||||
"Aktiv": "ja" if recurrence.is_active else "nein",
|
||||
}
|
||||
)
|
||||
return zeilen
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Integrationstests der Auswertungen."""
|
||||
|
||||
import io
|
||||
|
||||
from httpx import AsyncClient
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
async def posten(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
async def haushalt(client: AsyncClient, seeded: dict) -> None:
|
||||
"""Ein realistisches Grundgerüst: Gehalt, Miete, Abo, jährliche Versicherung."""
|
||||
await posten(
|
||||
client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3200.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
await posten(
|
||||
client,
|
||||
seeded,
|
||||
title="Miete",
|
||||
category_id=seeded["miete"],
|
||||
amount="950.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
)
|
||||
await posten(client, seeded)
|
||||
await posten(
|
||||
client,
|
||||
seeded,
|
||||
title="Kfz-Versicherung",
|
||||
category_id=seeded["miete"],
|
||||
amount="612.00",
|
||||
rrule="FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1",
|
||||
dtstart="2026-07-01",
|
||||
reserve_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
# --- Forecast ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_forecast_setzt_jaehrliche_posten_in_den_richtigen_monat(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Akzeptanzkriterium: der jährliche Posten taucht nur im Juli auf."""
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.get(
|
||||
"/api/reports/forecast", params={"months": 12, "start": "2026-01-01"}
|
||||
)
|
||||
assert antwort.status_code == 200
|
||||
monate = antwort.json()["months"]
|
||||
|
||||
assert len(monate) == 12
|
||||
assert monate[0]["month"] == "2026-01-01"
|
||||
assert monate[11]["month"] == "2026-12-01"
|
||||
|
||||
nach_monat = {monat["month"]: monat for monat in monate}
|
||||
# Januar bis Juni: Miete 950 + Netflix 13,99
|
||||
assert nach_monat["2026-01-01"]["expenses"] == "963.99"
|
||||
assert nach_monat["2026-06-01"]["expenses"] == "963.99"
|
||||
# Juli zusätzlich die Jahresprämie
|
||||
assert nach_monat["2026-07-01"]["expenses"] == "1575.99"
|
||||
assert nach_monat["2026-08-01"]["expenses"] == "963.99"
|
||||
|
||||
|
||||
async def test_forecast_summiert_den_kontostand_auf(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
monate = (
|
||||
await auth_client.get("/api/reports/forecast", params={"months": 3, "start": "2026-01-01"})
|
||||
).json()["months"]
|
||||
|
||||
# Eröffnungssaldo 1000 plus dem Saldo jedes Monats.
|
||||
assert monate[0]["balance"] == "2236.01"
|
||||
assert monate[0]["cumulative_balance"] == "3236.01"
|
||||
assert monate[1]["cumulative_balance"] == "5472.02"
|
||||
assert monate[2]["cumulative_balance"] == "7708.03"
|
||||
|
||||
|
||||
async def test_forecast_ohne_daten_ist_flach(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
monate = (await auth_client.get("/api/reports/forecast", params={"months": 2})).json()["months"]
|
||||
|
||||
assert all(monat["income"] == "0.00" for monat in monate)
|
||||
assert all(monat["cumulative_balance"] == "1000.00" for monat in monate)
|
||||
|
||||
|
||||
# --- Kategorien ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kategorien_mit_drilldown(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
bericht = (
|
||||
await auth_client.get(
|
||||
"/api/reports/categories", params={"from": "2026-03-01", "to": "2026-03-31"}
|
||||
)
|
||||
).json()
|
||||
|
||||
assert bericht["total"] == "963.99"
|
||||
namen = {gruppe["name"]: gruppe for gruppe in bericht["categories"]}
|
||||
assert namen["Wohnen"]["amount"] == "950.00"
|
||||
# Der Drilldown zeigt die Unterkategorie.
|
||||
assert [kind["name"] for kind in namen["Wohnen"]["children"]] == ["Miete"]
|
||||
assert namen["Abos & Medien"]["amount"] == "13.99"
|
||||
|
||||
|
||||
async def test_kategorien_koennen_einkuenfte_zeigen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
bericht = (
|
||||
await auth_client.get(
|
||||
"/api/reports/categories",
|
||||
params={"from": "2026-03-01", "to": "2026-03-31", "kind": "income"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert bericht["total"] == "3200.00"
|
||||
assert bericht["categories"][0]["name"] == "Einkünfte"
|
||||
|
||||
|
||||
async def test_kategorien_weisen_verdrehten_zeitraum_ab(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.get(
|
||||
"/api/reports/categories", params={"from": "2026-03-31", "to": "2026-03-01"}
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "invalid_date_range"
|
||||
|
||||
|
||||
# --- Abo-Übersicht -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_aboübersicht_mit_jahreskosten(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
|
||||
|
||||
nach_titel = {eintrag["title"]: eintrag for eintrag in bericht["entries"]}
|
||||
assert nach_titel["Miete"]["annual_cost"] == "11400.00"
|
||||
assert nach_titel["Netflix"]["annual_cost"] == "167.88"
|
||||
assert nach_titel["Kfz-Versicherung"]["annual_cost"] == "612.00"
|
||||
assert nach_titel["Kfz-Versicherung"]["monthly_cost"] == "51.00"
|
||||
|
||||
# Absteigend nach Jahreskosten sortiert.
|
||||
assert next(eintrag["title"] for eintrag in bericht["entries"]) == "Miete"
|
||||
assert bericht["total_annual"] == "12179.88"
|
||||
assert bericht["total_monthly"] == "1014.99"
|
||||
|
||||
|
||||
async def test_raten_zaehlen_nicht_in_die_abosumme(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await posten(auth_client, seeded)
|
||||
await posten(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Autokredit",
|
||||
category_id=seeded["kredite"],
|
||||
amount="250.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
installments_total=36,
|
||||
)
|
||||
|
||||
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
|
||||
|
||||
kredit = next(e for e in bericht["entries"] if e["title"] == "Autokredit")
|
||||
assert kredit["is_installment"] is True
|
||||
# Nur Netflix zählt in die Summe.
|
||||
assert bericht["total_annual"] == "167.88"
|
||||
|
||||
|
||||
async def test_ablaufende_kuendigungsfrist_wird_gemeldet(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Vertrag so legen, dass der Kündigungstermin in 30 Tagen liegt.
|
||||
heute = date.today()
|
||||
laufzeitende = heute + timedelta(days=30 + 90)
|
||||
vertragsbeginn = laufzeitende + timedelta(days=1) - timedelta(days=365)
|
||||
|
||||
await posten(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart=vertragsbeginn.isoformat(),
|
||||
contract_start=vertragsbeginn.isoformat(),
|
||||
contract_min_term_months=12,
|
||||
contract_notice_period_days=90,
|
||||
)
|
||||
|
||||
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
|
||||
|
||||
fristen = {eintrag["title"] for eintrag in bericht["upcoming_deadlines"]}
|
||||
assert "Handyvertrag" in fristen
|
||||
eintrag = next(e for e in bericht["entries"] if e["title"] == "Handyvertrag")
|
||||
assert 25 <= eintrag["days_until_notice"] <= 35
|
||||
|
||||
|
||||
# --- Jahresvergleich -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_jahresvergleich(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
# Ein Abo, das erst 2026 beginnt – 2025 gibt es also nichts.
|
||||
await posten(auth_client, seeded, amount="10.00")
|
||||
|
||||
bericht = (await auth_client.get("/api/reports/year-comparison", params={"year": 2026})).json()
|
||||
|
||||
assert bericht["year"] == 2026
|
||||
zeile = next(z for z in bericht["rows"] if z["name"] == "Abos & Medien")
|
||||
assert zeile["current"] == "120.00" # zwölf Monate à 10
|
||||
assert zeile["previous"] == "0.00"
|
||||
assert zeile["delta"] == "120.00"
|
||||
|
||||
|
||||
# --- Kalender ------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kalender_zeigt_faelligkeiten_und_verlauf(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
kalender = (
|
||||
await auth_client.get("/api/reports/calendar", params={"month": "2026-03-01"})
|
||||
).json()
|
||||
|
||||
assert kalender["month"] == "2026-03-01"
|
||||
assert len(kalender["days"]) == 31
|
||||
|
||||
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
|
||||
# Am 1. März ist die Miete fällig.
|
||||
assert [eintrag["title"] for eintrag in nach_tag["2026-03-01"]["entries"]] == ["Miete"]
|
||||
assert nach_tag["2026-03-01"]["net"] == "-950.00"
|
||||
# Der 15. trägt Netflix, der 28. das Gehalt.
|
||||
assert [eintrag["title"] for eintrag in nach_tag["2026-03-15"]["entries"]] == ["Netflix"]
|
||||
assert [eintrag["title"] for eintrag in nach_tag["2026-03-28"]["entries"]] == ["Gehalt"]
|
||||
|
||||
# Der laufende Kontostand schreibt sich über den Monat fort.
|
||||
# Eröffnung 1000, nach der Miete am 1. noch 50, nach dem Gehalt am 28. wieder hoch.
|
||||
assert nach_tag["2026-03-01"]["running_balance"] == "50.00"
|
||||
assert nach_tag["2026-03-15"]["running_balance"] == "36.01"
|
||||
assert nach_tag["2026-03-28"]["running_balance"] == "3236.01"
|
||||
assert kalender["closing_balance"] == nach_tag["2026-03-31"]["running_balance"]
|
||||
assert kalender["lowest_balance_on"] is not None
|
||||
|
||||
|
||||
async def test_kalender_kennzeichnet_werktage(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
kalender = (
|
||||
await auth_client.get("/api/reports/calendar", params={"month": "2026-04-01"})
|
||||
).json()
|
||||
|
||||
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
|
||||
assert nach_tag["2026-04-03"]["is_business_day"] is False # Karfreitag
|
||||
assert nach_tag["2026-04-06"]["is_business_day"] is False # Ostermontag
|
||||
assert nach_tag["2026-04-07"]["is_business_day"] is True
|
||||
|
||||
|
||||
async def test_kalender_liefert_das_nominale_datum_mit(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der Kalender muss Bestätigen ermöglichen – dafür braucht er den Schlüssel."""
|
||||
eintrag = await posten(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-02-01",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
kalender = (
|
||||
await auth_client.get("/api/reports/calendar", params={"month": "2026-02-01"})
|
||||
).json()
|
||||
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
|
||||
|
||||
# 01.02.2026 ist ein Sonntag; gezahlt wird am 02.02.
|
||||
assert nach_tag["2026-02-01"]["entries"] == []
|
||||
posten_eintrag = nach_tag["2026-02-02"]["entries"][0]
|
||||
assert posten_eintrag["occurrence_date"] == "2026-02-01"
|
||||
assert posten_eintrag["recurrence_id"] == eintrag["id"]
|
||||
assert posten_eintrag["status"] == "planned"
|
||||
|
||||
|
||||
# --- Budget-Ampel --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_budget_ampel_schaltet_richtig(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": "2026-03-01",
|
||||
"limit_amount": "400.00",
|
||||
},
|
||||
)
|
||||
|
||||
async def verbrauche(betrag: str) -> None:
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Einkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": betrag,
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
|
||||
async def status() -> dict:
|
||||
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})
|
||||
return antwort.json()[0]
|
||||
|
||||
await verbrauche("100.00")
|
||||
assert (await status())["state"] == "ok" # 25 %
|
||||
|
||||
await verbrauche("219.00")
|
||||
assert (await status())["state"] == "ok" # 79,75 %
|
||||
|
||||
await verbrauche("2.00")
|
||||
aktuell = await status()
|
||||
assert aktuell["state"] == "warning" # 80,25 %
|
||||
assert aktuell["spent"] == "321.00"
|
||||
|
||||
await verbrauche("100.00")
|
||||
ueberschritten = await status()
|
||||
assert ueberschritten["state"] == "exceeded"
|
||||
assert ueberschritten["remaining"] == "-21.00"
|
||||
|
||||
|
||||
async def test_budget_auf_oberkategorie_umfasst_unterkategorien(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
baum = (await auth_client.get("/api/categories")).json()
|
||||
lebenshaltung = next(k for k in baum if k["name"] == "Lebenshaltung")
|
||||
|
||||
await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": lebenshaltung["id"],
|
||||
"period_month": "2026-03-01",
|
||||
"limit_amount": "500.00",
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Einkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "120.00",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
|
||||
status = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
|
||||
|
||||
assert status[0]["spent"] == "120.00"
|
||||
|
||||
|
||||
async def test_budgetvorlage_gilt_ab_dem_monat(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await auth_client.post(
|
||||
"/api/budget-templates",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"valid_from": "2026-02-01",
|
||||
"limit_amount": "450.00",
|
||||
},
|
||||
)
|
||||
|
||||
januar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-01-01"})).json()
|
||||
maerz = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
|
||||
|
||||
assert januar == []
|
||||
assert maerz[0]["limit_amount"] == "450.00"
|
||||
assert maerz[0]["is_template"] is True
|
||||
|
||||
|
||||
async def test_rollover_uebertraegt_den_rest(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
for monat in ("2026-01-01", "2026-02-01"):
|
||||
await auth_client.post(
|
||||
"/api/budgets",
|
||||
json={
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"period_month": monat,
|
||||
"limit_amount": "400.00",
|
||||
"rollover": True,
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Einkauf Januar",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "300.00",
|
||||
"booking_date": "2026-01-10",
|
||||
},
|
||||
)
|
||||
|
||||
februar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-02-01"})).json()
|
||||
|
||||
assert februar[0]["carried_over"] == "100.00"
|
||||
assert februar[0]["available"] == "500.00"
|
||||
|
||||
|
||||
# --- Sparziele -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sparziel_fortschritt_und_rate(auth_client: AsyncClient) -> None:
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
ziel_datum = date.today() + relativedelta(months=10)
|
||||
await auth_client.post(
|
||||
"/api/savings-goals",
|
||||
json={
|
||||
"name": "Neues Fahrrad",
|
||||
"target_amount": "2000.00",
|
||||
"current_amount": "500.00",
|
||||
"target_date": ziel_datum.isoformat(),
|
||||
"monthly_contribution": "100.00",
|
||||
},
|
||||
)
|
||||
|
||||
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
|
||||
|
||||
assert fortschritt["remaining_amount"] == "1500.00"
|
||||
assert fortschritt["ratio"] == 0.25
|
||||
assert fortschritt["months_left"] == 10
|
||||
assert fortschritt["required_monthly"] == "150.00"
|
||||
# 100 Euro reichen bei 150 nötigen nicht.
|
||||
assert fortschritt["is_on_track"] is False
|
||||
|
||||
|
||||
async def test_erreichtes_sparziel_braucht_keine_rate(auth_client: AsyncClient) -> None:
|
||||
from datetime import date
|
||||
|
||||
await auth_client.post(
|
||||
"/api/savings-goals",
|
||||
json={
|
||||
"name": "Urlaubskasse",
|
||||
"target_amount": "1000.00",
|
||||
"current_amount": "1000.00",
|
||||
"target_date": (date.today().replace(year=date.today().year + 1)).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
|
||||
|
||||
assert fortschritt["ratio"] == 1.0
|
||||
assert fortschritt["required_monthly"] == "0.00"
|
||||
|
||||
|
||||
# --- Dashboard -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dashboard_buendelt_alles(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
dashboard = (
|
||||
await auth_client.get("/api/reports/dashboard", params={"month": "2026-03-01"})
|
||||
).json()
|
||||
|
||||
assert dashboard["month"]["month"] == "2026-03-01"
|
||||
assert dashboard["month"]["available_after_fixed"] == "2185.01"
|
||||
assert len(dashboard["forecast"]) == 12
|
||||
assert len(dashboard["categories"]) == 2
|
||||
assert dashboard["total_balance"] == "1000.00"
|
||||
assert dashboard["budgets"] == []
|
||||
assert dashboard["goals"] == []
|
||||
|
||||
|
||||
# --- Export --------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_export_buchungen_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
|
||||
antwort = await auth_client.get(
|
||||
"/api/export/transactions",
|
||||
params={"format": "csv", "from": "2026-01-01", "to": "2026-12-31"},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.headers["content-type"].startswith("text/csv")
|
||||
assert "moneyfy-buchungen" in antwort.headers["content-disposition"]
|
||||
|
||||
inhalt = antwort.content
|
||||
# Excel erkennt UTF-8 nur mit BOM zuverlässig.
|
||||
assert inhalt.startswith(b"\xef\xbb\xbf")
|
||||
text = inhalt.decode("utf-8-sig")
|
||||
zeilen = text.strip().splitlines()
|
||||
assert zeilen[0].startswith("Datum;Titel;Richtung;Betrag")
|
||||
# Deutsches Dezimaltrennzeichen.
|
||||
assert "84,30" in zeilen[1]
|
||||
assert "Lebenshaltung · Lebensmittel" in zeilen[1]
|
||||
|
||||
|
||||
async def test_export_buchungen_als_xlsx(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-03-05",
|
||||
},
|
||||
)
|
||||
|
||||
antwort = await auth_client.get("/api/export/transactions", params={"format": "xlsx"})
|
||||
|
||||
assert antwort.status_code == 200
|
||||
assert antwort.content[:2] == b"PK"
|
||||
|
||||
mappe = load_workbook(io.BytesIO(antwort.content))
|
||||
blatt = mappe["Buchungen"]
|
||||
kopf = [zelle.value for zelle in blatt[1]]
|
||||
assert kopf[:4] == ["Datum", "Titel", "Richtung", "Betrag"]
|
||||
# Der Betrag ist eine Zahl, keine Zeichenkette – Excel kann damit rechnen.
|
||||
assert blatt.cell(row=2, column=4).value == 84.3
|
||||
assert "€" in blatt.cell(row=2, column=4).number_format
|
||||
|
||||
|
||||
async def test_export_posten(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.get("/api/export/recurrences", params={"format": "xlsx"})
|
||||
|
||||
mappe = load_workbook(io.BytesIO(antwort.content))
|
||||
blatt = mappe["Posten"]
|
||||
titel = [blatt.cell(row=zeile, column=1).value for zeile in range(2, blatt.max_row + 1)]
|
||||
assert set(titel) == {"Gehalt", "Kfz-Versicherung", "Miete", "Netflix"}
|
||||
|
||||
|
||||
async def test_export_monatsauswertung(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.get(
|
||||
"/api/export/month", params={"format": "xlsx", "month": "2026-03-01"}
|
||||
)
|
||||
|
||||
mappe = load_workbook(io.BytesIO(antwort.content))
|
||||
assert mappe.sheetnames == ["Bewegungen", "Kennzahlen"]
|
||||
|
||||
bewegungen = mappe["Bewegungen"]
|
||||
titel = [bewegungen.cell(row=zeile, column=2).value for zeile in range(2, 5)]
|
||||
assert set(titel) == {"Miete", "Netflix", "Gehalt"}
|
||||
|
||||
kennzahlen = {
|
||||
mappe["Kennzahlen"].cell(row=zeile, column=1).value: mappe["Kennzahlen"]
|
||||
.cell(row=zeile, column=2)
|
||||
.value
|
||||
for zeile in range(2, mappe["Kennzahlen"].max_row + 1)
|
||||
}
|
||||
assert kennzahlen["Ausgaben (Plan)"] == 963.99
|
||||
assert kennzahlen["Einnahmen (Plan)"] == 3200.0
|
||||
|
||||
|
||||
async def test_export_monatsauswertung_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await haushalt(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.get(
|
||||
"/api/export/month", params={"format": "csv", "month": "2026-03-01"}
|
||||
)
|
||||
|
||||
text = antwort.content.decode("utf-8-sig")
|
||||
assert "Datum;Titel;Kategorie" in text
|
||||
# Die Kennzahlen folgen nach einer Leerzeile im selben Dokument.
|
||||
assert "Kennzahl;Betrag" in text
|
||||
assert "Verfügbar nach Fixkosten" in text
|
||||
|
||||
|
||||
async def test_export_weist_verdrehten_zeitraum_ab(auth_client: AsyncClient) -> None:
|
||||
antwort = await auth_client.get(
|
||||
"/api/export/transactions", params={"from": "2026-12-31", "to": "2026-01-01"}
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "invalid_date_range"
|
||||
|
||||
|
||||
async def test_export_verlangt_anmeldung(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/export/transactions")).status_code == 401
|
||||
+11
-2
@@ -5,8 +5,13 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
import { Skeleton } from "@/components/ui/Feedback";
|
||||
import { useMe } from "@/hooks/useAuth";
|
||||
import { BudgetsPage } from "@/pages/BudgetsPage";
|
||||
import { CalendarPage } from "@/pages/CalendarPage";
|
||||
import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { GoalsPage } from "@/pages/GoalsPage";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { ReportsPage } from "@/pages/ReportsPage";
|
||||
import { MerchantsPage } from "@/pages/MerchantsPage";
|
||||
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
||||
import { SettingsPage } from "@/pages/SettingsPage";
|
||||
@@ -31,12 +36,16 @@ export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<Navigate to="/recurrences" replace />} />
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/recurrences" element={<RecurrencesPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/merchants" element={<MerchantsPage />} />
|
||||
<Route path="/budgets" element={<BudgetsPage />} />
|
||||
<Route path="/goals" element={<GoalsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/recurrences" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Tests der Budget-Ampel. */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import type { BudgetStatus } from "@/types/api";
|
||||
|
||||
function budget(overrides: Partial<BudgetStatus> = {}): BudgetStatus {
|
||||
return {
|
||||
category_id: 11,
|
||||
category_name: "Lebensmittel",
|
||||
color: "#84cc16",
|
||||
period_month: "2026-03-01",
|
||||
limit_amount: "400.00",
|
||||
carried_over: "0.00",
|
||||
available: "400.00",
|
||||
spent: "100.00",
|
||||
remaining: "300.00",
|
||||
ratio: 0.25,
|
||||
state: "ok",
|
||||
rollover: false,
|
||||
is_template: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rendern(status: BudgetStatus) {
|
||||
return render(
|
||||
<ul>
|
||||
<BudgetMeter budget={status} />
|
||||
</ul>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BudgetMeter", () => {
|
||||
it("zeigt Verbrauch, Rest und Prozentwert", () => {
|
||||
rendern(budget());
|
||||
|
||||
expect(screen.getByText("Lebensmittel")).toBeInTheDocument();
|
||||
expect(screen.getByText(/100,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/400,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/300,00.*übrig/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trägt den Zustand nicht allein über die Farbe", () => {
|
||||
rendern(budget({ ratio: 0.9, state: "warning", spent: "360.00", remaining: "40.00" }));
|
||||
|
||||
// Prozentwert und Wortlaut stehen neben dem Symbol.
|
||||
expect(screen.getByText(/90 %/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/fast ausgeschöpft/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet eine Überschreitung mit dem Fehlbetrag", () => {
|
||||
rendern(
|
||||
budget({ ratio: 1.15, state: "exceeded", spent: "460.00", remaining: "-60.00" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/überschritten/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/60,00.*zu viel/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stellt den Fortschritt auch für Screenreader bereit", () => {
|
||||
rendern(budget({ ratio: 0.25 }));
|
||||
|
||||
const messgeraet = screen.getByRole("meter");
|
||||
expect(messgeraet).toHaveAttribute("aria-valuenow", "25");
|
||||
expect(messgeraet).toHaveAccessibleName(/Lebensmittel: 25 Prozent verbraucht/);
|
||||
});
|
||||
|
||||
it("nennt Übertrag und Herkunft aus einer Vorlage", () => {
|
||||
rendern(budget({ carried_over: "50.00", available: "450.00", is_template: true }));
|
||||
|
||||
expect(screen.getByText(/Übertrag aus Vormonaten: 50,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Aus einer Vorlage/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Budget-Ampel.
|
||||
*
|
||||
* Der Zustand wird nie allein über die Farbe getragen: jede Zeile führt ein
|
||||
* Symbol, einen Wortlaut und den Prozentwert mit.
|
||||
*/
|
||||
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert } from "lucide-react";
|
||||
|
||||
import { STATUS } from "@/components/charts/chartTheme";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { BudgetState, BudgetStatus } from "@/types/api";
|
||||
|
||||
const ZUSTAND: Record<
|
||||
BudgetState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2; klasse: string }
|
||||
> = {
|
||||
ok: { label: "im Rahmen", color: STATUS.good, icon: CheckCircle2, klasse: "text-positive" },
|
||||
warning: {
|
||||
label: "fast ausgeschöpft",
|
||||
color: STATUS.warning,
|
||||
icon: AlertTriangle,
|
||||
klasse: "text-warning",
|
||||
},
|
||||
exceeded: {
|
||||
label: "überschritten",
|
||||
color: STATUS.critical,
|
||||
icon: CircleAlert,
|
||||
klasse: "text-negative",
|
||||
},
|
||||
};
|
||||
|
||||
export function BudgetMeter({ budget }: { budget: BudgetStatus }) {
|
||||
const zustand = ZUSTAND[budget.state];
|
||||
const Symbol = zustand.icon;
|
||||
const anteil = Math.min(budget.ratio, 1);
|
||||
const ueberzogen = budget.ratio > 1;
|
||||
|
||||
return (
|
||||
<li className="card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: budget.color }}
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-ink">{budget.category_name}</span>
|
||||
</div>
|
||||
<span className="shrink-0 tabular text-sm text-ink">
|
||||
{formatMoney(budget.spent)}
|
||||
<span className="text-faint"> / {formatMoney(budget.available)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
|
||||
role="meter"
|
||||
aria-valuenow={Math.round(budget.ratio * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${budget.category_name}: ${Math.round(budget.ratio * 100)} Prozent verbraucht`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${anteil * 100}%`, backgroundColor: zustand.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs">
|
||||
<span className={cn("flex items-center gap-1", zustand.klasse)}>
|
||||
<Symbol aria-hidden className="h-3.5 w-3.5" />
|
||||
{Math.round(budget.ratio * 100)} % · {zustand.label}
|
||||
</span>
|
||||
<span className={ueberzogen ? "text-negative" : "text-muted"}>
|
||||
{ueberzogen
|
||||
? `${formatMoney(Math.abs(toNumber(budget.remaining)))} zu viel`
|
||||
: `${formatMoney(budget.remaining)} übrig`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(budget.carried_over !== "0.00" || budget.is_template) && (
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{budget.carried_over !== "0.00" &&
|
||||
`Übertrag aus Vormonaten: ${formatMoney(budget.carried_over)}. `}
|
||||
{budget.is_template && "Aus einer Vorlage."}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Tests des Kategorien-Rings samt Drilldown. */
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CategoryDonut } from "@/components/charts/CategoryDonut";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import type { CategorySlice } from "@/types/api";
|
||||
|
||||
function slice(
|
||||
id: number,
|
||||
name: string,
|
||||
amount: string,
|
||||
children: CategorySlice[] = [],
|
||||
): CategorySlice {
|
||||
return {
|
||||
category_id: id,
|
||||
name,
|
||||
color: "#84cc16",
|
||||
icon: "circle",
|
||||
amount,
|
||||
count: 1,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
const KATEGORIEN: CategorySlice[] = [
|
||||
slice(1, "Wohnen", "950.00", [slice(11, "Miete", "900.00"), slice(12, "Strom", "50.00")]),
|
||||
slice(2, "Lebenshaltung", "320.00", [slice(21, "Lebensmittel", "320.00")]),
|
||||
slice(3, "Mobilität", "130.00"),
|
||||
];
|
||||
|
||||
describe("CategoryDonut", () => {
|
||||
it("zeigt Summe, Anteile und Beträge im Klartext", () => {
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
|
||||
|
||||
// Die Zuordnung hängt nie allein an der Farbe.
|
||||
expect(screen.getByText("Wohnen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.400,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText("68 %")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blättert per Klick in die Unterkategorien und wieder zurück", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} title="Ausgaben" />);
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: /Wohnen/ }));
|
||||
|
||||
expect(screen.getByText("Ausgaben · Wohnen")).toBeInTheDocument();
|
||||
expect(screen.getByText("Miete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Strom")).toBeInTheDocument();
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: "Zurück" }));
|
||||
expect(screen.getByText("Lebenshaltung")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet keinen Drilldown bei nur einer Unterkategorie", () => {
|
||||
renderWithProviders(<CategoryDonut categories={KATEGORIEN} />);
|
||||
|
||||
// Lebenshaltung hat nur ein Kind – ein Aufklappen brächte nichts.
|
||||
expect(screen.queryByRole("button", { name: /Lebenshaltung/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fasst mehr als sechs Kategorien zusammen", () => {
|
||||
const viele = Array.from({ length: 9 }, (_, index) =>
|
||||
slice(index + 1, `Kategorie ${index + 1}`, String((9 - index) * 10)),
|
||||
);
|
||||
|
||||
renderWithProviders(<CategoryDonut categories={viele} />);
|
||||
|
||||
expect(screen.getByText("Weitere (3)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Kategorie 8")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("meldet einen leeren Zeitraum verständlich", () => {
|
||||
renderWithProviders(<CategoryDonut categories={[]} />);
|
||||
|
||||
expect(screen.getByText(/keine Bewegungen/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Aufteilung nach Kategorien mit Drilldown auf die Unterkategorien.
|
||||
*
|
||||
* Die Farben stammen aus den Kategorien selbst – sie sind dort die Identität
|
||||
* und tauchen in der ganzen Oberfläche auf. Damit die Zuordnung nicht allein an
|
||||
* der Farbe hängt, trägt jede Zeile daneben Name und Betrag.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip } from "@/components/charts/ChartFrame";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import type { CategorySlice } from "@/types/api";
|
||||
|
||||
/** Mehr Segmente lassen sich im Ring nicht mehr auseinanderhalten. */
|
||||
const MAX_SEGMENTE = 6;
|
||||
const REST_FARBE = "#94a3b8";
|
||||
|
||||
interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
amount: number;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
function zuSegmenten(slices: CategorySlice[]): Segment[] {
|
||||
const sortiert = [...slices].sort((links, rechts) => toNumber(rechts.amount) - toNumber(links.amount));
|
||||
const sichtbar = sortiert.slice(0, MAX_SEGMENTE);
|
||||
const rest = sortiert.slice(MAX_SEGMENTE);
|
||||
|
||||
const segmente: Segment[] = sichtbar.map((slice) => ({
|
||||
id: slice.category_id,
|
||||
name: slice.name,
|
||||
color: slice.color,
|
||||
amount: toNumber(slice.amount),
|
||||
hasChildren: slice.children.length > 1,
|
||||
}));
|
||||
|
||||
if (rest.length > 0) {
|
||||
segmente.push({
|
||||
id: -1,
|
||||
name: `Weitere (${rest.length})`,
|
||||
color: REST_FARBE,
|
||||
amount: rest.reduce((summe, slice) => summe + toNumber(slice.amount), 0),
|
||||
hasChildren: false,
|
||||
});
|
||||
}
|
||||
return segmente;
|
||||
}
|
||||
|
||||
export function CategoryDonut({
|
||||
categories,
|
||||
title = "Nach Kategorien",
|
||||
description,
|
||||
}: {
|
||||
categories: CategorySlice[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [drilldown, setDrilldown] = useState<CategorySlice | null>(null);
|
||||
|
||||
const quelle = drilldown ? drilldown.children : categories;
|
||||
const segmente = zuSegmenten(quelle);
|
||||
const gesamt = segmente.reduce((summe, segment) => summe + segment.amount, 0);
|
||||
|
||||
if (segmente.length === 0) {
|
||||
return (
|
||||
<ChartCard title={title} description={description}>
|
||||
<p className="py-10 text-center text-xs text-muted">
|
||||
Für diesen Zeitraum gibt es keine Bewegungen.
|
||||
</p>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={drilldown ? `${title} · ${drilldown.name}` : title}
|
||||
description={description}
|
||||
actions={
|
||||
drilldown && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setDrilldown(null)}>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
Zurück
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative h-48 w-full sm:w-48 sm:shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segmente}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
innerRadius="62%"
|
||||
outerRadius="98%"
|
||||
// Zwei Pixel Fläche zwischen den Segmenten statt eines Rahmens.
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
onClick={(eintrag: unknown) => {
|
||||
const segment = eintrag as Segment;
|
||||
const passend = quelle.find((slice) => slice.category_id === segment.id);
|
||||
if (passend && passend.children.length > 1) setDrilldown(passend);
|
||||
}}
|
||||
>
|
||||
{segmente.map((segment) => (
|
||||
<Cell
|
||||
key={segment.id}
|
||||
fill={segment.color}
|
||||
className={segment.hasChildren ? "cursor-pointer" : undefined}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const segment = payload[0]?.payload as Segment;
|
||||
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
|
||||
return (
|
||||
<ChartTooltip
|
||||
title={segment.name}
|
||||
rows={[
|
||||
{ label: "Betrag", value: formatMoney(segment.amount), color: segment.color },
|
||||
{ label: "Anteil", value: `${anteil.toFixed(1)} %` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Die Summe steht in der Mitte – dort sucht man sie. */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[11px] text-muted">Gesamt</span>
|
||||
<span className="tabular text-sm font-semibold text-ink">{formatMoney(gesamt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-1">
|
||||
{segmente.map((segment) => {
|
||||
const anteil = gesamt > 0 ? (segment.amount / gesamt) * 100 : 0;
|
||||
const passend = quelle.find((slice) => slice.category_id === segment.id);
|
||||
const aufklappbar = Boolean(passend && passend.children.length > 1);
|
||||
|
||||
const inhalt = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-ink">{segment.name}</span>
|
||||
<span className="shrink-0 tabular text-muted">{anteil.toFixed(0)} %</span>
|
||||
<span className="w-24 shrink-0 text-right tabular font-medium text-ink">
|
||||
{formatMoney(segment.amount)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={segment.id}>
|
||||
{aufklappbar ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => passend && setDrilldown(passend)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-xs transition hover:bg-raised"
|
||||
>
|
||||
{inhalt}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs">{inhalt}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/** Rahmen, Legende und Tooltip – für alle Diagramme gleich. */
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
legend,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
legend?: LegendItem[];
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("card p-4", className)}>
|
||||
<header className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</header>
|
||||
|
||||
{/* Ab zwei Serien ist die Legende immer vorhanden – Farbe allein trägt nie. */}
|
||||
{legend && legend.length > 1 && (
|
||||
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{legend.map((eintrag) => (
|
||||
<li key={eintrag.label} className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: eintrag.color }}
|
||||
/>
|
||||
{eintrag.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TooltipRow {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Einheitlicher Tooltip; die Werte stehen in Textfarbe, nicht in der Serienfarbe. */
|
||||
export function ChartTooltip({ title, rows }: { title: string; rows: TooltipRow[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface px-3 py-2 shadow-xl">
|
||||
<p className="mb-1 text-xs font-medium text-ink">{title}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{rows.map((zeile) => (
|
||||
<li key={zeile.label} className="flex items-center gap-2 text-xs">
|
||||
{zeile.color && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: zeile.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted">{zeile.label}</span>
|
||||
<span className="ml-auto tabular text-ink">{zeile.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aufklappbare Wertetabelle – die Alternative zum Ablesen aus der Grafik. */
|
||||
export function DataTable({
|
||||
columns,
|
||||
rows,
|
||||
caption,
|
||||
}: {
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
caption: string;
|
||||
}) {
|
||||
return (
|
||||
<details className="mt-3 text-xs">
|
||||
<summary className="cursor-pointer text-muted transition hover:text-ink">
|
||||
Werte als Tabelle
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<caption className="sr-only">{caption}</caption>
|
||||
<thead className="text-left text-muted">
|
||||
<tr>
|
||||
{columns.map((spalte, index) => (
|
||||
<th
|
||||
key={spalte}
|
||||
scope="col"
|
||||
className={cn("py-1 font-medium", index > 0 && "text-right")}
|
||||
>
|
||||
{spalte}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{rows.map((zeile, zeilenIndex) => (
|
||||
<tr key={zeilenIndex}>
|
||||
{zeile.map((zelle, spaltenIndex) => (
|
||||
<td
|
||||
key={spaltenIndex}
|
||||
className={cn(
|
||||
"py-1",
|
||||
spaltenIndex === 0 ? "text-ink" : "text-right tabular text-muted",
|
||||
)}
|
||||
>
|
||||
{zelle}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kennzahl für Fälle, in denen eine Zahl mehr sagt als ein Diagramm. */
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "default",
|
||||
large = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: ReactNode;
|
||||
tone?: "default" | "positive" | "negative";
|
||||
large?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-muted">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 font-semibold",
|
||||
large ? "text-3xl" : "text-xl",
|
||||
tone === "positive" && "text-positive",
|
||||
tone === "negative" && "text-negative",
|
||||
tone === "default" && "text-ink",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{hint && <div className="mt-1 text-xs text-muted">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Veränderung gegenüber einem Vergleichswert, mit Vorzeichen und Wortlaut. */
|
||||
export function DeltaHint({
|
||||
value,
|
||||
goodWhenPositive = true,
|
||||
suffix = "ggü. Vormonat",
|
||||
}: {
|
||||
value: string;
|
||||
goodWhenPositive?: boolean;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const zahl = Number.parseFloat(value);
|
||||
if (!Number.isFinite(zahl) || zahl === 0) {
|
||||
return <span className="text-faint">unverändert {suffix}</span>;
|
||||
}
|
||||
const gut = zahl > 0 === goodWhenPositive;
|
||||
return (
|
||||
<span className={gut ? "text-positive" : "text-negative"}>
|
||||
{zahl > 0 ? "▲" : "▼"} {formatMoney(Math.abs(zahl))} {suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Zwölf-Monats-Vorschau.
|
||||
*
|
||||
* Bewusst zwei Diagramme untereinander statt zweier Größenachsen in einem Bild:
|
||||
* Monatsbeträge und kumulierter Kontostand haben verschiedene Größenordnungen,
|
||||
* eine gemeinsame Achse würde einen Zusammenhang vortäuschen.
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
|
||||
import {
|
||||
AXIS_PROPS,
|
||||
BAR_GAP,
|
||||
BAR_RADIUS,
|
||||
GRID_PROPS,
|
||||
SERIES,
|
||||
} from "@/components/charts/chartTheme";
|
||||
import { formatMoney, toNumber } from "@/lib/format";
|
||||
import type { ForecastMonth } from "@/types/api";
|
||||
|
||||
const MONATSKUERZEL = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mär",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Dez",
|
||||
];
|
||||
|
||||
interface Punkt {
|
||||
label: string;
|
||||
monat: string;
|
||||
einkuenfte: number;
|
||||
ausgaben: number;
|
||||
saldo: number;
|
||||
kumuliert: number;
|
||||
}
|
||||
|
||||
function kompakt(wert: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert);
|
||||
}
|
||||
|
||||
export function ForecastChart({ months }: { months: ForecastMonth[] }) {
|
||||
const punkte: Punkt[] = months.map((monat) => {
|
||||
const datum = new Date(monat.month);
|
||||
return {
|
||||
label: `${MONATSKUERZEL[datum.getMonth()]} ${String(datum.getFullYear()).slice(2)}`,
|
||||
monat: monat.month,
|
||||
einkuenfte: toNumber(monat.income),
|
||||
ausgaben: toNumber(monat.expenses),
|
||||
saldo: toNumber(monat.balance),
|
||||
kumuliert: toNumber(monat.cumulative_balance),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ChartCard
|
||||
title="Einnahmen und Ausgaben"
|
||||
description="Jährliche Posten stehen in ihrem echten Fälligkeitsmonat."
|
||||
legend={[
|
||||
{ label: "Einkünfte", color: SERIES.one },
|
||||
{ label: "Ausgaben", color: SERIES.two },
|
||||
]}
|
||||
>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={punkte} barGap={BAR_GAP} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: "Einkünfte",
|
||||
value: formatMoney(payload[0]?.payload.einkuenfte ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
{
|
||||
label: "Ausgaben",
|
||||
value: formatMoney(payload[0]?.payload.ausgaben ?? 0),
|
||||
color: SERIES.two,
|
||||
},
|
||||
{
|
||||
label: "Saldo",
|
||||
value: formatMoney(payload[0]?.payload.saldo ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="einkuenfte" name="Einkünfte" fill={SERIES.one} radius={BAR_RADIUS} />
|
||||
<Bar dataKey="ausgaben" name="Ausgaben" fill={SERIES.two} radius={BAR_RADIUS} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
caption="Einnahmen und Ausgaben je Monat"
|
||||
columns={["Monat", "Einkünfte", "Ausgaben", "Saldo"]}
|
||||
rows={punkte.map((punkt) => [
|
||||
punkt.label,
|
||||
formatMoney(punkt.einkuenfte),
|
||||
formatMoney(punkt.ausgaben),
|
||||
formatMoney(punkt.saldo),
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Kumulierter Kontostand"
|
||||
description="Fortgeschrieben aus dem heutigen Saldo aller Konten."
|
||||
>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={punkte} margin={{ top: 8, right: 8, bottom: 0, left: 4 }}>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} />
|
||||
<YAxis {...AXIS_PROPS} width={52} tickFormatter={kompakt} />
|
||||
{/* Die Nulllinie ist die Grenze, auf die es ankommt. */}
|
||||
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: "Kontostand",
|
||||
value: formatMoney(payload[0]?.payload.kumuliert ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="kumuliert"
|
||||
name="Kontostand"
|
||||
stroke={SERIES.one}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 0, fill: SERIES.one }}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Jahresvergleich je Oberkategorie als gruppierte Balken. */
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, DataTable } from "@/components/charts/ChartFrame";
|
||||
import {
|
||||
AXIS_PROPS,
|
||||
BAR_GAP,
|
||||
GRID_PROPS,
|
||||
SERIES,
|
||||
} from "@/components/charts/chartTheme";
|
||||
import { formatMoney, formatSignedMoney, toNumber } from "@/lib/format";
|
||||
import type { YearComparison } from "@/types/api";
|
||||
|
||||
function kompakt(wert: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert);
|
||||
}
|
||||
|
||||
export function YearComparisonChart({ data }: { data: YearComparison }) {
|
||||
const punkte = data.rows.map((zeile) => ({
|
||||
name: zeile.name,
|
||||
aktuell: toNumber(zeile.current),
|
||||
vorjahr: toNumber(zeile.previous),
|
||||
delta: toNumber(zeile.delta),
|
||||
}));
|
||||
|
||||
if (punkte.length === 0) {
|
||||
return (
|
||||
<ChartCard title={`Jahresvergleich ${data.year}`}>
|
||||
<p className="py-10 text-center text-xs text-muted">
|
||||
Für {data.year} und {data.year - 1} liegen keine Ausgaben vor.
|
||||
</p>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={`Jahresvergleich ${data.year}`}
|
||||
description={`${formatMoney(data.current_total)} gegenüber ${formatMoney(
|
||||
data.previous_total,
|
||||
)} im Vorjahr.`}
|
||||
legend={[
|
||||
{ label: String(data.year), color: SERIES.one },
|
||||
{ label: String(data.year - 1), color: SERIES.two },
|
||||
]}
|
||||
>
|
||||
<div style={{ height: Math.max(200, punkte.length * 44) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={punkte}
|
||||
layout="vertical"
|
||||
barGap={BAR_GAP}
|
||||
margin={{ top: 4, right: 12, bottom: 0, left: 4 }}
|
||||
>
|
||||
<CartesianGrid {...GRID_PROPS} horizontal={false} vertical />
|
||||
<XAxis type="number" {...AXIS_PROPS} tickFormatter={kompakt} />
|
||||
<YAxis type="category" dataKey="name" {...AXIS_PROPS} width={110} />
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--viz-grid)", opacity: 0.35 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={String(label)}
|
||||
rows={[
|
||||
{
|
||||
label: String(data.year),
|
||||
value: formatMoney(payload[0]?.payload.aktuell ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
{
|
||||
label: String(data.year - 1),
|
||||
value: formatMoney(payload[0]?.payload.vorjahr ?? 0),
|
||||
color: SERIES.two,
|
||||
},
|
||||
{
|
||||
label: "Veränderung",
|
||||
value: formatSignedMoney(payload[0]?.payload.delta ?? 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="aktuell" name={String(data.year)} fill={SERIES.one} radius={[0, 4, 4, 0]} />
|
||||
<Bar
|
||||
dataKey="vorjahr"
|
||||
name={String(data.year - 1)}
|
||||
fill={SERIES.two}
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
caption={`Ausgaben ${data.year} gegenüber ${data.year - 1}`}
|
||||
columns={["Kategorie", String(data.year), String(data.year - 1), "Veränderung"]}
|
||||
rows={punkte.map((punkt) => [
|
||||
punkt.name,
|
||||
formatMoney(punkt.aktuell),
|
||||
formatMoney(punkt.vorjahr),
|
||||
formatSignedMoney(punkt.delta),
|
||||
])}
|
||||
/>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Gemeinsame Bausteine der Diagramme.
|
||||
*
|
||||
* Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen
|
||||
* Flächen dieser Anwendung auf Kontrast und Farbfehlsichtigkeit geprüft wurde.
|
||||
* Die Reihenfolge ist Teil dieser Absicherung und darf nicht umsortiert werden.
|
||||
*/
|
||||
|
||||
export const SERIES = {
|
||||
/** Einkünfte, laufendes Jahr, kumulierter Saldo. */
|
||||
one: "var(--viz-series-1)",
|
||||
/** Ausgaben, Vorjahr. */
|
||||
two: "var(--viz-series-2)",
|
||||
three: "var(--viz-series-3)",
|
||||
} as const;
|
||||
|
||||
export const CHROME = {
|
||||
grid: "var(--viz-grid)",
|
||||
axis: "var(--viz-axis)",
|
||||
label: "var(--viz-label)",
|
||||
surface: "var(--viz-surface)",
|
||||
} as const;
|
||||
|
||||
export const STATUS = {
|
||||
good: "var(--viz-good)",
|
||||
warning: "var(--viz-warning)",
|
||||
critical: "var(--viz-critical)",
|
||||
} as const;
|
||||
|
||||
/** Achsen und Raster bleiben zurückhaltend: dünne, durchgezogene Linien. */
|
||||
export const AXIS_PROPS = {
|
||||
stroke: CHROME.axis,
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
tick: { fill: CHROME.label, fontSize: 11 },
|
||||
} as const;
|
||||
|
||||
export const GRID_PROPS = {
|
||||
stroke: CHROME.grid,
|
||||
strokeWidth: 1,
|
||||
vertical: false,
|
||||
} as const;
|
||||
|
||||
/** Abgerundete Balkenenden an der Grundlinie. */
|
||||
export const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0];
|
||||
|
||||
/** Zwei Pixel Fläche zwischen benachbarten Balken statt eines Rahmens. */
|
||||
export const BAR_GAP = 2;
|
||||
@@ -1,6 +1,17 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
Repeat,
|
||||
Settings,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -11,9 +22,14 @@ interface NavEintrag {
|
||||
}
|
||||
|
||||
const NAVIGATION: NavEintrag[] = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/calendar", label: "Kalender", icon: CalendarDays },
|
||||
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
|
||||
{ to: "/transactions", label: "Buchungen", icon: Receipt },
|
||||
{ to: "/merchants", label: "Firmen", icon: Building2 },
|
||||
{ to: "/budgets", label: "Budgets", icon: Wallet },
|
||||
{ to: "/goals", label: "Sparziele", icon: PiggyBank },
|
||||
{ to: "/reports", label: "Auswertungen", icon: BarChart3 },
|
||||
{ to: "/settings", label: "Einstellungen", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -35,6 +51,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/** Budgets, Vorlagen und Sparziele als Stammdaten. */
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "@/store/toast";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetInput,
|
||||
BudgetTemplate,
|
||||
MessageResponse,
|
||||
SavingsGoal,
|
||||
SavingsGoalInput,
|
||||
} from "@/types/api";
|
||||
|
||||
function invalidate(client: ReturnType<typeof useQueryClient>): void {
|
||||
void client.invalidateQueries({ queryKey: ["budgets"] });
|
||||
void client.invalidateQueries({ queryKey: ["budget-templates"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
}
|
||||
|
||||
export function useBudgets(month?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["budgets", month ?? ""],
|
||||
queryFn: () => api.get<Budget[]>("/budgets", month ? { month } : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveBudget() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, daten }: { id?: number; daten: BudgetInput | Partial<BudgetInput> }) =>
|
||||
id ? api.patch<Budget>(`/budgets/${id}`, daten) : api.post<Budget>("/budgets", daten),
|
||||
onSuccess: (_budget, variablen) => {
|
||||
invalidate(client);
|
||||
toast.success(variablen.id ? "Budget gespeichert." : "Budget angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudget() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/budgets/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budget gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBudgetTemplates() {
|
||||
return useQuery({
|
||||
queryKey: ["budget-templates"],
|
||||
queryFn: () => api.get<BudgetTemplate[]>("/budget-templates"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveBudgetTemplate() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (daten: {
|
||||
category_id: number;
|
||||
valid_from: string;
|
||||
limit_amount: string;
|
||||
rollover?: boolean;
|
||||
}) => api.post<BudgetTemplate>("/budget-templates", daten),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budgetvorlage angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudgetTemplate() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/budget-templates/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate(client);
|
||||
toast.success("Budgetvorlage gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSavingsGoals(includeArchived = false) {
|
||||
return useQuery({
|
||||
queryKey: ["savings-goals", includeArchived],
|
||||
queryFn: () =>
|
||||
api.get<SavingsGoal[]>(
|
||||
"/savings-goals",
|
||||
includeArchived ? { include_archived: true } : undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveGoal() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
daten,
|
||||
}: {
|
||||
id?: number;
|
||||
daten: SavingsGoalInput | Partial<SavingsGoalInput>;
|
||||
}) =>
|
||||
id
|
||||
? api.patch<SavingsGoal>(`/savings-goals/${id}`, daten)
|
||||
: api.post<SavingsGoal>("/savings-goals", daten),
|
||||
onSuccess: (_ziel, variablen) => {
|
||||
void client.invalidateQueries({ queryKey: ["savings-goals"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
toast.success(variablen.id ? "Sparziel gespeichert." : "Sparziel angelegt.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGoal() {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.del<MessageResponse>(`/savings-goals/${id}`),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ["savings-goals"] });
|
||||
void client.invalidateQueries({ queryKey: ["reports"] });
|
||||
toast.success("Sparziel gelöscht.");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Abfragen der Auswertungen. */
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
BudgetStatus,
|
||||
CalendarMonth,
|
||||
CategoryReport,
|
||||
Dashboard,
|
||||
EntryKind,
|
||||
Forecast,
|
||||
MonthReport,
|
||||
SavingsGoalProgress,
|
||||
SubscriptionReport,
|
||||
YearComparison,
|
||||
} from "@/types/api";
|
||||
|
||||
export function useDashboard(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "dashboard", month],
|
||||
queryFn: () => api.get<Dashboard>("/reports/dashboard", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthReport(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "month", month],
|
||||
queryFn: () => api.get<MonthReport>("/reports/month", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useForecast(months = 12, start?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "forecast", months, start ?? ""],
|
||||
queryFn: () => api.get<Forecast>("/reports/forecast", { months, start }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategoryReport(from: string, to: string, kind: EntryKind = "expense") {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "categories", from, to, kind],
|
||||
queryFn: () => api.get<CategoryReport>("/reports/categories", { from, to, kind }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "subscriptions"],
|
||||
queryFn: () => api.get<SubscriptionReport>("/reports/subscriptions"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useYearComparison(year: number, kind: EntryKind = "expense") {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "year-comparison", year, kind],
|
||||
queryFn: () => api.get<YearComparison>("/reports/year-comparison", { year, kind }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCalendar(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "calendar", month],
|
||||
queryFn: () => api.get<CalendarMonth>("/reports/calendar", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBudgetStatus(month: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "budgets", month],
|
||||
queryFn: () => api.get<BudgetStatus[]>("/reports/budgets", { month }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGoalProgress() {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "savings-goals"],
|
||||
queryFn: () => api.get<SavingsGoalProgress[]>("/reports/savings-goals"),
|
||||
});
|
||||
}
|
||||
|
||||
/** Baut die URL eines Exports; der Browser lädt sie direkt herunter. */
|
||||
export function exportUrl(
|
||||
what: "transactions" | "recurrences" | "month",
|
||||
format: "csv" | "xlsx",
|
||||
params: Record<string, string | number | undefined> = {},
|
||||
): string {
|
||||
const suchparameter = new URLSearchParams({ format });
|
||||
for (const [schluessel, wert] of Object.entries(params)) {
|
||||
if (wert !== undefined && wert !== "") suchparameter.set(schluessel, String(wert));
|
||||
}
|
||||
return `/api/export/${what}?${suchparameter.toString()}`;
|
||||
}
|
||||
@@ -19,6 +19,24 @@
|
||||
--color-warning: 180 83 9;
|
||||
}
|
||||
|
||||
/* Diagrammfarben, heller Modus.
|
||||
Die Reihenfolge der Serienfarben ist die Absicherung gegen Farbfehlsichtigkeit
|
||||
und wurde gegen die Flächen dieser Anwendung geprüft – nicht umsortieren.
|
||||
Grün/Rot wäre als Serienpaar bei Deuteranopie nicht unterscheidbar (ΔE 7,9)
|
||||
und bleibt deshalb den Vorzeichen im Text vorbehalten. */
|
||||
:root {
|
||||
--viz-series-1: #2a78d6;
|
||||
--viz-series-2: #eb6834;
|
||||
--viz-series-3: #1baf7a;
|
||||
--viz-grid: #e2e8f0;
|
||||
--viz-axis: #94a3b8;
|
||||
--viz-label: #64748b;
|
||||
--viz-surface: #ffffff;
|
||||
--viz-good: #0ca30c;
|
||||
--viz-warning: #fab219;
|
||||
--viz-critical: #d03b3b;
|
||||
}
|
||||
|
||||
/* Dunkler Modus – die Vorgabe der Anwendung */
|
||||
.dark {
|
||||
--color-ground: 15 17 21;
|
||||
@@ -33,6 +51,18 @@
|
||||
--color-positive: 74 222 128;
|
||||
--color-negative: 248 113 113;
|
||||
--color-warning: 251 191 36;
|
||||
|
||||
/* Eigene Stufen für die dunkle Fläche, kein automatisches Umdrehen. */
|
||||
--viz-series-1: #3987e5;
|
||||
--viz-series-2: #d95926;
|
||||
--viz-series-3: #199e70;
|
||||
--viz-grid: #2b3038;
|
||||
--viz-axis: #4b5563;
|
||||
--viz-label: #94a3b8;
|
||||
--viz-surface: #181b21;
|
||||
--viz-good: #0ca30c;
|
||||
--viz-warning: #fab219;
|
||||
--viz-critical: #d03b3b;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/** Budgets je Monat samt Ampel und dauerhaften Vorlagen. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Wallet } from "lucide-react";
|
||||
|
||||
import { CategorySelect } from "@/components/EntitySelects";
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { Checkbox, Field, Input } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { MoneyInput } from "@/components/ui/MoneyInput";
|
||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import {
|
||||
useBudgetTemplates,
|
||||
useBudgets,
|
||||
useDeleteBudget,
|
||||
useDeleteBudgetTemplate,
|
||||
useSaveBudget,
|
||||
useSaveBudgetTemplate,
|
||||
} from "@/hooks/useBudgets";
|
||||
import { useBudgetStatus } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth } from "@/lib/format";
|
||||
import type { Budget, BudgetTemplate } from "@/types/api";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const [formular, setFormular] = useState<"budget" | "template" | null>(null);
|
||||
const [loeschen, setLoeschen] = useState<Budget | null>(null);
|
||||
const [vorlageLoeschen, setVorlageLoeschen] = useState<BudgetTemplate | null>(null);
|
||||
|
||||
const { data: status = [], isLoading } = useBudgetStatus(monat);
|
||||
const { data: budgets = [] } = useBudgets(monat);
|
||||
const { data: vorlagen = [] } = useBudgetTemplates();
|
||||
const entfernen = useDeleteBudget();
|
||||
const vorlageEntfernen = useDeleteBudgetTemplate();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Budgets"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setFormular("template")}>Vorlage</Button>
|
||||
<Button variant="primary" onClick={() => setFormular("budget")}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Budget
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : status.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title="Keine Budgets in diesem Monat"
|
||||
description="Ein Budget zeigt, wie viel einer Kategorie noch bleibt. Mit einer Vorlage gilt es dauerhaft ab einem Monat."
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setFormular("budget")}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Erstes Budget anlegen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-2 lg:grid-cols-2">
|
||||
{status.map((eintrag) => (
|
||||
<BudgetMeter key={eintrag.category_id} budget={eintrag} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{budgets.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">
|
||||
Einzelbudgets in diesem Monat
|
||||
</h2>
|
||||
<ul className="divide-y divide-line rounded-card border border-line">
|
||||
{budgets.map((budget) => (
|
||||
<li key={budget.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-ink">
|
||||
{kategorieName(budget.category_id)}
|
||||
</span>
|
||||
{budget.rollover && <Badge>Übertrag</Badge>}
|
||||
<span className="tabular text-muted">{formatMoney(budget.limit_amount)}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => setLoeschen(budget)}
|
||||
aria-label="Budget löschen"
|
||||
>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{vorlagen.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Dauerhafte Vorlagen</h2>
|
||||
<ul className="divide-y divide-line rounded-card border border-line">
|
||||
{vorlagen.map((vorlage) => (
|
||||
<li key={vorlage.id} className="group flex items-center gap-3 px-3 py-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate text-ink">
|
||||
{kategorieName(vorlage.category_id)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">
|
||||
ab {formatDate(vorlage.valid_from)}
|
||||
{vorlage.valid_until && ` bis ${formatDate(vorlage.valid_until)}`}
|
||||
</span>
|
||||
{vorlage.rollover && <Badge>Übertrag</Badge>}
|
||||
<span className="tabular text-muted">{formatMoney(vorlage.limit_amount)}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="opacity-0 transition group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => setVorlageLoeschen(vorlage)}
|
||||
aria-label="Vorlage löschen"
|
||||
>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<BudgetDialog
|
||||
kind={formular}
|
||||
month={monat}
|
||||
open={formular !== null}
|
||||
onClose={() => setFormular(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={loeschen !== null}
|
||||
title="Budget löschen"
|
||||
description="Das Budget dieses Monats wird entfernt."
|
||||
loading={entfernen.isPending}
|
||||
onCancel={() => setLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={vorlageLoeschen !== null}
|
||||
title="Vorlage löschen"
|
||||
description="Die Vorlage gilt danach in keinem Monat mehr."
|
||||
loading={vorlageEntfernen.isPending}
|
||||
onCancel={() => setVorlageLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (vorlageLoeschen)
|
||||
vorlageEntfernen.mutate(vorlageLoeschen.id, {
|
||||
onSuccess: () => setVorlageLoeschen(null),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetDialog({
|
||||
kind,
|
||||
month,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
kind: "budget" | "template" | null;
|
||||
month: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kategorie, setKategorie] = useState<number | null>(null);
|
||||
const [betrag, setBetrag] = useState("");
|
||||
const [uebertrag, setUebertrag] = useState(false);
|
||||
const [gueltigAb, setGueltigAb] = useState(month);
|
||||
|
||||
const budgetSpeichern = useSaveBudget();
|
||||
const vorlageSpeichern = useSaveBudgetTemplate();
|
||||
const laeuft = budgetSpeichern.isPending || vorlageSpeichern.isPending;
|
||||
|
||||
function absenden(ereignis: FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
if (kategorie === null || !betrag) return;
|
||||
|
||||
const fertig = {
|
||||
onSuccess: () => {
|
||||
setKategorie(null);
|
||||
setBetrag("");
|
||||
setUebertrag(false);
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "template") {
|
||||
vorlageSpeichern.mutate(
|
||||
{
|
||||
category_id: kategorie,
|
||||
valid_from: gueltigAb,
|
||||
limit_amount: betrag,
|
||||
rollover: uebertrag,
|
||||
},
|
||||
fertig,
|
||||
);
|
||||
} else {
|
||||
budgetSpeichern.mutate(
|
||||
{
|
||||
daten: {
|
||||
category_id: kategorie,
|
||||
period_month: month,
|
||||
limit_amount: betrag,
|
||||
rollover: uebertrag,
|
||||
},
|
||||
},
|
||||
fertig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
title={kind === "template" ? "Neue Budgetvorlage" : "Neues Budget"}
|
||||
description={
|
||||
kind === "template"
|
||||
? "Gilt ab dem gewählten Monat dauerhaft, bis eine neue Vorlage sie ablöst."
|
||||
: `Gilt nur für ${formatMonth(month)}.`
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={absenden}
|
||||
loading={laeuft}
|
||||
disabled={kategorie === null || !betrag}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={absenden} className="space-y-3">
|
||||
<Field label="Kategorie" required hint="Budgets auf einer Oberkategorie umfassen deren Unterkategorien.">
|
||||
{(id) => (
|
||||
<CategorySelect id={id} required kind="expense" value={kategorie} onChange={setKategorie} />
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{kind === "template" && (
|
||||
<Field label="Gültig ab" required>
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="month"
|
||||
value={gueltigAb.slice(0, 7)}
|
||||
onChange={(ereignis) => setGueltigAb(`${ereignis.target.value}-01`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Betrag" required>
|
||||
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
label="Rest in den Folgemonat übertragen"
|
||||
hint="Nicht verbrauchtes Budget erhöht das Budget des nächsten Monats."
|
||||
checked={uebertrag}
|
||||
onChange={(ereignis) => setUebertrag(ereignis.target.checked)}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/** Tests des Cashflow-Kalenders. */
|
||||
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CalendarPage } from "@/pages/CalendarPage";
|
||||
import { renderWithProviders } from "@/test/utils";
|
||||
import type { CalendarDay, CalendarEntry } from "@/types/api";
|
||||
|
||||
function eintrag(overrides: Partial<CalendarEntry> = {}): CalendarEntry {
|
||||
return {
|
||||
title: "Miete",
|
||||
kind: "expense",
|
||||
amount: "950.00",
|
||||
category_id: 11,
|
||||
merchant_id: null,
|
||||
account_id: 1,
|
||||
source: "recurrence",
|
||||
recurrence_id: 7,
|
||||
occurrence_date: "2026-03-01",
|
||||
status: "planned",
|
||||
is_variable: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Baut einen vollständigen März 2026 mit zwei belegten Tagen. */
|
||||
function maerz(): CalendarDay[] {
|
||||
return Array.from({ length: 31 }, (_, index) => {
|
||||
const tag = index + 1;
|
||||
const datum = `2026-03-${String(tag).padStart(2, "0")}`;
|
||||
const wochentag = new Date(datum).getDay();
|
||||
|
||||
if (tag === 2) {
|
||||
return {
|
||||
date: datum,
|
||||
entries: [eintrag({ occurrence_date: "2026-03-01" })],
|
||||
net: "-950.00",
|
||||
running_balance: "50.00",
|
||||
is_business_day: true,
|
||||
};
|
||||
}
|
||||
if (tag === 28) {
|
||||
return {
|
||||
date: datum,
|
||||
entries: [
|
||||
eintrag({
|
||||
title: "Gehalt",
|
||||
kind: "income",
|
||||
amount: "3200.00",
|
||||
recurrence_id: 8,
|
||||
occurrence_date: "2026-03-28",
|
||||
}),
|
||||
],
|
||||
net: "3200.00",
|
||||
running_balance: "3250.00",
|
||||
is_business_day: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
date: datum,
|
||||
entries: [],
|
||||
net: "0.00",
|
||||
running_balance: tag < 2 ? "1000.00" : tag < 28 ? "50.00" : "3250.00",
|
||||
is_business_day: wochentag !== 0 && wochentag !== 6,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockApi() {
|
||||
const anfragen: { url: string; method: string; body: unknown }[] = [];
|
||||
|
||||
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();
|
||||
anfragen.push({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
|
||||
});
|
||||
|
||||
if (url.includes("/api/reports/calendar")) {
|
||||
return json({
|
||||
month: "2026-03-01",
|
||||
days: maerz(),
|
||||
opening_balance: "1000.00",
|
||||
closing_balance: "3250.00",
|
||||
lowest_balance: "50.00",
|
||||
lowest_balance_on: "2026-03-02",
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/merchants")) {
|
||||
return json({ items: [], total: 0, limit: 200, offset: 0 });
|
||||
}
|
||||
if (url.includes("/api/occurrences/")) {
|
||||
return json({});
|
||||
}
|
||||
return json({});
|
||||
}),
|
||||
);
|
||||
|
||||
return anfragen;
|
||||
}
|
||||
|
||||
describe("Cashflow-Kalender", () => {
|
||||
beforeEach(() => {
|
||||
mockApi();
|
||||
});
|
||||
|
||||
it("zeigt die Kennzahlen des Monats", async () => {
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Stand zu Monatsbeginn")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/1.000,00/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Tiefster Stand")).toBeInTheDocument();
|
||||
expect(screen.getByText(/am 02\.03\.2026/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rendert ein vollständiges Monatsraster", async () => {
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument();
|
||||
});
|
||||
// 31 Tage im März.
|
||||
expect(screen.getAllByRole("button", { name: /\d{2}\.03\.2026/ })).toHaveLength(31);
|
||||
});
|
||||
|
||||
it("öffnet die Tagesansicht mit den Posten", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
|
||||
|
||||
const detail = await screen.findByRole("heading", { name: "02.03.2026" });
|
||||
const bereich = detail.closest("section")!;
|
||||
// Der Betrag steht sowohl in der Zeile als auch im Tagessaldo – hier zählt die Zeile.
|
||||
const zeile = within(bereich).getByRole("listitem");
|
||||
expect(within(zeile).getByText("Miete")).toBeInTheDocument();
|
||||
expect(within(zeile).getByText(/950,00/)).toBeInTheDocument();
|
||||
expect(within(zeile).getByText("Geplant")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bestätigt eine Fälligkeit über das nominale Datum", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/02\.03\.2026/));
|
||||
|
||||
await nutzer.click(await screen.findByRole("button", { name: "Miete bestätigen" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const bestaetigung = anfragen.find((e) => e.url.includes("/occurrences/confirm"));
|
||||
expect(bestaetigung).toBeDefined();
|
||||
// Der Zahltag ist der 02.03., der Schlüssel bleibt der 01.03.
|
||||
expect(bestaetigung?.body).toEqual({
|
||||
recurrence_id: 7,
|
||||
occurrence_date: "2026-03-01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("meldet leere Tage verständlich", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/05\.03\.2026/)).toBeInTheDocument());
|
||||
await nutzer.click(screen.getByLabelText(/05\.03\.2026/));
|
||||
|
||||
expect(await screen.findByText("Nichts fällig")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blättert in den Vormonat", async () => {
|
||||
const anfragen = mockApi();
|
||||
const nutzer = userEvent.setup();
|
||||
renderWithProviders(<CalendarPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText(/02\.03\.2026/)).toBeInTheDocument());
|
||||
const vorher = anfragen.filter((e) => e.url.includes("/reports/calendar")).length;
|
||||
|
||||
await nutzer.click(screen.getByRole("button", { name: "Vorheriger Monat" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(anfragen.filter((e) => e.url.includes("/reports/calendar")).length).toBeGreaterThan(
|
||||
vorher,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Cashflow-Kalender.
|
||||
*
|
||||
* Monatsraster mit den Fälligkeiten je Tag; darunter der Verlauf des
|
||||
* Kontostands über den Monat.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, SkipForward } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartCard, ChartTooltip, StatTile } from "@/components/charts/ChartFrame";
|
||||
import { AXIS_PROPS, GRID_PROPS, SERIES } from "@/components/charts/chartTheme";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
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 { useCalendar } from "@/hooks/useReports";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, 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 {
|
||||
const datum = new Date(erster);
|
||||
return (datum.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export function CalendarPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const [gewaehlterTag, setGewaehlterTag] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useCalendar(monat);
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||
|
||||
const tage = data?.days ?? [];
|
||||
const heute = todayIso();
|
||||
const detailTag = tage.find((tag) => tag.date === gewaehlterTag) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cashflow-Kalender"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, -1));
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat(firstOfMonth());
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMonat((alt) => addMonthsIso(alt, 1));
|
||||
setGewaehlterTag(null);
|
||||
}}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-96" />
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
value={formatMoney(data.closing_balance)}
|
||||
tone={toNumber(data.closing_balance) < 0 ? "negative" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tiefster Stand"
|
||||
value={formatMoney(data.lowest_balance)}
|
||||
tone={toNumber(data.lowest_balance) < 0 ? "negative" : "default"}
|
||||
hint={data.lowest_balance_on ? `am ${formatDate(data.lowest_balance_on)}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="card p-3">
|
||||
<div className="mb-1 grid grid-cols-7 gap-1.5">
|
||||
{WOCHENTAGE.map((tag) => (
|
||||
<div key={tag} className="py-1 text-center text-[11px] font-medium text-muted">
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{Array.from({ length: fuehrendeLeerfelder(data.month) }, (_, index) => (
|
||||
<div key={`leer-${index}`} aria-hidden />
|
||||
))}
|
||||
|
||||
{tage.map((tag) => (
|
||||
<TagZelle
|
||||
key={tag.date}
|
||||
tag={tag}
|
||||
istHeute={tag.date === heute}
|
||||
istGewaehlt={tag.date === gewaehlterTag}
|
||||
firmen={firmen}
|
||||
onSelect={() => setGewaehlterTag(tag.date === gewaehlterTag ? null : tag.date)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{detailTag && (
|
||||
<TagDetail
|
||||
tag={detailTag}
|
||||
firmen={firmen}
|
||||
onClose={() => setGewaehlterTag(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChartCard
|
||||
title="Verlauf des Kontostands"
|
||||
description="Fortgeschrieben aus dem Stand zu Monatsbeginn."
|
||||
>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={tage.map((tag) => ({
|
||||
tag: new Date(tag.date).getDate(),
|
||||
stand: toNumber(tag.running_balance),
|
||||
}))}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 4 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="verlauf" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={SERIES.one} stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor={SERIES.one} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid {...GRID_PROPS} />
|
||||
<XAxis dataKey="tag" {...AXIS_PROPS} />
|
||||
<YAxis
|
||||
{...AXIS_PROPS}
|
||||
width={52}
|
||||
tickFormatter={(wert: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(wert)
|
||||
}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--viz-axis)" strokeWidth={1} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--viz-grid)", strokeWidth: 1 }}
|
||||
content={({ active, payload, label }) =>
|
||||
active && payload?.length ? (
|
||||
<ChartTooltip
|
||||
title={`${label}. ${formatMonth(monat)}`}
|
||||
rows={[
|
||||
{
|
||||
label: "Kontostand",
|
||||
value: formatMoney(payload[0]?.payload.stand ?? 0),
|
||||
color: SERIES.one,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="stand"
|
||||
stroke={SERIES.one}
|
||||
strokeWidth={2}
|
||||
fill="url(#verlauf)"
|
||||
dot={false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: "var(--viz-surface)" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagZelle({
|
||||
tag,
|
||||
istHeute,
|
||||
istGewaehlt,
|
||||
firmen,
|
||||
onSelect,
|
||||
}: {
|
||||
tag: CalendarDay;
|
||||
istHeute: boolean;
|
||||
istGewaehlt: boolean;
|
||||
firmen: Map<number, Merchant>;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const netto = toNumber(tag.net);
|
||||
const tagesZahl = new Date(tag.date).getDate();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={istGewaehlt}
|
||||
aria-label={`${formatDate(tag.date)}, ${tag.entries.length} Posten, Saldo ${formatMoney(tag.net)}`}
|
||||
className={cn(
|
||||
"flex min-h-[5.5rem] flex-col rounded-lg border p-1.5 text-left transition",
|
||||
istGewaehlt ? "border-accent bg-accent/10" : "border-line hover:border-faint",
|
||||
!tag.is_business_day && "bg-raised/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs tabular",
|
||||
istHeute
|
||||
? "flex h-5 w-5 items-center justify-center rounded-full bg-accent font-semibold text-accent-ink"
|
||||
: tag.is_business_day
|
||||
? "text-muted"
|
||||
: "text-faint",
|
||||
)}
|
||||
>
|
||||
{tagesZahl}
|
||||
</span>
|
||||
{netto !== 0 && (
|
||||
<span
|
||||
className={cn("tabular text-[10px]", netto < 0 ? "text-negative" : "text-positive")}
|
||||
>
|
||||
{netto > 0 ? "+" : "−"}
|
||||
{new Intl.NumberFormat("de-DE", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.abs(netto))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap gap-0.5">
|
||||
{tag.entries.slice(0, 4).map((eintrag, index) => {
|
||||
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
|
||||
return (
|
||||
<MerchantLogo
|
||||
key={`${eintrag.title}-${index}`}
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={18}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{tag.entries.length > 4 && (
|
||||
<span className="self-center text-[10px] text-faint">+{tag.entries.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TagDetail({
|
||||
tag,
|
||||
firmen,
|
||||
onClose,
|
||||
}: {
|
||||
tag: CalendarDay;
|
||||
firmen: Map<number, Merchant>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const bestaetigen = useConfirmOccurrence();
|
||||
const auslassen = useSkipOccurrence();
|
||||
|
||||
return (
|
||||
<section className="card p-4">
|
||||
<header className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-ink">{formatDate(tag.date)}</h2>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted">
|
||||
Saldo{" "}
|
||||
<span className={cn("tabular", toNumber(tag.net) < 0 ? "text-negative" : "text-positive")}>
|
||||
{formatMoney(tag.net)}
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tag.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="Nichts fällig"
|
||||
description="An diesem Tag steht keine Zahlung an."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{tag.entries.map((eintrag, index) => (
|
||||
<EintragZeile
|
||||
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
|
||||
eintrag={eintrag}
|
||||
firma={eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined}
|
||||
onConfirm={() =>
|
||||
eintrag.recurrence_id &&
|
||||
eintrag.occurrence_date &&
|
||||
bestaetigen.mutate({
|
||||
recurrence_id: eintrag.recurrence_id,
|
||||
occurrence_date: eintrag.occurrence_date,
|
||||
})
|
||||
}
|
||||
onSkip={() =>
|
||||
eintrag.recurrence_id &&
|
||||
eintrag.occurrence_date &&
|
||||
auslassen.mutate({
|
||||
recurrence_id: eintrag.recurrence_id,
|
||||
occurrence_date: eintrag.occurrence_date,
|
||||
})
|
||||
}
|
||||
busy={bestaetigen.isPending || auslassen.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EintragZeile({
|
||||
eintrag,
|
||||
firma,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
busy,
|
||||
}: {
|
||||
eintrag: CalendarEntry;
|
||||
firma: Merchant | undefined;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const bestaetigbar = eintrag.source === "recurrence" && eintrag.status === "planned";
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 py-2">
|
||||
<MerchantLogo
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={32}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{eintrag.title}</p>
|
||||
<p className="text-xs text-faint">
|
||||
{eintrag.source === "transaction"
|
||||
? "Einmalige Buchung"
|
||||
: eintrag.status === "confirmed"
|
||||
? "Bestätigt"
|
||||
: "Geplant"}
|
||||
{eintrag.is_variable && " · geschätzt"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 tabular text-sm font-medium",
|
||||
eintrag.kind === "income" ? "text-positive" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{eintrag.kind === "income" ? "+" : "−"}
|
||||
{formatMoney(eintrag.amount)}
|
||||
</span>
|
||||
|
||||
{bestaetigbar && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
aria-label={`${eintrag.title} bestätigen`}
|
||||
>
|
||||
<Check aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onSkip}
|
||||
disabled={busy}
|
||||
aria-label={`${eintrag.title} auslassen`}
|
||||
>
|
||||
<SkipForward aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/** Dashboard: die Kennzahlen des Monats auf einen Blick. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, PiggyBank, Wallet } from "lucide-react";
|
||||
|
||||
import { CategoryDonut } from "@/components/charts/CategoryDonut";
|
||||
import { BudgetMeter } from "@/components/charts/BudgetMeter";
|
||||
import { ChartCard, DeltaHint, StatTile } from "@/components/charts/ChartFrame";
|
||||
import { ForecastChart } from "@/components/charts/ForecastChart";
|
||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
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 { useDashboard } from "@/hooks/useReports";
|
||||
import { addMonthsIso, firstOfMonth, formatDate, formatMoney, formatMonth, relativeDays } from "@/lib/format";
|
||||
|
||||
export function DashboardPage() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const { data, isLoading } = useDashboard(monat);
|
||||
const { data: firmenSeite } = useMerchants();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description={formatMonth(monat)}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, -1))}
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMonat(firstOfMonth())}>
|
||||
Heute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setMonat((alt) => addMonthsIso(alt, 1))}
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight aria-hidden className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-72" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Die große Kennzahl steht allein – sie ist die Antwort auf die
|
||||
häufigste Frage und braucht kein Diagramm. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile
|
||||
label="Verfügbar nach Fixkosten"
|
||||
value={formatMoney(data.month.available_after_fixed)}
|
||||
large
|
||||
tone={
|
||||
Number.parseFloat(data.month.available_after_fixed) < 0 ? "negative" : "positive"
|
||||
}
|
||||
hint={`Einkünfte ${formatMoney(data.month.planned.income)} abzüglich Fixkosten ${formatMoney(
|
||||
data.month.fixed_costs,
|
||||
)}${
|
||||
data.month.reserves !== "0.00"
|
||||
? ` und Rücklagen ${formatMoney(data.month.reserves)}`
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Einkünfte im Monat"
|
||||
value={formatMoney(data.month.planned.income)}
|
||||
hint={<DeltaHint value={data.month.delta_to_previous.income} />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Ausgaben im Monat"
|
||||
value={formatMoney(data.month.planned.expenses)}
|
||||
hint={
|
||||
<DeltaHint value={data.month.delta_to_previous.expenses} goodWhenPositive={false} />
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Kontostand heute"
|
||||
value={formatMoney(data.total_balance)}
|
||||
hint={`${data.month.open_count} offen, ${data.month.confirmed_count} bestätigt`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.upcoming_deadlines.length > 0 && (
|
||||
<div className="card border-warning/40 p-4">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
|
||||
<AlertTriangle aria-hidden className="h-4 w-4" />
|
||||
Kündigungsfristen laufen ab
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{data.upcoming_deadlines.map((eintrag) => (
|
||||
<li key={eintrag.recurrence_id} className="flex flex-wrap gap-x-2 text-xs">
|
||||
<span className="font-medium text-ink">{eintrag.title}</span>
|
||||
<span className="text-muted">
|
||||
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
|
||||
</span>
|
||||
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<CategoryDonut
|
||||
categories={data.categories}
|
||||
description={`Ausgaben im ${formatMonth(monat)}. Klick auf eine Kategorie zeigt die Unterkategorien.`}
|
||||
/>
|
||||
|
||||
<ChartCard
|
||||
title="Nächste Fälligkeiten"
|
||||
description="Die kommenden 14 Tage."
|
||||
>
|
||||
{data.upcoming.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted">
|
||||
In den nächsten zwei Wochen steht nichts an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line">
|
||||
{data.upcoming.slice(0, 8).map((eintrag, index) => {
|
||||
const firma = eintrag.merchant_id ? firmen.get(eintrag.merchant_id) : undefined;
|
||||
return (
|
||||
<li
|
||||
key={`${eintrag.recurrence_id ?? "t"}-${eintrag.occurrence_date ?? index}`}
|
||||
className="flex items-center gap-2.5 py-2"
|
||||
>
|
||||
<MerchantLogo
|
||||
merchant={
|
||||
firma ?? {
|
||||
name: eintrag.title,
|
||||
logo_asset_id: null,
|
||||
brand_color: null,
|
||||
brand_color_dark: null,
|
||||
}
|
||||
}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{eintrag.title}</p>
|
||||
<p className="truncate text-xs text-faint">
|
||||
{kategorieName(eintrag.category_id)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 tabular text-sm ${
|
||||
eintrag.kind === "income" ? "text-positive" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{eintrag.kind === "income" ? "+" : "−"}
|
||||
{formatMoney(eintrag.amount)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Link
|
||||
to="/calendar"
|
||||
className="mt-2 inline-block text-xs text-accent transition hover:underline"
|
||||
>
|
||||
Zum Cashflow-Kalender
|
||||
</Link>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<ForecastChart months={data.forecast} />
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Budgets</h2>
|
||||
{data.budgets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title="Keine Budgets für diesen Monat"
|
||||
description="Lege ein Budget an, um den Verbrauch je Kategorie im Blick zu behalten."
|
||||
action={
|
||||
<Link to="/budgets">
|
||||
<Button variant="primary">Budgets pflegen</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.budgets.map((budget) => (
|
||||
<BudgetMeter key={budget.category_id} budget={budget} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold text-ink">Sparziele</h2>
|
||||
{data.goals.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PiggyBank}
|
||||
title="Keine Sparziele"
|
||||
description="Lege ein Ziel an – moneyfy rechnet die nötige Monatsrate aus."
|
||||
action={
|
||||
<Link to="/goals">
|
||||
<Button variant="primary">Sparziel anlegen</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.goals.map((ziel) => (
|
||||
<li key={ziel.goal_id} className="card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-ink">{ziel.name}</span>
|
||||
<span className="shrink-0 tabular text-sm text-ink">
|
||||
{formatMoney(ziel.current_amount)}
|
||||
<span className="text-faint"> / {formatMoney(ziel.target_amount)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-raised">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(ziel.ratio, 1) * 100}%`,
|
||||
backgroundColor: ziel.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted">
|
||||
{ziel.target_date
|
||||
? `Bis ${formatDate(ziel.target_date)} (${relativeDays(ziel.target_date)}) · ${formatMoney(
|
||||
ziel.required_monthly,
|
||||
)} pro Monat nötig`
|
||||
: "Ohne Zieldatum"}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/** Sparziele mit Fortschritt und nötiger Monatsrate. */
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { AccountSelect } from "@/components/EntitySelects";
|
||||
import { PageHeader } from "@/components/layout/AppLayout";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
|
||||
import { Field, Input } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { MoneyInput } from "@/components/ui/MoneyInput";
|
||||
import { useDeleteGoal, useSaveGoal, useSavingsGoals } from "@/hooks/useBudgets";
|
||||
import { useGoalProgress } from "@/hooks/useReports";
|
||||
import { formatDate, formatMoney, relativeDays } from "@/lib/format";
|
||||
import type { SavingsGoal, SavingsGoalProgress } from "@/types/api";
|
||||
|
||||
export function GoalsPage() {
|
||||
const [bearbeiten, setBearbeiten] = useState<SavingsGoal | null | undefined>(undefined);
|
||||
const [loeschen, setLoeschen] = useState<SavingsGoal | null>(null);
|
||||
|
||||
const { data: ziele = [], isLoading } = useSavingsGoals();
|
||||
const { data: fortschritt = [] } = useGoalProgress();
|
||||
const entfernen = useDeleteGoal();
|
||||
|
||||
const nachId = new Map(fortschritt.map((eintrag) => [eintrag.goal_id, eintrag]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Sparziele"
|
||||
description="Fortschritt und die Rate, die bis zum Zieldatum nötig ist."
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setBearbeiten(null)}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Sparziel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
) : ziele.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={PiggyBank}
|
||||
title="Noch keine Sparziele"
|
||||
description="Ob neues Fahrrad oder Rücklage fürs Auto – moneyfy rechnet aus, was du monatlich zurücklegen musst."
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setBearbeiten(null)}>
|
||||
<Plus aria-hidden className="h-4 w-4" />
|
||||
Erstes Sparziel anlegen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{ziele.map((ziel) => (
|
||||
<GoalCard
|
||||
key={ziel.id}
|
||||
goal={ziel}
|
||||
progress={nachId.get(ziel.id)}
|
||||
onEdit={() => setBearbeiten(ziel)}
|
||||
onDelete={() => setLoeschen(ziel)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<GoalDialog
|
||||
goal={bearbeiten}
|
||||
open={bearbeiten !== undefined}
|
||||
onClose={() => setBearbeiten(undefined)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={loeschen !== null}
|
||||
title="Sparziel löschen"
|
||||
description={`„${loeschen?.name}“ wird endgültig gelöscht.`}
|
||||
loading={entfernen.isPending}
|
||||
onCancel={() => setLoeschen(null)}
|
||||
onConfirm={() => {
|
||||
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalCard({
|
||||
goal,
|
||||
progress,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
goal: SavingsGoal;
|
||||
progress: SavingsGoalProgress | undefined;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const anteil = Math.min(progress?.ratio ?? 0, 1);
|
||||
const erreicht = anteil >= 1;
|
||||
|
||||
return (
|
||||
<li className="card group p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-ink">{goal.name}</h3>
|
||||
<p className="text-xs text-muted">
|
||||
{goal.target_date
|
||||
? `Ziel bis ${formatDate(goal.target_date)} · ${relativeDays(goal.target_date)}`
|
||||
: "Ohne Zieldatum"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
|
||||
<Button size="sm" variant="ghost" onClick={onEdit} aria-label={`${goal.name} bearbeiten`}>
|
||||
<Pencil aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} aria-label={`${goal.name} löschen`}>
|
||||
<Trash2 aria-hidden className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 tabular text-2xl font-semibold text-ink">
|
||||
{formatMoney(goal.current_amount)}
|
||||
<span className="text-base font-normal text-faint"> / {formatMoney(goal.target_amount)}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-2 h-2 overflow-hidden rounded-full bg-raised"
|
||||
role="meter"
|
||||
aria-valuenow={Math.round(anteil * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${goal.name}: ${Math.round(anteil * 100)} Prozent erreicht`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${anteil * 100}%`, backgroundColor: goal.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{Math.round(anteil * 100)} % erreicht</span>
|
||||
{erreicht ? (
|
||||
<Badge tone="positive">
|
||||
<CheckCircle2 aria-hidden className="h-3 w-3" />
|
||||
Ziel erreicht
|
||||
</Badge>
|
||||
) : (
|
||||
progress?.required_monthly && (
|
||||
<span className="text-muted">
|
||||
{formatMoney(progress.required_monthly)} pro Monat nötig
|
||||
{progress.months_left !== null && ` (${progress.months_left} Monate)`}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress?.is_on_track === false && (
|
||||
<p className="mt-2 flex items-center gap-1.5 rounded-lg bg-warning/10 px-2 py-1.5 text-xs text-warning">
|
||||
<AlertTriangle aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
Die geplante Rate von {formatMoney(goal.monthly_contribution)} reicht nicht bis zum
|
||||
Zieldatum.
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalDialog({
|
||||
goal,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
goal: SavingsGoal | null | undefined;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const speichern = useSaveGoal();
|
||||
const [name, setName] = useState("");
|
||||
const [ziel, setZiel] = useState("");
|
||||
const [stand, setStand] = useState("0.00");
|
||||
const [zieldatum, setZieldatum] = useState("");
|
||||
const [rate, setRate] = useState("");
|
||||
const [konto, setKonto] = useState<number | null>(null);
|
||||
const [farbe, setFarbe] = useState("#10b981");
|
||||
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
|
||||
|
||||
if (open && initialisiert !== (goal?.id ?? null)) {
|
||||
setName(goal?.name ?? "");
|
||||
setZiel(goal?.target_amount ?? "");
|
||||
setStand(goal?.current_amount ?? "0.00");
|
||||
setZieldatum(goal?.target_date ?? "");
|
||||
setRate(goal?.monthly_contribution ?? "");
|
||||
setKonto(goal?.account_id ?? null);
|
||||
setFarbe(goal?.color ?? "#10b981");
|
||||
setInitialisiert(goal?.id ?? null);
|
||||
}
|
||||
|
||||
function absenden(ereignis: FormEvent) {
|
||||
ereignis.preventDefault();
|
||||
if (!name.trim() || !ziel) return;
|
||||
|
||||
speichern.mutate(
|
||||
{
|
||||
id: goal?.id,
|
||||
daten: {
|
||||
name: name.trim(),
|
||||
target_amount: ziel,
|
||||
current_amount: stand || "0.00",
|
||||
target_date: zieldatum || null,
|
||||
monthly_contribution: rate || null,
|
||||
account_id: konto,
|
||||
color: farbe,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInitialisiert(undefined);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={goal ? "Sparziel bearbeiten" : "Neues Sparziel"}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={absenden}
|
||||
loading={speichern.isPending}
|
||||
disabled={!name.trim() || !ziel}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={absenden} className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Name" required className="sm:col-span-2">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Neues Fahrrad"
|
||||
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Zielbetrag" required>
|
||||
{(id) => <MoneyInput id={id} value={ziel} onValueChange={setZiel} />}
|
||||
</Field>
|
||||
|
||||
<Field label="Bereits gespart">
|
||||
{(id) => <MoneyInput id={id} value={stand} onValueChange={setStand} />}
|
||||
</Field>
|
||||
|
||||
<Field label="Zieldatum" hint="Ohne Datum wird keine Rate berechnet.">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="date"
|
||||
value={zieldatum}
|
||||
onChange={(ereignis) => setZieldatum(ereignis.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Geplante Monatsrate" hint="moneyfy prüft, ob sie ausreicht.">
|
||||
{(id) => <MoneyInput id={id} value={rate} onValueChange={setRate} />}
|
||||
</Field>
|
||||
|
||||
<Field label="Konto">
|
||||
{(id) => <AccountSelect id={id} value={konto} onChange={setKonto} />}
|
||||
</Field>
|
||||
|
||||
<Field label="Farbe">
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="color"
|
||||
value={farbe}
|
||||
className="h-10 p-1"
|
||||
onChange={(ereignis) => setFarbe(ereignis.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/** Auswertungen: Abos, Kategorien, Jahresvergleich und Export. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { AlertTriangle, Download, Repeat } from "lucide-react";
|
||||
|
||||
import { CategoryDonut } from "@/components/charts/CategoryDonut";
|
||||
import { ChartCard, StatTile } from "@/components/charts/ChartFrame";
|
||||
import { YearComparisonChart } from "@/components/charts/YearComparisonChart";
|
||||
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 { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||
import {
|
||||
exportUrl,
|
||||
useCategoryReport,
|
||||
useSubscriptions,
|
||||
useYearComparison,
|
||||
} from "@/hooks/useReports";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { firstOfMonth, formatDate, formatMoney, formatMonth, toNumber } from "@/lib/format";
|
||||
import { describeRRule } from "@/lib/rrule";
|
||||
import type { Subscription } from "@/types/api";
|
||||
|
||||
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
||||
|
||||
const REITER: { id: Reiter; label: string }[] = [
|
||||
{ id: "subscriptions", label: "Abos" },
|
||||
{ id: "categories", label: "Kategorien" },
|
||||
{ id: "year", label: "Jahresvergleich" },
|
||||
{ id: "export", label: "Export" },
|
||||
];
|
||||
|
||||
export function ReportsPage() {
|
||||
const [reiter, setReiter] = useState<Reiter>("subscriptions");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Auswertungen" />
|
||||
|
||||
<div className="mb-5 flex gap-1 overflow-x-auto border-b border-line" role="tablist">
|
||||
{REITER.map((eintrag) => (
|
||||
<button
|
||||
key={eintrag.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={reiter === eintrag.id}
|
||||
onClick={() => setReiter(eintrag.id)}
|
||||
className={cn(
|
||||
"-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition",
|
||||
reiter === eintrag.id
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{eintrag.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reiter === "subscriptions" && <SubscriptionsTab />}
|
||||
{reiter === "categories" && <CategoriesTab />}
|
||||
{reiter === "year" && <YearTab />}
|
||||
{reiter === "export" && <ExportTab />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Abos ----------------------------------------------------------------- */
|
||||
|
||||
type Sortierung = "annual" | "monthly" | "title" | "notice";
|
||||
|
||||
function SubscriptionsTab() {
|
||||
const [sortierung, setSortierung] = useState<Sortierung>("annual");
|
||||
const { data, isLoading } = useSubscriptions();
|
||||
const kategorieName = useCategoryLookup();
|
||||
|
||||
if (isLoading || !data) return <Skeleton className="h-64" />;
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title="Keine laufenden Posten"
|
||||
description="Sobald wiederkehrende Ausgaben angelegt sind, erscheint hier die Jahresübersicht."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sortiert = [...data.entries].sort((links, rechts) => {
|
||||
switch (sortierung) {
|
||||
case "monthly":
|
||||
return toNumber(rechts.monthly_cost) - toNumber(links.monthly_cost);
|
||||
case "title":
|
||||
return links.title.localeCompare(rechts.title, "de");
|
||||
case "notice":
|
||||
return (links.days_until_notice ?? 99999) - (rechts.days_until_notice ?? 99999);
|
||||
default:
|
||||
return toNumber(rechts.annual_cost) - toNumber(links.annual_cost);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
|
||||
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
|
||||
<StatTile
|
||||
label="Laufende Posten"
|
||||
value={String(data.entries.filter((eintrag) => !eintrag.is_installment).length)}
|
||||
hint={`zuzüglich ${data.entries.filter((eintrag) => eintrag.is_installment).length} Ratenzahlungen`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.upcoming_deadlines.length > 0 && (
|
||||
<div className="card border-warning/40 p-4">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-warning">
|
||||
<AlertTriangle aria-hidden className="h-4 w-4" />
|
||||
Kündigungsfristen der nächsten 60 Tage
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
{data.upcoming_deadlines.map((eintrag) => (
|
||||
<li key={eintrag.recurrence_id} className="flex flex-wrap items-center gap-x-2">
|
||||
<span className="font-medium text-ink">{eintrag.title}</span>
|
||||
<span className="text-muted">
|
||||
kündbar bis {formatDate(eintrag.contract_term?.notice_deadline)}
|
||||
</span>
|
||||
<Badge tone="warning">noch {eintrag.days_until_notice} Tage</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChartCard
|
||||
title="Alle laufenden Posten"
|
||||
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
|
||||
actions={
|
||||
<Select
|
||||
aria-label="Sortierung"
|
||||
className="h-9 w-44"
|
||||
value={sortierung}
|
||||
onChange={(ereignis) => setSortierung(ereignis.target.value as Sortierung)}
|
||||
>
|
||||
<option value="annual">Nach Jahreskosten</option>
|
||||
<option value="monthly">Nach Monatskosten</option>
|
||||
<option value="title">Nach Name</option>
|
||||
<option value="notice">Nach Kündigungsfrist</option>
|
||||
</Select>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<caption className="sr-only">Laufende Posten mit Jahreskosten</caption>
|
||||
<thead className="border-b border-line text-left text-xs text-muted">
|
||||
<tr>
|
||||
<th scope="col" className="py-2 font-medium">
|
||||
Posten
|
||||
</th>
|
||||
<th scope="col" className="hidden py-2 font-medium md:table-cell">
|
||||
Rhythmus
|
||||
</th>
|
||||
<th scope="col" className="py-2 text-right font-medium">
|
||||
pro Monat
|
||||
</th>
|
||||
<th scope="col" className="py-2 text-right font-medium">
|
||||
pro Jahr
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{sortiert.map((eintrag) => (
|
||||
<SubscriptionRow
|
||||
key={eintrag.recurrence_id}
|
||||
entry={eintrag}
|
||||
categoryName={kategorieName(eintrag.category_id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({
|
||||
entry,
|
||||
categoryName,
|
||||
}: {
|
||||
entry: Subscription;
|
||||
categoryName: string;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="py-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-ink">{entry.title}</span>
|
||||
{entry.is_installment && <Badge tone="accent">Raten</Badge>}
|
||||
{entry.is_cancelled && <Badge tone="negative">Gekündigt</Badge>}
|
||||
{entry.days_until_notice !== null && entry.days_until_notice <= 60 && (
|
||||
<Badge tone="warning">Frist in {entry.days_until_notice} Tagen</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-faint">
|
||||
{entry.merchant_name ? `${entry.merchant_name} · ` : ""}
|
||||
{categoryName}
|
||||
</p>
|
||||
</td>
|
||||
<td className="hidden py-2 text-xs text-muted md:table-cell">
|
||||
{describeRRule(entry.rrule)}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular text-muted">{formatMoney(entry.monthly_cost)}</td>
|
||||
<td className="py-2 text-right tabular font-medium text-ink">
|
||||
{formatMoney(entry.annual_cost)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Kategorien ----------------------------------------------------------- */
|
||||
|
||||
function CategoriesTab() {
|
||||
const [zeitraum, setZeitraum] = useState<"month" | "year">("month");
|
||||
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 { data, isLoading } = useCategoryReport(von, bis);
|
||||
|
||||
if (isLoading || !data) return <Skeleton className="h-72" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
["month", formatMonth(von)],
|
||||
["year", String(heute.getFullYear())],
|
||||
] as const
|
||||
).map(([wert, beschriftung]) => (
|
||||
<Button
|
||||
key={wert}
|
||||
size="sm"
|
||||
variant={zeitraum === wert ? "primary" : "secondary"}
|
||||
onClick={() => setZeitraum(wert)}
|
||||
>
|
||||
{beschriftung}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CategoryDonut
|
||||
categories={data.categories}
|
||||
title="Ausgaben nach Kategorien"
|
||||
description="Klick auf eine Kategorie zeigt die Unterkategorien."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Jahresvergleich ------------------------------------------------------ */
|
||||
|
||||
function YearTab() {
|
||||
const [jahr, setJahr] = useState(() => new Date().getFullYear());
|
||||
const { data, isLoading } = useYearComparison(jahr);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
aria-label="Jahr"
|
||||
className="h-9 w-32"
|
||||
value={jahr}
|
||||
onChange={(ereignis) => setJahr(Number(ereignis.target.value))}
|
||||
>
|
||||
{Array.from({ length: 6 }, (_, index) => new Date().getFullYear() - index).map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{wert}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{isLoading || !data ? <Skeleton className="h-72" /> : <YearComparisonChart data={data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Export --------------------------------------------------------------- */
|
||||
|
||||
function ExportTab() {
|
||||
const [monat, setMonat] = useState(() => firstOfMonth());
|
||||
const jahr = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<ExportCard
|
||||
title="Buchungen"
|
||||
description={`Alle einmaligen Buchungen des Jahres ${jahr}.`}
|
||||
links={[
|
||||
{ label: "CSV", href: exportUrl("transactions", "csv") },
|
||||
{ label: "Excel", href: exportUrl("transactions", "xlsx") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ExportCard
|
||||
title="Wiederkehrende Posten"
|
||||
description="Alle Serien mit Rhythmus, Jahreskosten und Vertragsdaten."
|
||||
links={[
|
||||
{ label: "CSV", href: exportUrl("recurrences", "csv") },
|
||||
{ label: "Excel", href: exportUrl("recurrences", "xlsx") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ExportCard
|
||||
title="Monatsauswertung"
|
||||
description="Alle Bewegungen des Monats samt Kennzahlen."
|
||||
extra={
|
||||
<input
|
||||
type="month"
|
||||
aria-label="Monat der Auswertung"
|
||||
value={monat.slice(0, 7)}
|
||||
onChange={(ereignis) => setMonat(`${ereignis.target.value}-01`)}
|
||||
className="mb-3 w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink"
|
||||
/>
|
||||
}
|
||||
links={[
|
||||
{ label: "CSV", href: exportUrl("month", "csv", { month: monat }) },
|
||||
{ label: "Excel", href: exportUrl("month", "xlsx", { month: monat }) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportCard({
|
||||
title,
|
||||
description,
|
||||
links,
|
||||
extra,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
links: { label: string; href: string }[];
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="card flex flex-col p-4">
|
||||
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
||||
<p className="mb-3 mt-0.5 text-xs text-muted">{description}</p>
|
||||
{extra}
|
||||
<div className="mt-auto flex gap-2">
|
||||
{links.map((verweis) => (
|
||||
<a
|
||||
key={verweis.label}
|
||||
href={verweis.href}
|
||||
download
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-raised px-3 text-xs font-medium text-ink transition hover:bg-line"
|
||||
>
|
||||
<Download aria-hidden className="h-3.5 w-3.5" />
|
||||
{verweis.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -21,3 +21,45 @@ Object.defineProperty(window, "matchMedia", {
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
// jsdom rechnet kein Layout: ohne diese Ergänzungen bliebe jedes Diagramm
|
||||
// 0 Pixel groß und Recharts würde bei jedem Test eine Warnung ausgeben.
|
||||
const TEST_BREITE = 640;
|
||||
const TEST_HOEHE = 320;
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
constructor(private readonly rueckruf: ResizeObserverCallback) {}
|
||||
|
||||
observe(ziel: Element): void {
|
||||
const abmessung = { inlineSize: TEST_BREITE, blockSize: TEST_HOEHE };
|
||||
this.rueckruf(
|
||||
[
|
||||
{
|
||||
target: ziel,
|
||||
contentRect: { width: TEST_BREITE, height: TEST_HOEHE } as DOMRectReadOnly,
|
||||
borderBoxSize: [abmessung],
|
||||
contentBoxSize: [abmessung],
|
||||
devicePixelContentBoxSize: [abmessung],
|
||||
} as ResizeObserverEntry,
|
||||
],
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver ??= ResizeObserverStub;
|
||||
|
||||
for (const [eigenschaft, wert] of [
|
||||
["offsetWidth", TEST_BREITE],
|
||||
["clientWidth", TEST_BREITE],
|
||||
["offsetHeight", TEST_HOEHE],
|
||||
["clientHeight", TEST_HOEHE],
|
||||
] as const) {
|
||||
Object.defineProperty(HTMLElement.prototype, eigenschaft, {
|
||||
configurable: true,
|
||||
value: wert,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -315,6 +315,58 @@ export interface Totals {
|
||||
balance: Money;
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
id: number;
|
||||
category_id: number;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
rollover: boolean;
|
||||
created_at: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface BudgetInput {
|
||||
category_id: number;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
rollover?: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetTemplate {
|
||||
id: number;
|
||||
category_id: number;
|
||||
valid_from: IsoDate;
|
||||
valid_until: IsoDate | null;
|
||||
limit_amount: Money;
|
||||
rollover: boolean;
|
||||
}
|
||||
|
||||
export interface SavingsGoal {
|
||||
id: number;
|
||||
name: string;
|
||||
target_amount: Money;
|
||||
target_date: IsoDate | null;
|
||||
current_amount: Money;
|
||||
account_id: number | null;
|
||||
monthly_contribution: Money | null;
|
||||
color: string;
|
||||
icon: string;
|
||||
is_archived: boolean;
|
||||
created_at: IsoDateTime;
|
||||
updated_at: IsoDateTime;
|
||||
}
|
||||
|
||||
export interface SavingsGoalInput {
|
||||
name: string;
|
||||
target_amount: Money;
|
||||
target_date?: IsoDate | null;
|
||||
current_amount?: Money;
|
||||
account_id?: number | null;
|
||||
monthly_contribution?: Money | null;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export interface MonthReport {
|
||||
month: IsoDate;
|
||||
planned: Totals;
|
||||
@@ -330,3 +382,150 @@ export interface MonthReport {
|
||||
open_count: number;
|
||||
skipped_count: number;
|
||||
}
|
||||
|
||||
export interface ForecastMonth {
|
||||
month: IsoDate;
|
||||
income: Money;
|
||||
expenses: Money;
|
||||
balance: Money;
|
||||
cumulative_balance: Money;
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
months: ForecastMonth[];
|
||||
total_income: Money;
|
||||
total_expenses: Money;
|
||||
}
|
||||
|
||||
export interface CategorySlice {
|
||||
category_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
amount: Money;
|
||||
count: number;
|
||||
children: CategorySlice[];
|
||||
}
|
||||
|
||||
export interface CategoryReport {
|
||||
date_from: IsoDate;
|
||||
date_to: IsoDate;
|
||||
kind: EntryKind;
|
||||
total: Money;
|
||||
categories: CategorySlice[];
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
recurrence_id: number;
|
||||
title: string;
|
||||
merchant_id: number | null;
|
||||
merchant_name: string | null;
|
||||
category_id: number;
|
||||
amount: Money;
|
||||
annual_cost: Money;
|
||||
monthly_cost: Money;
|
||||
rrule: string;
|
||||
is_installment: boolean;
|
||||
is_cancelled: boolean;
|
||||
contract_term: ContractTerm | null;
|
||||
days_until_notice: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionReport {
|
||||
entries: Subscription[];
|
||||
total_annual: Money;
|
||||
total_monthly: Money;
|
||||
upcoming_deadlines: Subscription[];
|
||||
}
|
||||
|
||||
export interface YearComparisonRow {
|
||||
category_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
current: Money;
|
||||
previous: Money;
|
||||
delta: Money;
|
||||
}
|
||||
|
||||
export interface YearComparison {
|
||||
year: number;
|
||||
rows: YearComparisonRow[];
|
||||
current_total: Money;
|
||||
previous_total: Money;
|
||||
}
|
||||
|
||||
export interface CalendarEntry {
|
||||
title: string;
|
||||
kind: EntryKind;
|
||||
amount: Money;
|
||||
category_id: number;
|
||||
merchant_id: number | null;
|
||||
account_id: number | null;
|
||||
source: "recurrence" | "transaction";
|
||||
recurrence_id: number | null;
|
||||
occurrence_date: IsoDate | null;
|
||||
status: OccurrenceStatus | null;
|
||||
is_variable: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: IsoDate;
|
||||
entries: CalendarEntry[];
|
||||
net: Money;
|
||||
running_balance: Money;
|
||||
is_business_day: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarMonth {
|
||||
month: IsoDate;
|
||||
days: CalendarDay[];
|
||||
opening_balance: Money;
|
||||
closing_balance: Money;
|
||||
lowest_balance: Money;
|
||||
lowest_balance_on: IsoDate | null;
|
||||
}
|
||||
|
||||
export type BudgetState = "ok" | "warning" | "exceeded";
|
||||
|
||||
export interface BudgetStatus {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
color: string;
|
||||
period_month: IsoDate;
|
||||
limit_amount: Money;
|
||||
carried_over: Money;
|
||||
available: Money;
|
||||
spent: Money;
|
||||
remaining: Money;
|
||||
ratio: number;
|
||||
state: BudgetState;
|
||||
rollover: boolean;
|
||||
is_template: boolean;
|
||||
}
|
||||
|
||||
export interface SavingsGoalProgress {
|
||||
goal_id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
target_amount: Money;
|
||||
current_amount: Money;
|
||||
remaining_amount: Money;
|
||||
ratio: number;
|
||||
target_date: IsoDate | null;
|
||||
months_left: number | null;
|
||||
required_monthly: Money | null;
|
||||
monthly_contribution: Money | null;
|
||||
is_on_track: boolean | null;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
month: MonthReport;
|
||||
total_balance: Money;
|
||||
forecast: ForecastMonth[];
|
||||
categories: CategorySlice[];
|
||||
budgets: BudgetStatus[];
|
||||
goals: SavingsGoalProgress[];
|
||||
upcoming: CalendarEntry[];
|
||||
upcoming_deadlines: Subscription[];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Recharts wiegt mehr als der Rest der Anwendung und ändert sich selten –
|
||||
// als eigener Chunk bleibt er über Releases hinweg im Browser-Cache.
|
||||
manualChunks: {
|
||||
charts: ["recharts"],
|
||||
vendor: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user