// invoice-view.js — ES Module v4 // Fixes: No Paid for drafts, payment modal, UTC dates, persistent settings let invoices = []; let filterCustomer = localStorage.getItem('inv_filterCustomer') || ''; let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid'; let groupBy = localStorage.getItem('inv_groupBy') || 'none'; const OVERDUE_DAYS = 30; // ============================================================ // Date Helpers — KEIN new Date('YYYY-MM-DD') wegen UTC-Bug! // ============================================================ function parseLocalDate(dateStr) { if (!dateStr) return null; const str = String(dateStr).split('T')[0]; const parts = str.split('-'); if (parts.length !== 3) return null; return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); } function formatDate(date) { if (!date) return '—'; const d = parseLocalDate(date); if (!d) return '—'; return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`; } function daysSince(date) { const d = parseLocalDate(date); if (!d) return 0; const now = new Date(); now.setHours(0, 0, 0, 0); return Math.floor((now - d) / 86400000); } function getWeekNumber(date) { const d = parseLocalDate(date); if (!d) return { year: 0, week: 0 }; const copy = new Date(d.getTime()); copy.setHours(0, 0, 0, 0); copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7)); const week1 = new Date(copy.getFullYear(), 0, 4); return { year: copy.getFullYear(), week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7) }; } function getWeekRange(year, weekNum) { const jan4 = new Date(year, 0, 4); const dayOfWeek = jan4.getDay() || 7; const monday = new Date(jan4); monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`; return { start: fmt(monday), end: fmt(sunday) }; } function getMonthName(i) { return ['January','February','March','April','May','June','July','August','September','October','November','December'][i]; } function isPaid(inv) { return !!inv.paid_date; } function isDraft(inv) { return !inv.qbo_id; } function isOverdue(inv) { return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; } function saveSettings() { localStorage.setItem('inv_filterStatus', filterStatus); localStorage.setItem('inv_groupBy', groupBy); localStorage.setItem('inv_filterCustomer', filterCustomer); } // ============================================================ // Data // ============================================================ export async function loadInvoices() { try { const response = await fetch('/api/invoices'); invoices = await response.json(); renderInvoiceView(); } catch (error) { console.error('Error loading invoices:', error); } } export function getInvoicesData() { return invoices; } // ============================================================ // Filter / Sort / Group // ============================================================ function getFilteredInvoices() { let f = [...invoices]; if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i)); else if (filterStatus === 'paid') f = f.filter(i => isPaid(i)); else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i)); else if (filterStatus === 'draft') f = f.filter(i => isDraft(i) && !isPaid(i)); if (filterCustomer.trim()) { const s = filterCustomer.toLowerCase(); f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s)); } f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0)); return f; } function groupInvoices(filtered) { if (groupBy === 'none') return null; const groups = new Map(); filtered.forEach(inv => { const d = parseLocalDate(inv.invoice_date); if (!d) return; let key, label; if (groupBy === 'week') { const wk = getWeekNumber(inv.invoice_date); key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`; const range = getWeekRange(wk.year, wk.week); label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`; } else { key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`; label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`; } if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 }); const g = groups.get(key); g.invoices.push(inv); g.total += parseFloat(inv.total) || 0; }); for (const g of groups.values()) { g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0)); } return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0]))); } // ============================================================ // Render // ============================================================ function renderInvoiceRow(invoice) { const hasQbo = !!invoice.qbo_id; const paid = isPaid(invoice); const overdue = isOverdue(invoice); const draft = isDraft(invoice); const invNumDisplay = invoice.invoice_number ? invoice.invoice_number : `Draft`; let statusBadge = ''; if (paid) statusBadge = `Paid`; else if (overdue) statusBadge = `Overdue`; // Send Date let sendDateDisplay = '—'; if (invoice.scheduled_send_date) { const sendDate = parseLocalDate(invoice.scheduled_send_date); const today = new Date(); today.setHours(0, 0, 0, 0); const daysUntil = Math.floor((sendDate - today) / 86400000); sendDateDisplay = formatDate(invoice.scheduled_send_date); if (!paid) { if (daysUntil < 0) sendDateDisplay += ` (${Math.abs(daysUntil)}d ago)`; else if (daysUntil === 0) sendDateDisplay += ` (today)`; else if (daysUntil <= 3) sendDateDisplay += ` (in ${daysUntil}d)`; } } // --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) --- const editBtn = ``; // QBO Button — nur aktiv wenn Kunde eine qbo_id hat const customerHasQbo = !!invoice.customer_qbo_id; let qboBtn; if (hasQbo) { qboBtn = `✓ QBO`; } else if (!customerHasQbo) { qboBtn = `QBO ⚠`; } else { qboBtn = ``; } const pdfBtn = draft ? `PDF` : ``; const htmlBtn = ``; // PAYMENT BUTTON — NUR wenn in QBO. Drafts bekommen KEINEN Button. let paidBtn = ''; if (paid) { paidBtn = ``; } else if (hasQbo) { paidBtn = ``; } // Kein Button für Drafts (!hasQbo && !paid) const delBtn = ``; const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : ''; return ` ${invNumDisplay} ${statusBadge} ${invoice.customer_name || 'N/A'} ${formatDate(invoice.invoice_date)} ${sendDateDisplay} ${invoice.terms} $${parseFloat(invoice.total).toFixed(2)} ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn} `; } function renderGroupHeader(label) { return `📅 ${label}`; } function renderGroupFooter(total, count) { return ` Group Total (${count} invoices): $${total.toFixed(2)}`; } export function renderInvoiceView() { const tbody = document.getElementById('invoices-list'); if (!tbody) return; const filtered = getFilteredInvoices(); const groups = groupInvoices(filtered); let html = '', grandTotal = 0; if (groups) { for (const [, group] of groups) { html += renderGroupHeader(group.label); group.invoices.forEach(inv => { html += renderInvoiceRow(inv); }); html += renderGroupFooter(group.total, group.invoices.length); grandTotal += group.total; } if (groups.size > 1) { html += ` Grand Total (${filtered.length} invoices): $${grandTotal.toFixed(2)}`; } } else { filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += parseFloat(inv.total) || 0; }); if (filtered.length > 0) { html += ` Total (${filtered.length} invoices): $${grandTotal.toFixed(2)}`; } } if (filtered.length === 0) html = `No invoices found.`; tbody.innerHTML = html; const countEl = document.getElementById('invoice-count'); if (countEl) countEl.textContent = filtered.length; updateStatusButtons(); } function updateStatusButtons() { document.querySelectorAll('[data-status-filter]').forEach(btn => { const s = btn.getAttribute('data-status-filter'); btn.classList.toggle('bg-blue-600', s === filterStatus); btn.classList.toggle('text-white', s === filterStatus); btn.classList.toggle('bg-white', s !== filterStatus); btn.classList.toggle('text-gray-600', s !== filterStatus); }); const overdueCount = invoices.filter(i => isOverdue(i)).length; const ob = document.getElementById('overdue-badge'); if (ob) { ob.textContent = overdueCount; ob.classList.toggle('hidden', overdueCount === 0); } const draftCount = invoices.filter(i => isDraft(i) && !isPaid(i)).length; const db = document.getElementById('draft-badge'); if (db) { db.textContent = draftCount; db.classList.toggle('hidden', draftCount === 0); } const unpaidCount = invoices.filter(i => !isPaid(i)).length; const ub = document.getElementById('unpaid-badge'); if (ub) ub.textContent = unpaidCount; } // ============================================================ // Toolbar // ============================================================ export function injectToolbar() { const c = document.getElementById('invoice-toolbar'); if (!c) return; c.innerHTML = `
0 invoices
`; updateStatusButtons(); document.getElementById('invoice-filter-customer').addEventListener('input', (e) => { filterCustomer = e.target.value; saveSettings(); renderInvoiceView(); }); document.getElementById('invoice-group-by').addEventListener('change', (e) => { groupBy = e.target.value; saveSettings(); renderInvoiceView(); }); } // ============================================================ // Actions // ============================================================ export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); } export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); } export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); } export async function exportToQBO(id) { if (!confirm('Rechnung an QuickBooks Online senden?')) return; if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...'); try { const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' }); const d = await r.json(); if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); } else alert(`❌ ${d.error}`); } catch (e) { alert('Netzwerkfehler.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function resetQbo(id) { if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return; try { const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }); if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); } } catch (e) { console.error(e); } } export async function markPaid(id) { try { const r = await fetch(`/api/invoices/${id}/mark-paid`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] }) }); if (r.ok) loadInvoices(); } catch (e) { console.error(e); } } export async function markUnpaid(id) { try { const r = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (r.ok) loadInvoices(); } catch (e) { console.error(e); } } export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); } export async function remove(id) { if (!confirm('Delete this invoice?')) return; try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); } catch (e) { console.error(e); } } // ============================================================ // Expose // ============================================================ window.invoiceView = { viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove, loadInvoices, renderInvoiceView, setStatus };