// invoice-view.js — ES Module für die Invoice View // Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO // ============================================================ // State // ============================================================ let invoices = []; let filterCustomer = ''; let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue' let groupBy = 'none'; // 'none' | 'week' | 'month' const OVERDUE_DAYS = 30; // ============================================================ // Helpers // ============================================================ function formatDate(date) { if (!date) return '—'; const d = new Date(date); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const year = d.getFullYear(); return `${month}/${day}/${year}`; } function daysSince(date) { const d = new Date(date); const now = new Date(); return Math.floor((now - d) / 86400000); } function getWeekNumber(date) { const d = new Date(date); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); const week1 = new Date(d.getFullYear(), 0, 4); return { year: d.getFullYear(), week: 1 + Math.round(((d - 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); return { start: formatDate(monday), end: formatDate(sunday) }; } function getMonthName(monthIndex) { return ['January','February','March','April','May','June', 'July','August','September','October','November','December'][monthIndex]; } function isPaid(inv) { return !!inv.paid_date; } function isOverdue(inv) { return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; } // ============================================================ // Data Loading // ============================================================ 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; } // ============================================================ // Filtering & Sorting & Grouping // ============================================================ function getFilteredInvoices() { let filtered = [...invoices]; // Status Filter if (filterStatus === 'unpaid') { filtered = filtered.filter(inv => !isPaid(inv)); } else if (filterStatus === 'paid') { filtered = filtered.filter(inv => isPaid(inv)); } else if (filterStatus === 'overdue') { filtered = filtered.filter(inv => isOverdue(inv)); } // Customer Filter if (filterCustomer.trim()) { const search = filterCustomer.toLowerCase(); filtered = filtered.filter(inv => (inv.customer_name || '').toLowerCase().includes(search) ); } // Sortierung: neueste zuerst filtered.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date)); return filtered; } function groupInvoices(filtered) { if (groupBy === 'none') return null; const groups = new Map(); filtered.forEach(inv => { const d = new Date(inv.invoice_date); 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 if (groupBy === 'month') { const month = d.getMonth(); const year = d.getFullYear(); key = `${year}-${String(month).padStart(2, '0')}`; label = `${getMonthName(month)} ${year}`; } if (!groups.has(key)) { groups.set(key, { label, invoices: [], total: 0 }); } const group = groups.get(key); group.invoices.push(inv); group.total += parseFloat(inv.total) || 0; }); for (const group of groups.values()) { group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date)); } return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0]))); } // ============================================================ // Rendering // ============================================================ function renderInvoiceRow(invoice) { const hasQbo = !!invoice.qbo_id; const paid = isPaid(invoice); const overdue = isOverdue(invoice); // Invoice Number Display const invNumDisplay = invoice.invoice_number ? invoice.invoice_number : `Draft`; // QBO Button — if already in QBO, show checkmark + optional reset const qboButton = hasQbo ? `✓ QBO` : ``; // Paid/Unpaid Toggle const paidButton = paid ? `` : ``; // Status Badge let statusBadge = ''; if (paid) { statusBadge = `Paid`; } else if (overdue) { statusBadge = `Overdue`; } // Send Date display let sendDateDisplay = '—'; if (invoice.scheduled_send_date) { const sendDate = new Date(invoice.scheduled_send_date); const today = new Date(); today.setHours(0,0,0,0); sendDate.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)`; } } } 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)} ${qboButton} ${paidButton} `; } 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 = ''; let grandTotal = 0; if (groups) { for (const [key, 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 status = btn.getAttribute('data-status-filter'); if (status === filterStatus) { btn.classList.remove('bg-white', 'text-gray-600'); btn.classList.add('bg-blue-600', 'text-white'); } else { btn.classList.remove('bg-blue-600', 'text-white'); btn.classList.add('bg-white', 'text-gray-600'); } }); const overdueCount = invoices.filter(inv => isOverdue(inv)).length; const overdueBadge = document.getElementById('overdue-badge'); if (overdueBadge) { if (overdueCount > 0) { overdueBadge.textContent = overdueCount; overdueBadge.classList.remove('hidden'); } else { overdueBadge.classList.add('hidden'); } } const unpaidCount = invoices.filter(inv => !isPaid(inv)).length; const unpaidBadge = document.getElementById('unpaid-badge'); if (unpaidBadge) { unpaidBadge.textContent = unpaidCount; } } // ============================================================ // Toolbar // ============================================================ export function injectToolbar() { const container = document.getElementById('invoice-toolbar'); if (!container) return; container.innerHTML = `
0 invoices
`; document.getElementById('invoice-filter-customer').addEventListener('input', (e) => { filterCustomer = e.target.value; renderInvoiceView(); }); document.getElementById('invoice-group-by').addEventListener('change', (e) => { groupBy = e.target.value; renderInvoiceView(); }); } // ============================================================ // Actions // ============================================================ export function setStatus(status) { filterStatus = status; 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 wirklich an QuickBooks Online senden?')) return; const btn = event.target; const originalText = btn.textContent; btn.textContent = "⏳..."; btn.disabled = true; try { const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' }); const result = await response.json(); if (response.ok) { alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`); loadInvoices(); } else { alert(`❌ Fehler: ${result.error}`); } } catch (error) { console.error(error); alert('Netzwerkfehler beim Export.'); } finally { btn.textContent = originalText; btn.disabled = false; } } export async function resetQbo(id) { if (!confirm('QBO-Verknüpfung zurücksetzen?\nDie Rechnung muss zuerst in QBO gelöscht werden!')) return; try { const response = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }); if (response.ok) { loadInvoices(); } else { const err = await response.json(); alert('Error: ' + (err.error || 'Unknown')); } } catch (error) { console.error('Error resetting QBO:', error); } } export async function markPaid(id) { try { const response = 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 (response.ok) { loadInvoices(); } else { const err = await response.json(); alert('Error: ' + (err.error || 'Unknown')); } } catch (error) { console.error('Error marking paid:', error); } } export async function markUnpaid(id) { try { const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }); if (response.ok) { loadInvoices(); } else { const err = await response.json(); alert('Error: ' + (err.error || 'Unknown')); } } catch (error) { console.error('Error marking unpaid:', error); } } export async function edit(id) { if (typeof window.openInvoiceModal === 'function') { await window.openInvoiceModal(id); } } export async function remove(id) { if (!confirm('Are you sure you want to delete this invoice?')) return; try { const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (response.ok) { loadInvoices(); } else { alert('Error deleting invoice'); } } catch (error) { console.error('Error:', error); } } // ============================================================ // Expose to window // ============================================================ window.invoiceView = { viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove, loadInvoices, renderInvoiceView, setStatus };