// invoice-view.js — ES Module v5 // Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button 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 // ============================================================ 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 formatDateTime(isoStr) { if (!isoStr) return 'Never'; const d = new Date(isoStr); return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } 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) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; } function isPartiallyPaid(inv) { const amountPaid = parseFloat(inv.amount_paid) || 0; const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid); return !inv.paid_date && amountPaid > 0 && balance > 0; } 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(); loadLastSync(); } catch (error) { console.error('Error loading invoices:', error); } } async function loadLastSync() { try { const res = await fetch('/api/qbo/last-sync'); const data = await res.json(); const el = document.getElementById('last-sync-time'); if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced'; } catch (e) { /* ignore */ } } 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)); else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(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; } // Effective amount: for unpaid/partial show balance, for paid show total function effectiveAmount(inv) { const total = parseFloat(inv.total) || 0; const amountPaid = parseFloat(inv.amount_paid) || 0; if (inv.paid_date) return total; // Paid → show full total if (amountPaid > 0) return total - amountPaid; // Partial → show balance return total; // Unpaid → show total } 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 += effectiveAmount(inv); }); 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 amountPaid = parseFloat(invoice.amount_paid) || 0; const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid); const partial = isPartiallyPaid(invoice); const invNumDisplay = invoice.invoice_number ? invoice.invoice_number : `Draft`; // Status Badge let statusBadge = ''; if (paid && invoice.payment_status === 'Deposited') { statusBadge = `Deposited`; } else if (paid) { statusBadge = `Paid`; } else if (partial) { statusBadge = `Partial $${amountPaid.toFixed(2)}`; } 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)`; } } // Amount column — show balance when partially paid let amountDisplay; if (partial) { amountDisplay = `$${balance.toFixed(2)} $${parseFloat(invoice.total).toFixed(2)}`; } else { amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`; } // --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del --- const editBtn = ``; const customerHasQbo = !!invoice.customer_qbo_id; let qboBtn; if (hasQbo) { qboBtn = `✓ QBO`; } else if (!customerHasQbo) { qboBtn = `QBO ⚠`; } else { qboBtn = `QBO pending`; } const pdfBtn = draft ? `PDF` : ``; const htmlBtn = ``; // Payment button — only for QBO invoices that are not fully paid let paidBtn = ''; if (!paid && hasQbo) { paidBtn = ``; } const delBtn = ``; const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : ''; return `