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:
+16
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 <repo-url> /opt/certain-finance
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+20
-2
@@ -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]
|
||||
|
||||
Generated
+94
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}` : ""}`),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user