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' ? `
+
` : ''}
+
+
+ `;
+
+ 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 = `
+
+ `;
+
+ // 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: `
+
+ `,
+ 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: `
+
+ `,
+ 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
+
+
+
+ | Beschreibung |
+ Menge |
+ Preis |
+ Gesamt |
+
+
+ ${itemsHtml}
+
+
+ | 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
+
+
+
+
+
+
+ ${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)}
+
+ `;
+ } catch (e) {
+ resultEl.innerHTML = `Fehler: ${_esc(e.message)}
`;
+ }
+ });
+ }
+
return { init, refresh, onDogChange };
})();