515 lines
25 KiB
JavaScript
515 lines
25 KiB
JavaScript
// 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
|
||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||
|
||
// Status Badge (left side, next to invoice number)
|
||
let statusBadge = '';
|
||
if (paid && invoice.payment_status === 'Deposited') {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||
} else if (paid) {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||
} else if (partial) {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial $${amountPaid.toFixed(2)}</span>`;
|
||
} else if (overdue) {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
||
} else if (hasQbo && invoice.email_status === 'sent') {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
|
||
} else if (hasQbo) {
|
||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
|
||
}
|
||
|
||
// 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 += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
|
||
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
||
}
|
||
}
|
||
|
||
// Amount column — show balance when partially paid
|
||
let amountDisplay;
|
||
if (partial) {
|
||
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
|
||
} else {
|
||
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
|
||
}
|
||
|
||
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
|
||
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||
|
||
const customerHasQbo = !!invoice.customer_qbo_id;
|
||
let qboBtn;
|
||
if (hasQbo) {
|
||
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
|
||
} else if (!customerHasQbo) {
|
||
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||
} else {
|
||
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
|
||
}
|
||
|
||
const pdfBtn = draft
|
||
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
|
||
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
|
||
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
|
||
|
||
// Payment button — only for QBO invoices that are not fully paid
|
||
let paidBtn = '';
|
||
if (!paid && hasQbo) {
|
||
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
|
||
}
|
||
|
||
// Mark Sent button (right side) — only when open, not paid/partial
|
||
let sendBtn = '';
|
||
if (hasQbo && !paid && !partial && !overdue && invoice.email_status !== 'sent') {
|
||
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||
}
|
||
|
||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||
|
||
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 `
|
||
<tr class="${rowClass}">
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn}
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function renderGroupHeader(label) {
|
||
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
|
||
}
|
||
function renderGroupFooter(total, count) {
|
||
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
|
||
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
|
||
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
|
||
}
|
||
|
||
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 += `<tr class="bg-blue-100 border-t-4 border-blue-400">
|
||
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
|
||
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||
}
|
||
} else {
|
||
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += effectiveAmount(inv); });
|
||
if (filtered.length > 0) {
|
||
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
|
||
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
|
||
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
|
||
}
|
||
}
|
||
|
||
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
||
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 = `
|
||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
|
||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
|
||
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Unpaid
|
||
<span id="unpaid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
<button data-status-filter="open" onclick="window.invoiceView.setStatus('open')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Open
|
||
<span id="open-badge" class="hidden absolute -top-1.5 -right-1.5 bg-orange-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
<button data-status-filter="sent" onclick="window.invoiceView.setStatus('sent')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Sent
|
||
<span id="sent-badge" class="hidden absolute -top-1.5 -right-1.5 bg-cyan-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
|
||
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid
|
||
<span id="paid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
|
||
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
|
||
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
|
||
</div>
|
||
<div class="w-px h-8 bg-gray-300"></div>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
||
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
|
||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div class="w-px h-8 bg-gray-300"></div>
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm font-medium text-gray-700">Group:</label>
|
||
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
|
||
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
|
||
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
|
||
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
|
||
</select>
|
||
</div>
|
||
<div class="w-px h-8 bg-gray-300"></div>
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
|
||
⟳ Sync from QBO
|
||
</button>
|
||
</div>
|
||
<div class="ml-auto flex items-center gap-4">
|
||
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
|
||
<span class="text-sm text-gray-500">
|
||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||
</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
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
|
||
}; |