Feature: Daueraufträge in Ausgaben — monatlich/quartalsweise/jährlich, Scheduler, SW by-v605
This commit is contained in:
parent
a63a9ba197
commit
798289ae5a
9 changed files with 448 additions and 9 deletions
|
|
@ -1857,3 +1857,21 @@ def _migrate(conn_factory):
|
||||||
UNIQUE(from_dog_id, to_dog_id)
|
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);
|
||||||
|
""")
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ apscheduler==3.10.4
|
||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
polyline==2.0.2
|
polyline==2.0.2
|
||||||
fpdf2==2.8.3
|
fpdf2==2.8.3
|
||||||
|
python-dateutil>=2.9
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""BAN YARO — Ausgaben-Tracker Routes"""
|
"""BAN YARO — Ausgaben-Tracker Routes"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel):
|
||||||
notiz: Optional[str] = None
|
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:
|
def _serialize(row) -> dict:
|
||||||
return dict(row)
|
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.")
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||||
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
|
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
|
||||||
return None
|
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
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,14 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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
|
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_hdm_winner,
|
_job_hdm_winner,
|
||||||
|
|
@ -1266,3 +1274,17 @@ async def _job_recall_check():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
|
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
|
||||||
_log_job("recall_check", "error", str(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))
|
||||||
|
|
|
||||||
|
|
@ -7382,3 +7382,56 @@ svg.empty-state-icon {
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--c-text-secondary);
|
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); }
|
||||||
|
|
|
||||||
|
|
@ -258,4 +258,12 @@
|
||||||
<symbol id="share-network" viewBox="0 0 256 256">
|
<symbol id="share-network" viewBox="0 0 256 256">
|
||||||
<path d="M212,200a36,36,0,1,1-69.85-12.25l-53-34.05a36,36,0,1,1,0-51.4l53-34a36.09,36.09,0,1,1,8.67,13.45l-53,34.05a36,36,0,0,1,0,24.5l53,34.05A36,36,0,0,1,212,200Z"/>
|
<path d="M212,200a36,36,0,1,1-69.85-12.25l-53-34.05a36,36,0,1,1,0-51.4l53-34a36.09,36.09,0,1,1,8.67,13.45l-53,34.05a36,36,0,0,1,0,24.5l53,34.05A36,36,0,0,1,212,200Z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="pause" viewBox="0 0 256 256">
|
||||||
|
<path d="M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<symbol id="play" viewBox="0 0 256 256">
|
||||||
|
<path d="M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z"/>
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ window.Page_expenses = (() => {
|
||||||
let _statsData = null;
|
let _statsData = null;
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
||||||
{ id: 'eintraege', label: 'Einträge', icon: 'list-bullets' },
|
{ id: 'eintraege', label: 'Ausgaben', icon: 'list-bullets' },
|
||||||
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
|
{ id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
|
||||||
|
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const KATEGORIEN = [
|
const KATEGORIEN = [
|
||||||
|
|
@ -95,9 +96,10 @@ window.Page_expenses = (() => {
|
||||||
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
||||||
try {
|
try {
|
||||||
switch (_tab) {
|
switch (_tab) {
|
||||||
case 'uebersicht': await _renderUebersicht(el); break;
|
case 'uebersicht': await _renderUebersicht(el); break;
|
||||||
case 'eintraege': await _renderEintraege(el); break;
|
case 'eintraege': await _renderEintraege(el); break;
|
||||||
case 'statistik': await _renderStatistik(el); break;
|
case 'dauerauftraege': await _renderDauerauftraege(el); break;
|
||||||
|
case 'statistik': await _renderStatistik(el); break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
|
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
|
||||||
|
|
@ -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 `
|
||||||
|
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
|
||||||
|
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
|
||||||
|
<div class="exp-entry-body">
|
||||||
|
<div class="exp-entry-head">
|
||||||
|
<span class="exp-entry-kat">${k.label}</span>
|
||||||
|
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
|
||||||
|
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
|
||||||
|
<div class="exp-recurring-next">
|
||||||
|
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
|
||||||
|
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="exp-entry-right">
|
||||||
|
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
|
||||||
|
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
|
||||||
|
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
|
||||||
|
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
|
||||||
|
${UI.icon(r.aktiv ? 'pause' : 'play')}
|
||||||
|
</button>
|
||||||
|
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
|
||||||
|
title="Löschen">${UI.icon('trash')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||||
|
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
|
||||||
|
${UI.icon('plus')} Dauerauftrag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${recurring.length
|
||||||
|
? `<div class="exp-list">${cards}</div>`
|
||||||
|
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
|
||||||
|
title: 'Keine Daueraufträge',
|
||||||
|
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
|
||||||
|
<div style="height:80px"></div>`;
|
||||||
|
|
||||||
|
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 => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
|
||||||
|
|
||||||
|
const dogOptions = (_appState.dogs || []).map(d =>
|
||||||
|
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<form id="exp-recurring-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Kategorie</label>
|
||||||
|
<select class="form-control" name="kategorie">${katOptions}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Betrag (€)</label>
|
||||||
|
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
|
||||||
|
value="${r?.betrag || ''}" placeholder="0,00" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Häufigkeit</label>
|
||||||
|
<select class="form-control" name="haeufigkeit">
|
||||||
|
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
|
||||||
|
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
|
||||||
|
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Startdatum</label>
|
||||||
|
<input class="form-control" type="date" name="startdatum"
|
||||||
|
value="${r?.startdatum || today}" required>
|
||||||
|
</div>
|
||||||
|
${dogOptions ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||||
|
<select class="form-control" name="dog_id">
|
||||||
|
<option value="">Kein Hund</option>${dogOptions}
|
||||||
|
</select>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||||
|
<input class="form-control" type="text" name="notiz"
|
||||||
|
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
const footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||||
|
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
|
||||||
|
|
||||||
|
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
|
// TAB: STATISTIK
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v604';
|
const CACHE_VERSION = 'by-v605';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue