- Budget-Donut: Mittelbetrag passt sich der Länge an (kein Hineinlaufen in den Ring bei 4-stelligen Beträgen) - Vermögensverlauf: Y-Achse ohne Währungssymbol (formatAmount) + etwas mehr linker Rand → „CHF …" wird nicht mehr abgeschnitten - Karten-Optionen: im Anordnen-Modus je Karte einstellbar; Budget-Donut und Budget-Fortschritt können nach Gruppen ODER Kategorien aufschlüsseln (?group_by=), Auswahl wird im Layout (props) mitgespeichert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""Dashboard: pro Nutzer gespeichertes Karten-Layout und die Datenendpunkte je Karte."""
|
|
|
|
import json
|
|
from collections import defaultdict
|
|
from datetime import date, timedelta
|
|
|
|
from fastapi import APIRouter, Body, Depends, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..database import get_db
|
|
from ..deps import get_current_user
|
|
from ..models import (
|
|
Account,
|
|
Category,
|
|
CategoryGroup,
|
|
CategoryKind,
|
|
DashboardLayout,
|
|
Transaction,
|
|
TxnType,
|
|
User,
|
|
)
|
|
from ..services import balances, budget, currency
|
|
from .accounts import base_currency
|
|
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
|
|
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
|
|
# Sinnvolles Standard-Layout (12-Spalten-Raster). Eine Karte je Typ.
|
|
DEFAULT_LAYOUT = [
|
|
{"i": "account-balances", "type": "account-balances", "x": 0, "y": 0, "w": 4, "h": 5},
|
|
{"i": "budget-donut", "type": "budget-donut", "x": 4, "y": 0, "w": 4, "h": 5},
|
|
{"i": "income-expense", "type": "income-expense", "x": 8, "y": 0, "w": 4, "h": 2},
|
|
{"i": "budget-progress", "type": "budget-progress", "x": 8, "y": 2, "w": 4, "h": 3},
|
|
{"i": "net-worth", "type": "net-worth", "x": 0, "y": 5, "w": 8, "h": 4},
|
|
{"i": "recent-transactions", "type": "recent-transactions", "x": 8, "y": 5, "w": 4, "h": 4},
|
|
]
|
|
|
|
|
|
def _start_day(db: Session, user: User) -> int:
|
|
try:
|
|
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
|
except ValueError:
|
|
return 1
|
|
|
|
|
|
def _label(db: Session, user: User) -> str:
|
|
return budget.current_label(_start_day(db, user))
|
|
|
|
|
|
# --- Layout ----------------------------------------------------------------
|
|
|
|
|
|
@router.get("/layout")
|
|
def get_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
|
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
|
if row is None:
|
|
return {"layout": DEFAULT_LAYOUT, "default": True}
|
|
try:
|
|
layout = json.loads(row.layout)
|
|
except ValueError:
|
|
layout = DEFAULT_LAYOUT
|
|
return {"layout": layout, "default": False}
|
|
|
|
|
|
@router.put("/layout")
|
|
def save_layout(
|
|
layout: list = Body(...),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
|
if row is None:
|
|
row = DashboardLayout(owner_id=user.id, layout=json.dumps(layout))
|
|
db.add(row)
|
|
else:
|
|
row.layout = json.dumps(layout)
|
|
db.commit()
|
|
return {"layout": layout, "default": False}
|
|
|
|
|
|
@router.delete("/layout")
|
|
def reset_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
|
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"layout": DEFAULT_LAYOUT, "default": True}
|
|
|
|
|
|
# --- Kartendaten -----------------------------------------------------------
|
|
|
|
|
|
def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
|
|
return (
|
|
db.query(CategoryGroup)
|
|
.filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense)
|
|
.order_by(CategoryGroup.position, CategoryGroup.name)
|
|
.all()
|
|
)
|
|
|
|
|
|
def _units(groups: list[CategoryGroup], by_category: bool):
|
|
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
|
|
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
|
|
for g in groups:
|
|
if by_category:
|
|
for c in g.categories:
|
|
if not c.is_archived:
|
|
yield (c.name, c.color, [c.id])
|
|
else:
|
|
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
|
|
|
|
|
@router.get("/budget-donut")
|
|
def budget_donut(
|
|
group_by: str = Query(default="group"),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
base = base_currency(db, user)
|
|
label = _label(db, user)
|
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
|
planned = budget.effective_planned(db, user, label)
|
|
groups = _expense_groups(db, user)
|
|
by_category = group_by == "category"
|
|
|
|
def collect(values: dict[int, int]):
|
|
segs, tot = [], 0
|
|
for name, color, ids in _units(groups, by_category):
|
|
val = sum(values.get(i, 0) for i in ids)
|
|
if val > 0:
|
|
segs.append({"name": name, "value": val, "color": color})
|
|
tot += val
|
|
return segs, tot
|
|
|
|
segments, total = collect(planned)
|
|
mode = "planned"
|
|
if total == 0:
|
|
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
|
|
mode = "actual"
|
|
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
|
"label": label, "group_by": "category" if by_category else "group"}
|
|
|
|
|
|
@router.get("/budget-progress")
|
|
def budget_progress(
|
|
group_by: str = Query(default="group"),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
base = base_currency(db, user)
|
|
label = _label(db, user)
|
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
|
planned = budget.effective_planned(db, user, label)
|
|
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
|
by_category = group_by == "category"
|
|
rows = []
|
|
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
|
gp = sum(planned.get(i, 0) for i in ids)
|
|
ga = sum(actual.get(i, 0) for i in ids)
|
|
if gp > 0 or ga > 0:
|
|
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
|
|
return {"rows": rows, "currency": base, "label": label,
|
|
"group_by": "category" if by_category else "group"}
|
|
|
|
|
|
@router.get("/income-expense")
|
|
def income_expense(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
|
base = base_currency(db, user)
|
|
label = _label(db, user)
|
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
|
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values())
|
|
income = sum(budget.income_by_category(db, user, start, end, base).values())
|
|
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
|
|
|
|
|
@router.get("/balances")
|
|
def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
|
base = base_currency(db, user)
|
|
today = date.today()
|
|
accounts = (
|
|
db.query(Account)
|
|
.filter(Account.owner_id == user.id)
|
|
.order_by(Account.position, Account.name)
|
|
.all()
|
|
)
|
|
rows = []
|
|
total = 0
|
|
for a in accounts:
|
|
bb = balances.account_balance_base_cents(db, a, base, today)
|
|
rows.append({
|
|
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
|
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
|
|
"balance_base_cents": bb, "archived": a.is_archived,
|
|
})
|
|
if not a.is_archived:
|
|
total += bb
|
|
return {"accounts": rows, "total": total, "currency": base}
|
|
|
|
|
|
@router.get("/net-worth")
|
|
def net_worth(
|
|
days: int = Query(default=90, ge=7, le=365),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
base = base_currency(db, user)
|
|
end = date.today()
|
|
start = end - timedelta(days=days - 1)
|
|
|
|
accounts = db.query(Account).filter(Account.owner_id == user.id, Account.is_archived == False).all() # noqa: E712
|
|
acc_by_id = {a.id: a for a in accounts}
|
|
|
|
def to_eur(cents: int, ccy: str, on: date) -> int:
|
|
v = currency.to_eur_cents(db, cents, ccy, on)
|
|
return v if v is not None else cents
|
|
|
|
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
|
|
|
|
def eur_effect(t: Transaction) -> int:
|
|
if t.type == TxnType.income:
|
|
return t.amount_eur_cents
|
|
if t.type == TxnType.expense:
|
|
return -t.amount_eur_cents
|
|
# Transfer: Netto-Effekt aufs Vermögen = EUR-Wert Zugang - EUR-Wert Abgang.
|
|
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
|
if to_acc is None:
|
|
return 0 # Zielkonto archiviert/gelöscht: ignorieren
|
|
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
|
return to_eur(credited, to_acc.currency, t.date) - t.amount_eur_cents
|
|
|
|
txns = db.query(Transaction).filter(
|
|
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
|
).all() if acc_by_id else []
|
|
|
|
before = sum(eur_effect(t) for t in txns if t.date < start)
|
|
deltas: dict[date, int] = defaultdict(int)
|
|
for t in txns:
|
|
if start <= t.date <= end:
|
|
deltas[t.date] += eur_effect(t)
|
|
|
|
running = opening_eur + before
|
|
labels: list[str] = []
|
|
values_eur: list[int] = []
|
|
d = start
|
|
while d <= end:
|
|
running += deltas.get(d, 0)
|
|
labels.append(d.isoformat())
|
|
values_eur.append(running)
|
|
d += timedelta(days=1)
|
|
|
|
if base != "EUR":
|
|
rate = currency.rate_on(db, base, end)
|
|
if rate is not None:
|
|
values_eur = [int(round(v * float(rate))) for v in values_eur]
|
|
return {"labels": labels, "values": values_eur, "currency": base}
|
|
|
|
|
|
@router.get("/recent")
|
|
def recent(
|
|
limit: int = Query(default=8, le=50),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
base = base_currency(db, user)
|
|
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
|
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
|
|
rows = (
|
|
db.query(Transaction)
|
|
.filter(Transaction.owner_id == user.id)
|
|
.order_by(Transaction.date.desc(), Transaction.id.desc())
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
out = []
|
|
for t in rows:
|
|
acc = accs.get(t.account_id)
|
|
if t.type == TxnType.transfer:
|
|
to = accs.get(t.to_account_id)
|
|
title = f"{acc.name if acc else '?'} → {to.name if to else '?'}"
|
|
icon, color, sign = "arrow-left-right", "#6b7480", None
|
|
else:
|
|
cat = cats.get(t.category_id) if t.category_id else None
|
|
title = cat.name if cat else (t.payee or "—")
|
|
icon = cat.icon if cat else "more-horizontal"
|
|
color = cat.color if cat else "#6b7480"
|
|
sign = "pos" if t.type == TxnType.income else "neg"
|
|
out.append({
|
|
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
|
"title": title, "icon": icon, "color": color, "sign": sign,
|
|
"amount_cents": t.amount_cents, "currency": acc.currency if acc else "EUR",
|
|
"amount_base_cents": t.amount_eur_cents if base == "EUR"
|
|
else (currency.from_eur_cents(db, t.amount_eur_cents, base, t.date) or t.amount_eur_cents),
|
|
})
|
|
return {"transactions": out, "currency": base}
|