"""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() ) @router.get("/budget-donut") def budget_donut(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) segments = [] total = 0 for g in groups: val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived) if val > 0: segments.append({"name": g.name, "value": val, "color": g.color}) total += val mode = "planned" if total == 0: actual = budget.actual_expense_by_category(db, user, start, end, base) for g in groups: val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived) if val > 0: segments.append({"name": g.name, "value": val, "color": g.color}) total += val mode = "actual" return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label} @router.get("/budget-progress") def budget_progress(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) rows = [] for g in _expense_groups(db, user): gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived) ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived) if gp > 0 or ga > 0: rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp}) return {"rows": rows, "currency": base, "label": label} @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}