invoice-system/public/invoice-view.js

413 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
let statusBadge = '';
const amountPaid = parseFloat(invoice.amount_paid) || 0;
const balance = parseFloat(invoice.balance) ?? (parseFloat(invoice.total) - amountPaid);
const isPartiallyPaid = !paid && amountPaid > 0 && balance > 0;
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 (isPartiallyPaid) 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>`;
// 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 += ` <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>`;
}
}
// --- 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>`;
// QBO Button — Export oder Sync
const customerHasQbo = !!invoice.customer_qbo_id;
let qboBtn;
if (hasQbo) {
// Already in QBO — show sync button + reset option
qboBtn = `<button onclick="window.invoiceView.syncToQBO(${invoice.id})" class="text-purple-600 hover:text-purple-900" title="Sync changes to QBO (ID: ${invoice.qbo_id})">⟳ QBO Sync</button>`;
} 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 = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
}
const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach 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 — NUR wenn in QBO. Drafts bekommen KEINEN Button.
let paidBtn = '';
if (paid) {
paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
} else if (hasQbo) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
}
// Kein Button für Drafts (!hasQbo && !paid)
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
const rowClass = paid ? 'bg-green-50/50' : 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">
${isPartiallyPaid
? `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`
: `$${parseFloat(invoice.total).toFixed(2)}`
}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${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 += parseFloat(inv.total) || 0; });
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 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 = `
<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="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span></button>
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid</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>
<button data-status-filter="draft" onclick="window.invoiceView.setStatus('draft')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Draft
<span id="draft-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>
</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="ml-auto text-sm text-gray-500">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
</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('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 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 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, syncToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
loadInvoices, renderInvoiceView, setStatus
};