diff --git a/public/invoice-view.js b/public/invoice-view.js index 854794b..9820e1e 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -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 + const invNumDisplay = invoice.invoice_number + ? invoice.invoice_number : `Draft`; - // QBO Button — if already in QBO, show checkmark + optional reset - const qboButton = hasQbo - ? `✓ QBO` - : ``; - - // Paid/Unpaid Toggle - const paidButton = paid - ? `` - : ``; - // Status Badge let statusBadge = ''; if (paid) { @@ -183,14 +219,13 @@ 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); - + if (!paid) { if (daysUntil < 0) { sendDateDisplay += ` (${Math.abs(daysUntil)}d ago)`; @@ -202,6 +237,30 @@ function renderInvoiceRow(invoice) { } } + // --- ACTION BUTTONS (Reihenfolge: Edit | QBO | PDF HTML | Paid | Del) --- + + // Edit + const editBtn = ``; + + // QBO + const qboBtn = hasQbo + ? `✓ QBO` + : ``; + + // PDF + HTML — PDF deaktiviert wenn Draft (keine Rechnungsnummer) + const pdfBtn = draft + ? `PDF` + : ``; + const htmlBtn = ``; + + // Paid/Unpaid + const paidBtn = paid + ? `` + : ``; + + // Delete + const delBtn = ``; + const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : ''; return ` @@ -215,12 +274,7 @@ function renderInvoiceRow(invoice) { ${invoice.terms} $${parseFloat(invoice.total).toFixed(2)} - - - ${qboButton} - ${paidButton} - - + ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn} `; @@ -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() { +
@@ -375,6 +446,7 @@ export function injectToolbar() {
@@ -384,9 +456,9 @@ export function injectToolbar() {
@@ -397,13 +469,18 @@ export function injectToolbar() { `; + // 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(); } @@ -454,7 +532,7 @@ 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) { diff --git a/server.js b/server.js index 2e1dff5..7c62fca 100644 --- a/server.js +++ b/server.js @@ -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)