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