diff --git a/backend/database.py b/backend/database.py index 5d992eb..1a70aa5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1857,3 +1857,21 @@ def _migrate(conn_factory): UNIQUE(from_dog_id, to_dog_id) ) """) + + # Wiederkehrende Ausgaben (Daueraufträge) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS recurring_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich + startdatum TEXT NOT NULL, + naechste_faelligkeit TEXT NOT NULL, + notiz TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); + """) diff --git a/backend/requirements.txt b/backend/requirements.txt index 17db134..c4e830c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 +python-dateutil>=2.9 diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py index a3bcf7a..9c93475 100644 --- a/backend/routes/expenses.py +++ b/backend/routes/expenses.py @@ -1,7 +1,8 @@ """BAN YARO — Ausgaben-Tracker Routes""" import logging -from datetime import date +from datetime import date, timedelta +from dateutil.relativedelta import relativedelta from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional @@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel): notiz: Optional[str] = None +class RecurringCreate(BaseModel): + dog_id: Optional[int] = None + kategorie: str + betrag: float + haeufigkeit: str # monatlich | quartalsweise | jaehrlich + startdatum: str # ISO date + notiz: Optional[str] = None + +class RecurringUpdate(BaseModel): + dog_id: Optional[int] = None + kategorie: Optional[str] = None + betrag: Optional[float] = None + haeufigkeit: Optional[str] = None + startdatum: Optional[str] = None + notiz: Optional[str] = None + aktiv: Optional[bool] = None + + +HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"} + + +def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date: + """Berechnet das nächste Fälligkeitsdatum nach `after`.""" + d = date.fromisoformat(startdatum) + if d > after: + return d + if haeufigkeit == "monatlich": + delta = relativedelta(months=1) + elif haeufigkeit == "quartalsweise": + delta = relativedelta(months=3) + else: + delta = relativedelta(years=1) + while d <= after: + d += delta + return d + + def _serialize(row) -> dict: return dict(row) @@ -226,3 +264,133 @@ async def delete_expense(expense_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Eintrag nicht gefunden.") conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,)) return None + + +# ------------------------------------------------------------------ +# Wiederkehrende Ausgaben +# ------------------------------------------------------------------ +@router.get("/recurring") +async def list_recurring(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + """SELECT r.*, d.name AS dog_name + FROM recurring_expenses r + LEFT JOIN dogs d ON d.id = r.dog_id + WHERE r.user_id=? ORDER BY r.startdatum DESC""", + (user["id"],), + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/recurring", status_code=201) +async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)): + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}") + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + + today = date.today() + naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1)) + + with db() as conn: + if data.dog_id: + if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"])).fetchone(): + raise HTTPException(404, "Hund nicht gefunden.") + conn.execute( + """INSERT INTO recurring_expenses + (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz) + VALUES (?,?,?,?,?,?,?,?)""", + (user["id"], data.dog_id, data.kategorie, data.betrag, + data.haeufigkeit, data.startdatum, str(naechste), data.notiz), + ) + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],), + ).fetchone() + return dict(row) + + +@router.patch("/recurring/{rid}") +async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + updates: dict = {} + if data.kategorie is not None: + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie.") + updates["kategorie"] = data.kategorie + if data.betrag is not None: + updates["betrag"] = data.betrag + if data.haeufigkeit is not None: + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, "Ungültige Häufigkeit.") + updates["haeufigkeit"] = data.haeufigkeit + if data.startdatum is not None: + updates["startdatum"] = data.startdatum + if data.notiz is not None: + updates["notiz"] = data.notiz + if data.aktiv is not None: + updates["aktiv"] = 1 if data.aktiv else 0 + if updates: + # naechste_faelligkeit neu berechnen wenn relevante Felder geändert + startdatum = updates.get("startdatum", row["startdatum"]) + haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"]) + today = date.today() + updates["naechste_faelligkeit"] = str( + _next_due(startdatum, haeufigkeit, today - timedelta(days=1)) + ) + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?", + [*updates.values(), rid]) + row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone() + return dict(row) + + +@router.delete("/recurring/{rid}", status_code=204) +async def delete_recurring(rid: int, user=Depends(get_current_user)): + with db() as conn: + if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?", + (rid, user["id"])).fetchone(): + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,)) + return None + + +def process_due_recurring(user_id: int | None = None): + """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen.""" + today = date.today() + today_str = str(today) + with db() as conn: + where = "aktiv=1 AND naechste_faelligkeit <= ?" + params: list = [today_str] + if user_id: + where += " AND user_id=?" + params.append(user_id) + rows = conn.execute( + f"SELECT * FROM recurring_expenses WHERE {where}", params + ).fetchall() + + for r in rows: + # Eintrag anlegen + conn.execute( + """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) + VALUES (?,?,?,?,?,?)""", + (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"], + r["naechste_faelligkeit"], + f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"), + ) + # Nächste Fälligkeit berechnen + naechste = _next_due(r["startdatum"], r["haeufigkeit"], + date.fromisoformat(r["naechste_faelligkeit"])) + conn.execute( + "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?", + (str(naechste), r["id"]), + ) + return len(rows) if rows else 0 diff --git a/backend/scheduler.py b/backend/scheduler.py index 4dcab4c..4aeb89a 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -124,6 +124,14 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 06:30 — Wiederkehrende Ausgaben anlegen + _scheduler.add_job( + _job_recurring_expenses, + CronTrigger(hour=6, minute=30), + id="recurring_expenses", + replace_existing=True, + misfire_grace_time=3600, + ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( _job_hdm_winner, @@ -1266,3 +1274,17 @@ async def _job_recall_check(): except Exception as e: logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") _log_job("recall_check", "error", str(e)) + + +# ------------------------------------------------------------------ +# JOB: Wiederkehrende Ausgaben anlegen +# ------------------------------------------------------------------ +async def _job_recurring_expenses(): + try: + from routes.expenses import process_due_recurring + count = process_due_recurring() + logger.info(f"Daueraufträge: {count} Einträge angelegt.") + _log_job("recurring_expenses", "ok", f"{count} Einträge") + except Exception as e: + logger.error(f"Daueraufträge-Job Fehler: {e}") + _log_job("recurring_expenses", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1930060..60cdfb4 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7382,3 +7382,56 @@ svg.empty-state-icon { font-weight: var(--weight-semibold); color: var(--c-text-secondary); } + +/* Daueraufträge */ +.exp-recurring-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-lg); + margin-bottom: var(--space-2); + transition: opacity .2s; +} +.exp-recurring-card--inaktiv { opacity: .55; } +.exp-recurring-freq { + font-size: var(--text-xs); + color: var(--c-primary); + font-weight: var(--weight-semibold); + background: var(--c-primary-subtle); + padding: 1px var(--space-2); + border-radius: var(--radius-full); +} +.exp-recurring-next { + font-size: var(--text-xs); + color: var(--c-text-muted); + margin-top: var(--space-1); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.exp-badge-inaktiv { + background: var(--c-surface-2); + color: var(--c-text-muted); + padding: 1px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); +} +.exp-icon-btn { + width: 28px; + height: 28px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + background: var(--c-surface); + color: var(--c-text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color .15s, border-color .15s; +} +.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); } +.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); } diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index bdbfd78..ac08636 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -258,4 +258,12 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5028691..7e2c144 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '604'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '605'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index e7c2b61..8d00c2e 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -15,9 +15,10 @@ window.Page_expenses = (() => { let _statsData = null; const TABS = [ - { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, - { id: 'eintraege', label: 'Einträge', icon: 'list-bullets' }, - { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, + { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, + { id: 'eintraege', label: 'Ausgaben', icon: 'list-bullets' }, + { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' }, + { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, ]; const KATEGORIEN = [ @@ -95,9 +96,10 @@ window.Page_expenses = (() => { el.innerHTML = `
${UI.skeleton(4)}
`; try { switch (_tab) { - case 'uebersicht': await _renderUebersicht(el); break; - case 'eintraege': await _renderEintraege(el); break; - case 'statistik': await _renderStatistik(el); break; + case 'uebersicht': await _renderUebersicht(el); break; + case 'eintraege': await _renderEintraege(el); break; + case 'dauerauftraege': await _renderDauerauftraege(el); break; + case 'statistik': await _renderStatistik(el); break; } } catch (e) { el.innerHTML = `
Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}
`; @@ -307,6 +309,173 @@ window.Page_expenses = (() => { }); } + // ---------------------------------------------------------- + // TAB: DAUERAUFTRÄGE + // ---------------------------------------------------------- + const HAEUFIGKEIT_LABEL = { + monatlich: 'Monatlich', + quartalsweise: 'Quartalsweise', + jaehrlich: 'Jährlich', + }; + + async function _renderDauerauftraege(el) { + let recurring = []; + try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ } + + const cards = recurring.map(r => { + const k = _kat(r.kategorie); + const naechste = r.naechste_faelligkeit + ? new Date(r.naechste_faelligkeit + 'T00:00:00') + .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : '—'; + return ` +
+
${UI.icon(k.icon)}
+
+
+ ${k.label} + ${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit} + ${r.dog_name ? `${UI.icon('paw-print')} ${_esc(r.dog_name)}` : ''} +
+ ${r.notiz ? `
${_esc(r.notiz)}
` : ''} +
+ ${UI.icon('calendar')} Nächste Buchung: ${naechste} + ${!r.aktiv ? 'Pausiert' : ''} +
+
+
+
${_fmt(r.betrag)}
+
+ + +
+
+
`; + }).join(''); + + el.innerHTML = ` +
+ +
+ ${recurring.length + ? `
${cards}
` + : UI.emptyState({ icon: UI.icon('arrows-clockwise'), + title: 'Keine Daueraufträge', + text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })} +
`; + + el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => { + _tab = 'dauerauftraege'; _renderTab(); + })); + + el.querySelectorAll('.exp-recurring-toggle').forEach(btn => { + btn.addEventListener('click', async () => { + const rid = parseInt(btn.dataset.rid); + const aktiv = btn.dataset.aktiv === '1'; + await UI.asyncButton(btn, async () => { + await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv }); + _renderTab(); + }); + }); + }); + + el.querySelectorAll('.exp-recurring-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Dauerauftrag löschen?')) return; + await UI.asyncButton(btn, async () => { + await API.del(`/expenses/recurring/${btn.dataset.rid}`); + _renderTab(); + }); + }); + }); + } + + function _showRecurringForm(r, onSave) { + const today = new Date().toISOString().slice(0, 10); + const katOptions = [ + { id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' }, + { id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' }, + { id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' }, + ].map(k => ``).join(''); + + const dogOptions = (_appState.dogs || []).map(d => + `` + ).join(''); + + const body = ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${dogOptions ? ` +
+ + +
` : ''} +
+ + +
+
`; + + const footer = ` + + `; + + UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer }); + + document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]'); + const fd = UI.formData(e.target); + const payload = { + kategorie: fd.kategorie, + betrag: parseFloat(fd.betrag), + haeufigkeit: fd.haeufigkeit, + startdatum: fd.startdatum, + notiz: fd.notiz || null, + dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, + }; + await UI.asyncButton(btn, async () => { + if (r) { + await API.patch(`/expenses/recurring/${r.id}`, payload); + } else { + await API.post('/expenses/recurring', payload); + } + UI.modal.close(); + onSave?.(); + }); + }); + } + // ---------------------------------------------------------- // TAB: STATISTIK // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index f15c053..189813f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v604'; +const CACHE_VERSION = 'by-v605'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache