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)
|
||||
)
|
||||
""")
|
||||
|
||||
# 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
|
||||
polyline==2.0.2
|
||||
fpdf2==2.8.3
|
||||
python-dateutil>=2.9
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -258,4 +258,12 @@
|
|||
<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"/>
|
||||
</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>
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
||||
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 = `<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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue