// 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 isSent(inv) { return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent'; } function isOpen(inv) { return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent'; } 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 === 'partial') f = f.filter(i => isPartiallyPaid(i)); else if (filterStatus === 'sent') f = f.filter(i => isSent(i)); else if (filterStatus === 'open') f = f.filter(i => isOpen(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 (left side, next to invoice number) let statusBadge = ''; if (paid && invoice.payment_status === 'Deposited') { statusBadge = `Deposited`; } else if (paid) { statusBadge = `Paid`; } else if (partial) { // Partial: show delivery status badge + Partial badge if (hasQbo && invoice.email_status === 'sent') { statusBadge = `Sent `; } else if (hasQbo) { statusBadge = `Open `; } statusBadge += `Partial`; } else if (overdue) { statusBadge = `Overdue`; } else if (hasQbo && invoice.email_status === 'sent') { statusBadge = `Sent`; } else if (hasQbo) { statusBadge = `Open`; } // 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 && invoice.email_status !== 'sent') { 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 = ``; } // Mark Sent button (right side) — only when open, not paid/partial let sendBtn = ''; // if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') { // sendBtn = ``; // } if (hasQbo && !paid && !overdue) { sendBtn = ` `; } 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 ` ${invNumDisplay} ${statusBadge} ${invoice.customer_name || 'N/A'} ${formatDate(invoice.invoice_date)} ${sendDateDisplay} ${invoice.terms} ${amountDisplay} ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${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 += effectiveAmount(inv); }); 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 counts = { unpaid: invoices.filter(i => !isPaid(i)).length, open: invoices.filter(i => isOpen(i)).length, sent: invoices.filter(i => isSent(i)).length, partial: invoices.filter(i => isPartiallyPaid(i)).length, paid: invoices.filter(i => isPaid(i)).length, overdue: invoices.filter(i => isOverdue(i)).length }; ['unpaid', 'open', 'sent', 'partial', 'paid', 'overdue'].forEach(key => { const el = document.getElementById(`${key}-badge`); if (el) { el.textContent = counts[key]; el.classList.toggle('hidden', counts[key] === 0); } }); } // ============================================================ // 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('Export invoice to QuickBooks Online?')) return; if (typeof showSpinner === 'function') showSpinner('Exporting invoice to 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('Network error.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function syncToQBO(id) { if (!confirm('Sync changes to QuickBooks Online?')) return; if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...'); try { const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' }); const d = await r.json(); if (r.ok) { alert(`✅ ${d.message}`); loadInvoices(); } else alert(`❌ ${d.error}`); } catch (e) { alert('Network error.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function syncFromQBO() { if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...'); try { const r = await fetch('/api/qbo/sync-payments', { method: 'POST' }); const d = await r.json(); if (r.ok) { alert(`✅ ${d.message}`); loadInvoices(); } else { alert(`❌ ${d.error}`); } } catch (e) { alert('Network error.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function setEmailStatus(id, status) { const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent'; if (!confirm(`${label}?`)) return; if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`); try { const r = await fetch(`/api/invoices/${id}/email-status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }); const d = await r.json(); if (r.ok) loadInvoices(); else alert(`❌ ${d.error}`); } catch (e) { alert('Network error.'); } finally { if (typeof hideSpinner === 'function') hideSpinner(); } } export async function resetQbo(id) { if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) 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 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, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove, loadInvoices, renderInvoiceView, setStatus };