Feat: Rechnungs-Management Tab in Admin-Oberfläche
Neuer 'Rechnungen'-Tab mit vollständiger Invoice-Verwaltung: - Invoice-Liste mit Status-Badges (draft/sent/paid/cancelled) und kontextuellen Aktionen - Modal: Neue Rechnung erstellen (dynamische Positionen, Live-Vorschau Netto/Brutto) - Modal: Als bezahlt markieren (Datum + Betrag) - Modal: Stornieren mit Pflichtgrund - Modal: Detail-Ansicht mit Positionen-Tabelle - Cashflow-View: Übersichtskacheln + Monatstabelle + Quartalsbericht-CSV-Download - Action-Items Badge für offene Rechnungen (invoices_unpaid aus action-items API)
This commit is contained in:
parent
c032b9a3fb
commit
9c359bb07e
1 changed files with 714 additions and 0 deletions
|
|
@ -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 = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
|
||||
${UI.icon('list-bullets')} Rechnungen
|
||||
</button>
|
||||
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
|
||||
${UI.icon('chart-bar')} Cashflow
|
||||
</button>
|
||||
</div>
|
||||
${_subView === 'liste' ? `
|
||||
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
|
||||
${UI.icon('plus')} Neue Rechnung
|
||||
</button>` : ''}
|
||||
</div>
|
||||
<div id="adm-inv-content">
|
||||
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
|
||||
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
|
||||
};
|
||||
|
||||
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(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Senden">
|
||||
${UI.icon('paper-plane-tilt')} Senden
|
||||
</button>`);
|
||||
}
|
||||
if (inv.status === 'sent') {
|
||||
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
|
||||
${UI.icon('check-circle')} Bezahlt
|
||||
</button>`);
|
||||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}"
|
||||
style="color:var(--c-danger)" title="Stornieren">
|
||||
${UI.icon('x-circle')} Storno
|
||||
</button>`);
|
||||
}
|
||||
if (inv.status === 'paid' || inv.status === 'cancelled') {
|
||||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
|
||||
${UI.icon('eye')} Details
|
||||
</button>`);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||||
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
|
||||
${_esc(inv.invoice_number)}
|
||||
</td>
|
||||
<td class="adm-td">
|
||||
<div style="font-weight:500">${_esc(inv.recipient_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(inv.recipient_email || '')}</div>
|
||||
</td>
|
||||
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
|
||||
${_fmtEur(inv.amount_gross)}
|
||||
</td>
|
||||
<td class="adm-td">${_statusBadge(inv.status)}</td>
|
||||
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
|
||||
${_fmtDate(inv.created_at)}
|
||||
</td>
|
||||
<td class="adm-td" style="white-space:nowrap">
|
||||
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card adm-table-card">
|
||||
<div class="adm-table-scroll">
|
||||
<table class="adm-table">
|
||||
<thead>
|
||||
<tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Nummer</th>
|
||||
<th class="adm-th">Empfänger</th>
|
||||
<th class="adm-th" style="text-align:right">Betrag</th>
|
||||
<th class="adm-th">Status</th>
|
||||
<th class="adm-th">Erstellt</th>
|
||||
<th class="adm-th"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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: `
|
||||
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
|
||||
<!-- Empfänger -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
|
||||
<input class="form-control" name="recipient_name" type="text" required placeholder="Max Muster">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
|
||||
<input class="form-control" name="recipient_email" type="email" placeholder="max@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Adresse <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<textarea class="form-control" name="recipient_address" rows="2"
|
||||
placeholder="Musterstr. 1 12345 Berlin"
|
||||
style="resize:vertical;font-family:inherit"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<input class="form-control" name="service_period" type="text"
|
||||
placeholder="01.01.2026 – 31.12.2026">
|
||||
</div>
|
||||
|
||||
<!-- Positionen -->
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
|
||||
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
|
||||
<button type="button" id="${id}-add-item"
|
||||
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
|
||||
+ Position hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="${id}-items" style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<!-- Items werden dynamisch eingefügt -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rabatt -->
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
|
||||
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="0"
|
||||
style="width:80px" id="${id}-discount">
|
||||
</div>
|
||||
<!-- Live-Vorschau -->
|
||||
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
|
||||
<span style="color:var(--c-text-muted)">Netto: —</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||
<textarea class="form-control" name="notes" rows="2"
|
||||
style="resize:vertical;font-family:inherit"
|
||||
placeholder="Interne Notiz / Zahlungshinweis"></textarea>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} Rechnung erstellen</button>
|
||||
`,
|
||||
});
|
||||
|
||||
// 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 = `
|
||||
<input class="form-control inv-item-desc" type="text" placeholder="Beschreibung *"
|
||||
value="${_esc(desc)}" style="font-size:var(--text-sm)">
|
||||
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
|
||||
style="font-size:var(--text-sm);text-align:right" title="Menge">
|
||||
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
|
||||
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
|
||||
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
|
||||
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
|
||||
${UI.icon('x')}
|
||||
</button>
|
||||
`;
|
||||
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 = `
|
||||
<span style="color:var(--c-text-muted)">Netto: </span>
|
||||
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</strong>
|
||||
${disc > 0 ? ` · <span style="color:var(--c-danger)">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
|
||||
· <span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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: `
|
||||
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Zahlungsdatum *</label>
|
||||
<input class="form-control" name="paid_at" type="date" value="${today}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Betrag (€) *</label>
|
||||
<input class="form-control" name="paid_amount" type="number" min="0" step="0.01"
|
||||
value="${defaultAmount.toFixed(2)}" required>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `
|
||||
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||||
Rechnung <strong>${_esc(invoiceNum)}</strong> stornieren.
|
||||
</p>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Stornierungsgrund *</label>
|
||||
<input class="form-control" name="reason" type="text" required
|
||||
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="${id}" type="submit"
|
||||
style="background:var(--c-danger);border-color:var(--c-danger)">
|
||||
${UI.icon('x-circle')} Rechnung stornieren
|
||||
</button>
|
||||
`,
|
||||
});
|
||||
|
||||
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 => `
|
||||
<tr>
|
||||
<td style="padding:6px 8px">${_esc(item.description)}</td>
|
||||
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
|
||||
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
|
||||
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`,
|
||||
body: `
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
|
||||
<div style="font-weight:600">${_esc(inv.recipient_name)}</div>
|
||||
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(inv.recipient_email)}</div>` : ''}
|
||||
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${_esc(inv.recipient_address)}</div>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
|
||||
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||||
Erstellt: ${_fmtDate(inv.created_at)}<br>
|
||||
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
|
||||
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${inv.service_period ? `
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
|
||||
<div>${_esc(inv.service_period)}</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Positionen -->
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
|
||||
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
|
||||
<th style="text-align:right;padding:4px 8px">Menge</th>
|
||||
<th style="text-align:right;padding:4px 8px">Preis</th>
|
||||
<th style="text-align:right;padding:4px 8px">Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${itemsHtml}</tbody>
|
||||
<tfoot>
|
||||
<tr style="border-top:2px solid var(--c-border)">
|
||||
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
|
||||
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${inv.notes ? `
|
||||
<div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
||||
font-size:var(--text-xs);white-space:pre-wrap">${_esc(inv.notes)}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
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]) => `
|
||||
<div class="card" style="padding:var(--space-3);text-align:center">
|
||||
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const monthRows = (cf.monthly || []).map((m, i) => `
|
||||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||||
<td class="adm-td">${_esc(m.month)}</td>
|
||||
<td class="adm-td" style="text-align:right">${m.count}</td>
|
||||
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
// Quartalsbericht-Download
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<!-- Übersichtskacheln -->
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
|
||||
</div>
|
||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
|
||||
</div>
|
||||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
|
||||
</div>
|
||||
${countKacheln}
|
||||
</div>
|
||||
|
||||
<!-- Monatliche Tabelle -->
|
||||
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||||
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
|
||||
<div class="adm-table-scroll">
|
||||
<table class="adm-table">
|
||||
<thead>
|
||||
<tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Monat</th>
|
||||
<th class="adm-th" style="text-align:right">Rechnungen</th>
|
||||
<th class="adm-th" style="text-align:right">Umsatz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quartalsbericht -->
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||||
${UI.icon('file-csv')} Quartalsbericht herunterladen
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Jahr</label>
|
||||
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Quartal</label>
|
||||
<select id="adm-inv-quarter" class="form-control" style="width:auto">
|
||||
<option value="1">Q1 (Jan–Mär)</option>
|
||||
<option value="2">Q2 (Apr–Jun)</option>
|
||||
<option value="3">Q3 (Jul–Sep)</option>
|
||||
<option value="4">Q4 (Okt–Dez)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
|
||||
${UI.icon('download-simple')} CSV herunterladen
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
|
||||
${UI.icon('eye')} Vorschau
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-inv-q-result" style="margin-top:var(--space-3)"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
|
||||
try {
|
||||
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
|
||||
if (!data.invoices?.length) {
|
||||
resultEl.innerHTML = `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
|
||||
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) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs)">${_esc(inv.invoice_number)}</td>
|
||||
<td class="adm-td">${_esc(inv.recipient_name)}</td>
|
||||
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtE(inv.amount_gross)}</td>
|
||||
<td class="adm-td">${sL[inv.status]||inv.status}</td>
|
||||
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtD(inv.created_at)}</td>
|
||||
</tr>`).join('');
|
||||
resultEl.innerHTML = `
|
||||
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||||
${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)}
|
||||
</div>
|
||||
<div class="adm-table-scroll">
|
||||
<table class="adm-table">
|
||||
<thead><tr style="background:var(--c-surface-2)">
|
||||
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
|
||||
<th class="adm-th" style="text-align:right">Betrag</th><th class="adm-th">Status</th>
|
||||
<th class="adm-th">Erstellt</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows2}</tbody>
|
||||
<tfoot><tr style="border-top:2px solid var(--c-border)">
|
||||
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
|
||||
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
|
||||
<td colspan="2" class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
|
||||
</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${_esc(e.message)}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue