This commit is contained in:
Andreas Knuth 2026-02-17 21:29:56 -06:00
parent a0c62d639e
commit 2bb304babe
2 changed files with 124 additions and 46 deletions

View File

@ -1,13 +1,13 @@
// invoice-view.js — ES Module für die Invoice View
// Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO
// v3: UTC date fix, Draft filter, persistent settings, PDF disabled for drafts
// ============================================================
// State
// ============================================================
let invoices = [];
let filterCustomer = '';
let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue'
let groupBy = 'none'; // 'none' | 'week' | 'month'
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
const OVERDUE_DAYS = 30;
@ -15,9 +15,23 @@ const OVERDUE_DAYS = 30;
// Helpers
// ============================================================
// KRITISCHER FIX: Datum-String 'YYYY-MM-DD' OHNE Timezone-Conversion parsen
// new Date('2026-02-16') interpretiert als UTC → in CST wird's der 15.
// Stattdessen: manuell parsen oder 'T12:00:00' anhängen
function parseLocalDate(dateStr) {
if (!dateStr) return null;
// Wenn es schon ein T enthält (ISO mit Zeit), den Datumsteil nehmen
const str = String(dateStr).split('T')[0];
const parts = str.split('-');
if (parts.length !== 3) return null;
// Monat ist 0-basiert in JS
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
function formatDate(date) {
if (!date) return '—';
const d = new Date(date);
const d = parseLocalDate(date);
if (!d) return '—';
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
@ -25,19 +39,23 @@ function formatDate(date) {
}
function daysSince(date) {
const d = new Date(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 = 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);
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: d.getFullYear(),
week: 1 + Math.round(((d - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
year: copy.getFullYear(),
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
};
}
@ -48,7 +66,12 @@ function getWeekRange(year, weekNum) {
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) };
const fmt = (d) => {
const m = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
return `${m}/${dy}/${d.getFullYear()}`;
};
return { start: fmt(monday), end: fmt(sunday) };
}
function getMonthName(monthIndex) {
@ -60,10 +83,21 @@ 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;
}
// Save state to localStorage
function saveSettings() {
localStorage.setItem('inv_filterStatus', filterStatus);
localStorage.setItem('inv_groupBy', groupBy);
localStorage.setItem('inv_filterCustomer', filterCustomer);
}
// ============================================================
// Data Loading
// ============================================================
@ -96,6 +130,8 @@ function getFilteredInvoices() {
filtered = filtered.filter(inv => isPaid(inv));
} else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv));
} else if (filterStatus === 'draft') {
filtered = filtered.filter(inv => isDraft(inv) && !isPaid(inv));
}
// Customer Filter
@ -107,7 +143,11 @@ function getFilteredInvoices() {
}
// Sortierung: neueste zuerst
filtered.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
filtered.sort((a, b) => {
const da = parseLocalDate(a.invoice_date);
const db = parseLocalDate(b.invoice_date);
return (db || 0) - (da || 0);
});
return filtered;
}
@ -118,7 +158,8 @@ function groupInvoices(filtered) {
const groups = new Map();
filtered.forEach(inv => {
const d = new Date(inv.invoice_date);
const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label;
if (groupBy === 'week') {
@ -142,7 +183,11 @@ function groupInvoices(filtered) {
});
for (const group of groups.values()) {
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
group.invoices.sort((a, b) => {
const da = parseLocalDate(a.invoice_date);
const db = parseLocalDate(b.invoice_date);
return (db || 0) - (da || 0);
});
}
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
@ -156,22 +201,13 @@ function renderInvoiceRow(invoice) {
const hasQbo = !!invoice.qbo_id;
const paid = isPaid(invoice);
const overdue = isOverdue(invoice);
const draft = isDraft(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) {
@ -183,10 +219,9 @@ function renderInvoiceRow(invoice) {
// Send Date display
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = new Date(invoice.scheduled_send_date);
const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date();
today.setHours(0,0,0,0);
sendDate.setHours(0,0,0,0);
today.setHours(0, 0, 0, 0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date);
@ -202,6 +237,30 @@ function renderInvoiceRow(invoice) {
}
}
// --- ACTION BUTTONS (Reihenfolge: Edit | QBO | PDF HTML | Paid | Del) ---
// Edit
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
// QBO
const qboBtn = 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>`;
// PDF + HTML — PDF deaktiviert wenn Draft (keine Rechnungsnummer)
const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export verfügbar">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>`;
// Paid/Unpaid
const paidBtn = 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>`;
// Delete
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 `
@ -215,12 +274,7 @@ function renderInvoiceRow(invoice) {
<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>
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
</td>
</tr>
`;
@ -327,6 +381,17 @@ function updateStatusButtons() {
}
}
const draftCount = invoices.filter(inv => isDraft(inv) && !isPaid(inv)).length;
const draftBadge = document.getElementById('draft-badge');
if (draftBadge) {
if (draftCount > 0) {
draftBadge.textContent = draftCount;
draftBadge.classList.remove('hidden');
} else {
draftBadge.classList.add('hidden');
}
}
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) {
@ -353,7 +418,7 @@ export function injectToolbar() {
</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">
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"
@ -367,6 +432,12 @@ export function injectToolbar() {
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>
@ -375,6 +446,7 @@ export function injectToolbar() {
<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>
@ -384,9 +456,9 @@ export function injectToolbar() {
<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>
<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>
@ -397,13 +469,18 @@ export function injectToolbar() {
</div>
`;
// Restore active button state
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();
});
}
@ -414,6 +491,7 @@ export function injectToolbar() {
export function setStatus(status) {
filterStatus = status;
saveSettings();
renderInvoiceView();
}

View File

@ -893,7 +893,7 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number)
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
@ -1118,7 +1118,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number)
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)