This commit is contained in:
2026-02-17 20:53:00 -06:00
parent 84b0836234
commit a0c62d639e
4 changed files with 191 additions and 99 deletions

View File

@@ -1,6 +1,5 @@
// invoice-view.js — ES Module für die Invoice View
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter,
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
// Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO
// ============================================================
// State
@@ -17,6 +16,7 @@ const OVERDUE_DAYS = 30;
// ============================================================
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');
@@ -97,7 +97,6 @@ function getFilteredInvoices() {
} else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv));
}
// 'all' → kein Filter
// Customer Filter
if (filterCustomer.trim()) {
@@ -142,12 +141,10 @@ function groupInvoices(filtered) {
group.total += parseFloat(inv.total) || 0;
});
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
for (const group of groups.values()) {
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
}
// Gruppen nach Key sortieren (neueste zuerst)
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
}
@@ -160,12 +157,17 @@ function renderInvoiceRow(invoice) {
const paid = isPaid(invoice);
const overdue = isOverdue(invoice);
// QBO Button
// 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" title="Already in QBO (ID: ${invoice.qbo_id})">✓ QBO</span>`
? `<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 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>`;
@@ -175,28 +177,50 @@ function renderInvoiceRow(invoice) {
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 old">Overdue</span>`;
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>`;
}
}
}
// Row styling
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return `
<tr class="${rowClass}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${invoice.invoice_number} ${statusBadge}
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${invNumDisplay} ${statusBadge}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<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">Delete</button>
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>
</td>
</tr>
`;
@@ -205,7 +229,7 @@ function renderInvoiceRow(invoice) {
function renderGroupHeader(label) {
return `
<tr class="bg-blue-50">
<td colspan="6" class="px-6 py-3 text-sm font-bold text-blue-800">
<td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">
📅 ${label}
</td>
</tr>
@@ -215,8 +239,8 @@ function renderGroupHeader(label) {
function renderGroupFooter(total, count) {
return `
<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
<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>
`;
@@ -245,8 +269,8 @@ export function renderInvoiceView() {
if (groups.size > 1) {
html += `
<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="4" class="px-6 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-6 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
<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>
`;
@@ -260,8 +284,8 @@ export function renderInvoiceView() {
if (filtered.length > 0) {
html += `
<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<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>
`;
@@ -269,16 +293,14 @@ export function renderInvoiceView() {
}
if (filtered.length === 0) {
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
}
tbody.innerHTML = html;
// Update count badge
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
// Update status button active states
updateStatusButtons();
}
@@ -294,7 +316,6 @@ function updateStatusButtons() {
}
});
// Update overdue count badge
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
const overdueBadge = document.getElementById('overdue-badge');
if (overdueBadge) {
@@ -306,7 +327,6 @@ function updateStatusButtons() {
}
}
// Update unpaid count
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) {
@@ -315,7 +335,7 @@ function updateStatusButtons() {
}
// ============================================================
// Toolbar HTML
// Toolbar
// ============================================================
export function injectToolbar() {
@@ -377,7 +397,6 @@ export function injectToolbar() {
</div>
`;
// Event Listeners
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value;
renderInvoiceView();
@@ -433,6 +452,22 @@ export async function exportToQBO(id) {
}
}
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`, {
@@ -493,6 +528,7 @@ window.invoiceView = {
viewPDF,
viewHTML,
exportToQBO,
resetQbo,
markPaid,
markUnpaid,
edit,