diff --git a/backend/app/main.py b/backend/app/main.py index 19a7cc9..da6f08c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutze Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt. """ +import threading from contextlib import asynccontextmanager from pathlib import Path @@ -22,6 +23,7 @@ from .routers import ( auth, budgets, categories, + dashboard, rates, settings as settings_router, transactions, @@ -44,10 +46,21 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: ensure_first_admin(db) - # Best-effort: aktuelle EZB-Kurse nachladen (ohne Internet läuft es weiter). - ecb_rates.ensure_rates_on_startup(db, settings.ecb_fetch_on_start) finally: db.close() + + # EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten + # (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst + # blockieren und der Health-Check nie grün werden). + if settings.ecb_fetch_on_start: + def _laden() -> None: + s = SessionLocal() + try: + ecb_rates.ensure_rates_on_startup(s, True) + finally: + s.close() + + threading.Thread(target=_laden, name="ecb-rates", daemon=True).start() yield @@ -89,6 +102,7 @@ api.include_router(accounts.router) api.include_router(categories.router) api.include_router(transactions.router) api.include_router(budgets.router) +api.include_router(dashboard.router) api.include_router(rates.router) app.include_router(api) diff --git a/backend/app/models.py b/backend/app/models.py index 20744c1..3dcca5a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -377,3 +377,20 @@ class BudgetPeriodLine(Base): __table_args__ = ( UniqueConstraint("budget_period_id", "category_id", name="uq_periodline_period_category"), ) + + +# --------------------------------------------------------------------------- +# Phase 3: Dashboard-Layout (frei anordbare Karten, JSON, je Benutzer) +# --------------------------------------------------------------------------- + + +class DashboardLayout(Base): + __tablename__ = "dashboard_layouts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True + ) + # JSON-Array: [{i, type, x, y, w, h, props}] + layout: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..d10f990 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,273 @@ +"""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} diff --git a/backend/migrations/versions/0004_dashboard.py b/backend/migrations/versions/0004_dashboard.py new file mode 100644 index 0000000..241b025 --- /dev/null +++ b/backend/migrations/versions/0004_dashboard.py @@ -0,0 +1,30 @@ +"""Dashboard-Layout je Benutzer. + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-08-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "dashboard_layouts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("layout", sa.Text(), nullable=False, server_default="[]"), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("owner_id", name="uq_dashboard_owner"), + ) + op.create_index("ix_dashboard_layouts_owner_id", "dashboard_layouts", ["owner_id"]) + + +def downgrade() -> None: + op.drop_index("ix_dashboard_layouts_owner_id", table_name="dashboard_layouts") + op.drop_table("dashboard_layouts") diff --git a/deploy/README.md b/deploy/README.md index ac558fe..6db4538 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,8 +1,12 @@ # Deployment (Proxmox-LXC + Nginx Proxy Manager) +> **Voraussetzung: Python 3.11+** – also ein **Debian-12-** oder **Ubuntu-24.04**-LXC +> (Debian 11 hat nur Python 3.9 und wird nicht unterstützt). `install.sh` prüft das +> und bricht mit einer klaren Meldung ab, falls die Version zu alt ist. + ## Installation -Im LXC (Debian/Ubuntu) das Repo klonen und als root installieren: +Im LXC (Debian 12 / Ubuntu 24.04) das Repo klonen und als root installieren: ```bash git clone /opt/certain-finance diff --git a/deploy/certain-finance.service b/deploy/certain-finance.service index 53c45b1..b2320ac 100644 --- a/deploy/certain-finance.service +++ b/deploy/certain-finance.service @@ -21,10 +21,10 @@ ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host ${A Restart=on-failure RestartSec=3 -# Härtung +# Härtung. Bewusst KEIN ProtectHome/ProtectSystem: das Repo (inkl. venv) darf +# auch unter /root oder /home liegen; ProtectHome=true würde diese Pfade für den +# Dienst ausblenden und zu "Failed to locate executable .../uvicorn" führen. NoNewPrivileges=true -ProtectSystem=full -ProtectHome=true PrivateTmp=true ReadWritePaths=/var/lib/certain-finance diff --git a/deploy/install.sh b/deploy/install.sh index f9a5ee8..cbf382e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -46,6 +46,18 @@ export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y python3 python3-venv python3-dev build-essential curl ca-certificates git +# --- Python-Version prüfen (3.11+ erforderlich) ------------------------------ +PY_VER="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo 0.0)" +PY_MAJOR="${PY_VER%%.*}" +PY_MINOR="${PY_VER##*.}" +if [[ "$PY_MAJOR" -lt 3 || ( "$PY_MAJOR" -eq 3 && "$PY_MINOR" -lt 11 ) ]]; then + error "Python 3.11+ wird benötigt – gefunden: $PY_VER." + error "Bitte einen Debian-12- oder Ubuntu-24.04-LXC verwenden (dort ist Python 3.11+ dabei)." + error "Ältere Systeme (z. B. Debian 11 = Python 3.9) werden nicht unterstützt." + exit 1 +fi +info "Python $PY_VER gefunden." + # --- Node.js (nur zum Bauen des Frontends nötig) ----------------------------- ensure_node() { local major=0 @@ -76,6 +88,11 @@ info "Richte Python-venv ein und installiere Abhängigkeiten …" python3 -m venv "$REPO_DIR/backend/.venv" "$REPO_DIR/backend/.venv/bin/pip" install --upgrade pip >/dev/null "$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt" +if [[ ! -x "$REPO_DIR/backend/.venv/bin/uvicorn" ]]; then + error "uvicorn wurde nicht in die venv installiert – die Abhängigkeiten sind unvollständig." + error "Bitte die Ausgabe oben auf pip-Fehler prüfen." + exit 1 +fi # --- Frontend bauen ---------------------------------------------------------- info "Baue das Frontend (Node wird nur hier gebraucht) …" @@ -146,9 +163,10 @@ ExecStart=$REPO_DIR/backend/.venv/bin/uvicorn app.main:app --host \${APP_BIND_HO Restart=on-failure RestartSec=3 NoNewPrivileges=true -ProtectSystem=full -ProtectHome=true PrivateTmp=true +# Bewusst KEIN ProtectHome/ProtectSystem: das Repo (inkl. venv) darf auch unter +# /root oder /home liegen; ProtectHome=true würde diese Pfade für den Dienst +# ausblenden -> "Failed to locate executable .../uvicorn". ReadWritePaths=$DATA_DIR [Install] diff --git a/web/package-lock.json b/web/package-lock.json index 5f95c7a..7e4c004 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "i18next": "^24.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.3", "react-i18next": "^15.4.0", "react-router-dom": "^6.28.0" }, @@ -1368,6 +1369,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1452,6 +1462,12 @@ "node": ">=6" } }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1625,6 +1641,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1674,6 +1699,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1699,6 +1735,38 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz", + "integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.4.tgz", + "integrity": "sha512-AyScMfqhgRfie9ECXddQeaYmTuNlomDj9SIryF/+vtiwXS6IsxX3cI6CCWbSRXqbAePHW397nPxvnekO5gViPg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -1725,6 +1793,12 @@ } } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1735,6 +1809,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.30.6", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", @@ -1767,6 +1855,12 @@ "react-dom": ">=16.8" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", diff --git a/web/package.json b/web/package.json index 81158e6..f4efe61 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "i18next": "^24.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^1.5.3", "react-i18next": "^15.4.0", "react-router-dom": "^6.28.0" }, diff --git a/web/src/api.js b/web/src/api.js index 6ab875e..c5ad9a2 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -175,4 +175,15 @@ export const api = { createIncome: (body) => request("/budget/income", { method: "POST", body }), updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }), deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }), + + // Dashboard + dashboardLayout: () => request("/dashboard/layout"), + saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }), + resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }), + cardBudgetDonut: () => request("/dashboard/budget-donut"), + cardBudgetProgress: () => request("/dashboard/budget-progress"), + cardIncomeExpense: () => request("/dashboard/income-expense"), + cardBalances: () => request("/dashboard/balances"), + cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`), + cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`), }; diff --git a/web/src/dashboard/charts/Donut.jsx b/web/src/dashboard/charts/Donut.jsx new file mode 100644 index 0000000..235acc5 --- /dev/null +++ b/web/src/dashboard/charts/Donut.jsx @@ -0,0 +1,64 @@ +import { useState } from "react"; + +// Ringdiagramm für Anteile. segmente: [{ name, value, color }] +// fmt: Formatierer für die Werte (z. B. Geldbetrag). +export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) => String(v) }) { + const [aktiv, setAktiv] = useState(null); + + const werte = segmente.filter((s) => s.value > 0); + const summe = werte.reduce((s, e) => s + e.value, 0); + if (summe <= 0) return
Keine Daten vorhanden.
; + + const groesse = 180; + const mitte = groesse / 2; + const radius = 74; + const dicke = 26; + const umfang = 2 * Math.PI * radius; + const luecke = 2; + + let versatz = 0; + const boegen = werte.map((s) => { + const anteil = s.value / summe; + const laenge = Math.max(anteil * umfang - luecke, 0.5); + const bogen = { ...s, laenge, versatz, anteil }; + versatz += anteil * umfang; + return bogen; + }); + const gezeigt = aktiv ?? null; + + return ( +
+ + + {boegen.map((b) => ( + setAktiv(b)} onMouseLeave={() => setAktiv(null)} /> + ))} + + + {fmt(gezeigt ? gezeigt.value : summe)} + + + {gezeigt ? gezeigt.name : gesamtLabel} + + + +
+
    + {boegen.map((b) => ( +
  • setAktiv(b)} onMouseLeave={() => setAktiv(null)}> + + {b.name} + {fmt(b.value)} · {Math.round(b.anteil * 100)} % +
  • + ))} +
+
+
+ ); +} diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx new file mode 100644 index 0000000..33d6924 --- /dev/null +++ b/web/src/dashboard/charts/Line.jsx @@ -0,0 +1,109 @@ +import { useRef, useState } from "react"; +import { tokenFarbe } from "../palette"; +import useGroesse from "./useGroesse"; + +// Liniendiagramm über die Zeit. +// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }] +// fmt: Werteformatierer. +export default function Line({ labels = [], serien = [], fmt = (v) => String(v) }) { + const wirt = useRef(null); + const plot = useRef(null); + const { breite, hoehe } = useGroesse(plot); + const [index, setIndex] = useState(null); + + const legende = serien.length > 1; + const hatWerte = serien.some((s) => s.werte?.length); + + if (labels.length <= 1 || !hatWerte) { + return
Keine Daten vorhanden.
; + } + + const rand = { oben: 12, rechts: 12, unten: 24, links: 52 }; + const w = Math.max(breite, 240); + const h = Math.max(hoehe, 120); + const flaecheB = Math.max(w - rand.links - rand.rechts, 10); + const flaecheH = Math.max(h - rand.oben - rand.unten, 10); + + const alleWerte = serien.flatMap((s) => s.werte); + const maxWert = Math.max(...alleWerte, 1); + const minWert = Math.min(...alleWerte, 0); + const spanne = Math.max(maxWert - minWert, 1); + const skalaMax = maxWert + spanne * 0.1; + const skalaMin = minWert - spanne * 0.05; + const skalaSpanne = Math.max(skalaMax - skalaMin, 1); + + const x = (i) => rand.links + (i / (labels.length - 1)) * flaecheB; + const y = (wert) => rand.oben + flaecheH - ((wert - skalaMin) / skalaSpanne) * flaecheH; + + const gitter = [0, 0.5, 1].map((teil) => { + const wert = skalaMin + skalaSpanne * teil; + return { wert, y: y(wert) }; + }); + + const schritt = Math.max(1, Math.round(labels.length / 5)); + const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0); + + const gitterFarbe = tokenFarbe("--border", "#ddd"); + const textFarbe = tokenFarbe("--muted", "#888"); + + function bewegen(event) { + const box = event.currentTarget.getBoundingClientRect(); + const rel = event.clientX - box.left - rand.links; + const i = Math.round((rel / flaecheB) * (labels.length - 1)); + setIndex(Math.min(Math.max(i, 0), labels.length - 1)); + } + + return ( +
+ {legende && ( + + )} +
+ setIndex(null)}> + {gitter.map((g) => ( + + + {fmt(g.wert)} + + ))} + {achse.map(({ l, i }) => ( + {l} + ))} + {serien.map((s) => ( + `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} /> + ))} + {index != null && ( + <> + + {serien.map((s) => ( + + ))} + + )} + + {index != null && ( +
+
{labels[index]}
+ {serien.map((s) => ( +
+ + {serien.length > 1 && {s.name}} + {fmt(s.werte[index] ?? 0)} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/dashboard/charts/useGroesse.js b/web/src/dashboard/charts/useGroesse.js new file mode 100644 index 0000000..76e95df --- /dev/null +++ b/web/src/dashboard/charts/useGroesse.js @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +// Misst die tatsächliche Größe eines Elements, damit ein SVG in echten Pixeln +// zeichnen kann (feste viewBox mit Skalierung würde Linien/Schrift verzerren). +export default function useGroesse(ref) { + const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 }); + useEffect(() => { + if (!ref.current) return undefined; + const beobachter = new ResizeObserver(([eintrag]) => { + setGroesse({ breite: eintrag.contentRect.width, hoehe: eintrag.contentRect.height }); + }); + beobachter.observe(ref.current); + return () => beobachter.disconnect(); + }, [ref]); + return groesse; +} diff --git a/web/src/dashboard/palette.js b/web/src/dashboard/palette.js new file mode 100644 index 0000000..5424635 --- /dev/null +++ b/web/src/dashboard/palette.js @@ -0,0 +1,26 @@ +// Farben für Diagramme. Kategorie-Farben in fester, farbfehlsicht-geprüfter +// Reihenfolge; Zustandsfarben (grün/rot) kommen aus den CSS-Tokens. + +export const CATEGORY_COLORS_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"]; +export const CATEGORY_COLORS_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"]; +export const OTHER_COLOR_LIGHT = "#8a8f98"; +export const OTHER_COLOR_DARK = "#6f7783"; + +export function istDunkel() { + if (typeof document === "undefined") return false; + const gesetzt = document.documentElement.getAttribute("data-theme"); + if (gesetzt === "dark") return true; + if (gesetzt === "light") return false; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false; +} + +export function kategorieFarben(dunkel) { + return dunkel ? CATEGORY_COLORS_DARK : CATEGORY_COLORS_LIGHT; +} + +/** Liest einen CSS-Tokenwert aus (die Diagramme zeichnen in echtem SVG). */ +export function tokenFarbe(name, fallback = "#888") { + if (typeof document === "undefined") return fallback; + const wert = getComputedStyle(document.documentElement).getPropertyValue(name); + return wert?.trim() || fallback; +}