539 lines
19 KiB
JavaScript
539 lines
19 KiB
JavaScript
// 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
|
||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||
|
||
// QBO Button — if already in QBO, show checkmark + optional reset
|
||
const qboButton = hasQbo
|
||
? `<span class="text-gray-400 text-xs cursor-pointer" title="In QBO (ID: ${invoice.qbo_id}) — Click to reset" onclick="window.invoiceView.resetQbo(${invoice.id})">✓ QBO</span>`
|
||
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
||
|
||
// Paid/Unpaid Toggle
|
||
const paidButton = paid
|
||
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
|
||
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
|
||
|
||
// Status Badge
|
||
let statusBadge = '';
|
||
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 (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 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 += ` <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>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
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">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||
<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||
<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>
|
||
${qboButton}
|
||
${paidButton}
|
||
<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>
|
||
</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 = '';
|
||
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 += `
|
||
<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 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 = `
|
||
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||
<!-- Status Filter Buttons -->
|
||
<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-blue-600 text-white">
|
||
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>
|
||
</div>
|
||
|
||
<div class="w-px h-8 bg-gray-300"></div>
|
||
|
||
<!-- Customer Filter -->
|
||
<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..."
|
||
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>
|
||
|
||
<!-- Group By -->
|
||
<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 focus:ring-blue-500 focus:border-blue-500">
|
||
<option value="none">None</option>
|
||
<option value="week">Week</option>
|
||
<option value="month">Month</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Invoice Count -->
|
||
<div class="ml-auto text-sm text-gray-500">
|
||
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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
|
||
}; |