diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 8d8e780..c519e48 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -27,6 +27,7 @@ window.Page_admin = (() => { { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, + { id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' }, ]; // ------------------------------------------------------------------ @@ -97,6 +98,7 @@ window.Page_admin = (() => { { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, + { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' }, ]; const open = items.filter(i => d[i.key] > 0); @@ -166,6 +168,7 @@ window.Page_admin = (() => { case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; case 'upgrades': await _renderUpgrades(el); break; + case 'rechnungen': await _renderRechnungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -3609,6 +3612,717 @@ window.Page_admin = (() => { }); } + // ------------------------------------------------------------------ + // TAB: RECHNUNGEN + // ------------------------------------------------------------------ + async function _renderRechnungen(el) { + let _subView = 'liste'; // 'liste' | 'cashflow' + + async function _load() { + el.innerHTML = ` +
+
+ + +
+ ${_subView === 'liste' ? ` + ` : ''} +
+
+
Lade…
+
+ `; + + el.querySelectorAll('.adm-inv-nav').forEach(btn => { + btn.addEventListener('click', () => { + _subView = btn.dataset.v; + _load(); + }); + }); + + el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load)); + + const content = el.querySelector('#adm-inv-content'); + if (_subView === 'liste') { + await _loadInvoiceList(content, _load); + } else { + await _loadCashflow(content); + } + } + + await _load(); + } + + async function _loadInvoiceList(el, reload) { + let invoices; + try { + invoices = await API.get('/admin/invoices'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.'); + return; + } + + if (!invoices.length) { + el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.'); + return; + } + + const _statusBadge = status => { + const cfg = { + draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'], + sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'], + paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'], + cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'], + }; + const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)']; + return `${label}`; + }; + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const rows = invoices.map((inv, i) => { + const actions = []; + if (inv.status === 'draft') { + actions.push(``); + } + if (inv.status === 'sent') { + actions.push(``); + actions.push(``); + } + if (inv.status === 'paid' || inv.status === 'cancelled') { + actions.push(``); + } + + return ` + + + ${_esc(inv.invoice_number)} + + +
${_esc(inv.recipient_name)}
+
${_esc(inv.recipient_email || '')}
+ + + ${_fmtEur(inv.amount_gross)} + + ${_statusBadge(inv.status)} + + ${_fmtDate(inv.created_at)} + + +
${actions.join('')}
+ + `; + }).join(''); + + el.innerHTML = ` +
+
+ + + + + + + + + + + + ${rows} +
NummerEmpfängerBetragStatusErstellt
+
+
+ `; + + // Senden + el.querySelectorAll('.adm-inv-send').forEach(btn => { + btn.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: `Rechnung ${btn.dataset.num} versenden?`, + message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.', + confirmText: 'Jetzt versenden', + }); + if (!ok) return; + btn.disabled = true; + try { + await API.post(`/admin/invoices/${btn.dataset.id}/send`, {}); + UI.toast.success('Rechnung versendet.'); + reload(); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Versenden.'); + btn.disabled = false; + } + }); + }); + + // Als bezahlt markieren + el.querySelectorAll('.adm-inv-pay').forEach(btn => { + btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload)); + }); + + // Stornieren + el.querySelectorAll('.adm-inv-cancel').forEach(btn => { + btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload)); + }); + + // Details + el.querySelectorAll('.adm-inv-detail').forEach(btn => { + btn.addEventListener('click', () => _openDetailModal(btn.dataset.id)); + }); + } + + function _openNeueRechnungModal(reload) { + const id = `inv-new-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('receipt')} Neue Rechnung erstellen`, + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ Netto: — +
+
+ +
+ + +
+ +
+ `, + footer: ` + + + `, + }); + + // Items-Container und Hilfsfunktionen + const itemsContainer = document.getElementById(`${id}-items`); + const previewEl = document.getElementById(`${id}-preview`); + const discountEl = document.getElementById(`${id}-discount`); + + function _addItem(desc = '', qty = 1, price = 0) { + const itemEl = document.createElement('div'); + itemEl.className = 'adm-inv-item-row'; + itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center'; + itemEl.innerHTML = ` + + + + + `; + itemEl.querySelector('.inv-item-remove').addEventListener('click', () => { + if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) { + itemEl.remove(); + _updatePreview(); + } + }); + itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview)); + itemsContainer.appendChild(itemEl); + _updatePreview(); + } + + function _updatePreview() { + let netto = 0; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + netto += qty * price; + }); + const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0)); + const rabatt = netto * disc / 100; + const brutto = netto - rabatt; + previewEl.innerHTML = ` + Netto: + ${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + ${disc > 0 ? ` · -${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)` : ''} +  · Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + `; + } + + // Erste Position hinzufügen + _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); + + // Weitere Position + document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem()); + discountEl?.addEventListener('input', _updatePreview); + + // Form Submit + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const items = []; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const desc = row.querySelector('.inv-item-desc').value.trim(); + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + if (desc) items.push({ description: desc, quantity: qty, unit_price: price }); + }); + if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; } + + const submitBtn = e.target.closest('.modal-content, [id]') + ? document.querySelector(`button[form="${id}"]`) + : null; + if (submitBtn) submitBtn.disabled = true; + + try { + await API.post('/admin/invoices', { + recipient_name: fd.get('recipient_name'), + recipient_email: fd.get('recipient_email') || null, + recipient_address: fd.get('recipient_address') || null, + service_period: fd.get('service_period') || null, + discount_pct: parseFloat(fd.get('discount_pct')) || 0, + notes: fd.get('notes') || null, + items, + }); + UI.modal.close(); + UI.toast.success('Rechnung erstellt.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Erstellen.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openBezahltModal(invoiceId, defaultAmount, reload) { + const today = new Date().toISOString().slice(0, 10); + const id = `inv-pay-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('check-circle')} Als bezahlt markieren`, + body: ` +
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + await API.post(`/admin/invoices/${invoiceId}/pay`, { + paid_at: fd.get('paid_at'), + paid_amount: parseFloat(fd.get('paid_amount')), + }); + UI.modal.close(); + UI.toast.success('Rechnung als bezahlt markiert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openStornoModal(invoiceId, invoiceNum, reload) { + const id = `inv-cancel-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('x-circle')} Rechnung stornieren`, + body: ` +
+

+ Rechnung ${_esc(invoiceNum)} stornieren. +

+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const reason = (fd.get('reason') || '').trim(); + if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; } + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason }); + UI.modal.close(); + UI.toast.success('Rechnung storniert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + async function _openDetailModal(invoiceId) { + let inv; + try { + inv = await API.get(`/admin/invoices/${invoiceId}`); + } catch (e) { + UI.toast.error(e.message || 'Detail konnte nicht geladen werden.'); + return; + } + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusColors = { + draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', + paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)', + }; + const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + + const itemsHtml = (inv.items || []).map(item => ` + + ${_esc(item.description)} + ${item.quantity} + ${_fmtEur(item.unit_price)} + ${_fmtEur(item.total)} + + `).join(''); + + UI.modal.open({ + title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`, + body: ` +
+
+
+
Empfänger
+
${_esc(inv.recipient_name)}
+ ${inv.recipient_email ? `
${_esc(inv.recipient_email)}
` : ''} + ${inv.recipient_address ? `
${_esc(inv.recipient_address)}
` : ''} +
+
+
Status
+
${statusLabels[inv.status] || inv.status}
+
+ Erstellt: ${_fmtDate(inv.created_at)}
+ ${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}
` : ''} + ${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}
` : ''} +
+
+
+ + ${inv.service_period ? ` +
+
Leistungszeitraum
+
${_esc(inv.service_period)}
+
` : ''} + + +
+
Positionen
+ + + + + + + + + + ${itemsHtml} + + + + + + +
BeschreibungMengePreisGesamt
Gesamt (brutto)${_fmtEur(inv.amount_gross)}
+
+ + ${inv.notes ? ` +
+
Notizen
+
${_esc(inv.notes)}
+
` : ''} +
+ `, + footer: ``, + }); + } + + async function _loadCashflow(el) { + let cf; + try { + cf = await API.get('/admin/invoices/cashflow'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.'); + return; + } + + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' }; + + const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => ` +
+
${n}
+
${statusLabels[s] || s}
+
`).join(''); + + const monthRows = (cf.monthly || []).map((m, i) => ` + + ${_esc(m.month)} + ${m.count} + ${_fmtEur(m.revenue)} + `).join(''); + + // Quartalsbericht-Download + const currentYear = new Date().getFullYear(); + const years = [currentYear, currentYear - 1].map(y => ``).join(''); + + el.innerHTML = ` + +
+
+
${_fmtEur(cf.total_paid)}
+
Einnahmen (bezahlt)
+
+
+
${_fmtEur(cf.total_outstanding)}
+
Offene Forderungen
+
+
+
${_fmtEur(cf.total_year)}
+
Jahresumsatz gesamt
+
+ ${countKacheln} +
+ + +
+
Monatliche Übersicht
+
+ + + + + + + + + + ${monthRows || ``} + +
MonatRechnungenUmsatz
Keine Daten
+
+
+ + +
+
+ ${UI.icon('file-csv')} Quartalsbericht herunterladen +
+
+
+ + +
+
+ + +
+ + +
+
+
+ `; + + // CSV Download + el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; } + + // CSV generieren + const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00'; + const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + + const header = 'Nummer;Empfänger;E-Mail;Betrag (netto);Betrag (brutto);Status;Erstellt;Versendet;Bezahlt\n'; + const csvRows = data.invoices.map(inv => + [inv.invoice_number, inv.recipient_name, inv.recipient_email || '', + fmtEur(inv.amount_gross), fmtEur(inv.amount_gross), inv.status, + fmtDate(inv.created_at), fmtDate(inv.sent_at), fmtDate(inv.paid_at) + ].map(escape).join(';') + ).join('\n'); + + const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `banyaro-rechnungen-${year}-Q${q}.csv`; + a.click(); + URL.revokeObjectURL(url); + UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Laden.'); + } + }); + + // Quartals-Vorschau + el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + const resultEl = el.querySelector('#adm-inv-q-result'); + resultEl.innerHTML = '
Lade…
'; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { + resultEl.innerHTML = `
Keine Rechnungen in ${data.period || `Q${q} ${year}`}.
`; + return; + } + const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—'; + const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—'; + const sL = { draft:'Entwurf',sent:'Versendet',paid:'Bezahlt',cancelled:'Storniert' }; + const rows2 = data.invoices.map((inv, i) => ` + + ${_esc(inv.invoice_number)} + ${_esc(inv.recipient_name)} + ${_fmtE(inv.amount_gross)} + ${sL[inv.status]||inv.status} + ${_fmtD(inv.created_at)} + `).join(''); + resultEl.innerHTML = ` +
+ ${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)} +
+
+ + + + + + + ${rows2} + + + + + +
NummerEmpfängerBetragStatusErstellt
Gesamt${_fmtE(data.total_gross)} + Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)} +
+
`; + } catch (e) { + resultEl.innerHTML = `
Fehler: ${_esc(e.message)}
`; + } + }); + } + return { init, refresh, onDogChange }; })();