update
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user