fix: systemd ohne ProtectHome (venv unter /root sichtbar) + Phase-3-Backend

Deployment-Fixes (Dienst startete nicht -> 203/EXEC "uvicorn not found"):
- Unit ohne ProtectHome/ProtectSystem: Repo/venv dürfen unter /root oder /home
  liegen (ProtectHome=true blendete diese Pfade für den Dienst aus).
- install.sh: Python-3.11+-Prüfung mit klarer Meldung; prüft nach der Installation,
  dass uvicorn wirklich in der venv liegt.
- EZB-Kursabruf läuft beim Start jetzt im Hintergrund (blockiert den Start nie).

Phase-3-Backend (Dashboard):
- Modell + Migration 0004: dashboard_layouts (JSON-Layout je Nutzer).
- routers/dashboard: Layout speichern/laden/zurücksetzen + Kartendaten
  (Budget-Donut, Budget-Fortschritt, Einnahmen/Ausgaben, Kontostände,
  Vermögensverlauf, letzte Buchungen).
- Frontend-Infrastruktur: Chart-Komponenten (Donut/Line), palette, API-Methoden,
  react-grid-layout. (Dashboard-Seite folgt im nächsten Commit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 19:31:55 +02:00
co-authored by Claude Opus 4.8
parent f53c79a20c
commit 2dd8f9cd35
14 changed files with 685 additions and 8 deletions
+16 -2
View File
@@ -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. Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
""" """
import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -22,6 +23,7 @@ from .routers import (
auth, auth,
budgets, budgets,
categories, categories,
dashboard,
rates, rates,
settings as settings_router, settings as settings_router,
transactions, transactions,
@@ -44,10 +46,21 @@ async def lifespan(app: FastAPI):
db = SessionLocal() db = SessionLocal()
try: try:
ensure_first_admin(db) 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: finally:
db.close() 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 yield
@@ -89,6 +102,7 @@ api.include_router(accounts.router)
api.include_router(categories.router) api.include_router(categories.router)
api.include_router(transactions.router) api.include_router(transactions.router)
api.include_router(budgets.router) api.include_router(budgets.router)
api.include_router(dashboard.router)
api.include_router(rates.router) api.include_router(rates.router)
app.include_router(api) app.include_router(api)
+17
View File
@@ -377,3 +377,20 @@ class BudgetPeriodLine(Base):
__table_args__ = ( __table_args__ = (
UniqueConstraint("budget_period_id", "category_id", name="uq_periodline_period_category"), 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)
+273
View File
@@ -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}
@@ -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")
+5 -1
View File
@@ -1,8 +1,12 @@
# Deployment (Proxmox-LXC + Nginx Proxy Manager) # 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 ## 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 ```bash
git clone <repo-url> /opt/certain-finance git clone <repo-url> /opt/certain-finance
+3 -3
View File
@@ -21,10 +21,10 @@ ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host ${A
Restart=on-failure Restart=on-failure
RestartSec=3 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 NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true PrivateTmp=true
ReadWritePaths=/var/lib/certain-finance ReadWritePaths=/var/lib/certain-finance
+20 -2
View File
@@ -46,6 +46,18 @@ export DEBIAN_FRONTEND=noninteractive
apt-get update -y apt-get update -y
apt-get install -y python3 python3-venv python3-dev build-essential curl ca-certificates git 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) ----------------------------- # --- Node.js (nur zum Bauen des Frontends nötig) -----------------------------
ensure_node() { ensure_node() {
local major=0 local major=0
@@ -76,6 +88,11 @@ info "Richte Python-venv ein und installiere Abhängigkeiten …"
python3 -m venv "$REPO_DIR/backend/.venv" 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 --upgrade pip >/dev/null
"$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt" "$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 ---------------------------------------------------------- # --- Frontend bauen ----------------------------------------------------------
info "Baue das Frontend (Node wird nur hier gebraucht) …" 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 Restart=on-failure
RestartSec=3 RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=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 ReadWritePaths=$DATA_DIR
[Install] [Install]
+94
View File
@@ -11,6 +11,7 @@
"i18next": "^24.2.0", "i18next": "^24.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
@@ -1368,6 +1369,15 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1452,6 +1462,12 @@
"node": ">=6" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1625,6 +1641,15 @@
"node": ">=18" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1674,6 +1699,17 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1699,6 +1735,38 @@
"react": "^18.3.1" "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": { "node_modules/react-i18next": {
"version": "15.7.4", "version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1735,6 +1809,20 @@
"node": ">=0.10.0" "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": { "node_modules/react-router": {
"version": "6.30.6", "version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
@@ -1767,6 +1855,12 @@
"react-dom": ">=16.8" "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": { "node_modules/rollup": {
"version": "4.62.4", "version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+1
View File
@@ -12,6 +12,7 @@
"i18next": "^24.2.0", "i18next": "^24.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0"
}, },
+11
View File
@@ -175,4 +175,15 @@ export const api = {
createIncome: (body) => request("/budget/income", { method: "POST", body }), createIncome: (body) => request("/budget/income", { method: "POST", body }),
updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }), updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }),
deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }), 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}` : ""}`),
}; };
+64
View File
@@ -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 <div className="empty">Keine Daten vorhanden.</div>;
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 (
<div className="chart-donut">
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
aria-label={`Ringdiagramm, ${werte.length} Anteile`}>
<g transform={`rotate(-90 ${mitte} ${mitte})`}>
{boegen.map((b) => (
<circle key={b.name}
cx={mitte} cy={mitte} r={radius} fill="none" stroke={b.color}
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
strokeDashoffset={-b.versatz}
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
))}
</g>
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
{fmt(gezeigt ? gezeigt.value : summe)}
</text>
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
{gezeigt ? gezeigt.name : gesamtLabel}
</text>
</svg>
<div className="chart-legend-scroll">
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className="legend-value">{fmt(b.value)} · {Math.round(b.anteil * 100)} %</span>
</li>
))}
</ul>
</div>
</div>
);
}
+109
View File
@@ -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 <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
}
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 (
<div className="chart-line" ref={wirt}>
{legende && (
<ul className="chart-legend inline">
{serien.map((s) => (
<li key={s.name}>
<span className="swatch" style={{ background: s.color }} />
<span className="legend-name">{s.name}</span>
</li>
))}
</ul>
)}
<div className="line-plot" ref={plot}>
<svg width={w} height={h} role="img" aria-label={`Liniendiagramm über ${labels.length} Tage`}
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
{gitter.map((g) => (
<g key={g.wert}>
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y} stroke={gitterFarbe} strokeWidth="1" />
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
</g>
))}
{achse.map(({ l, i }) => (
<text key={l + i} x={x(i)} y={h - 6} textAnchor="middle" fontSize="11" fill={textFarbe}>{l}</text>
))}
{serien.map((s) => (
<path key={s.name} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"
d={s.werte.map((wert, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} />
))}
{index != null && (
<>
<line x1={x(index)} x2={x(index)} y1={rand.oben} y2={rand.oben + flaecheH} stroke={gitterFarbe} strokeWidth="1" />
{serien.map((s) => (
<circle key={s.name} cx={x(index)} cy={y(s.werte[index] ?? 0)} r="4.5"
fill={s.color} stroke={tokenFarbe("--surface", "#fff")} strokeWidth="2" />
))}
</>
)}
</svg>
{index != null && (
<div className="line-tooltip" style={{ left: Math.min(Math.max(x(index), 60), w - 60) }}>
<div className="tip-date">{labels[index]}</div>
{serien.map((s) => (
<div key={s.name} className="tip-row">
<span className="swatch" style={{ background: s.color }} />
{serien.length > 1 && <span className="tip-name">{s.name}</span>}
<strong>{fmt(s.werte[index] ?? 0)}</strong>
</div>
))}
</div>
)}
</div>
</div>
);
}
+16
View File
@@ -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;
}
+26
View File
@@ -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;
}