Feature: Daueraufträge in Ausgaben — monatlich/quartalsweise/jährlich, Scheduler, SW by-v605

This commit is contained in:
rene 2026-05-02 10:51:28 +02:00
parent a63a9ba197
commit 798289ae5a
9 changed files with 448 additions and 9 deletions

View file

@ -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); }

View file

@ -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

Before After
Before After

View file

@ -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';

View file

@ -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
// ----------------------------------------------------------

View file

@ -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