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 // 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 // State
// ============================================================ // ============================================================
let invoices = []; let invoices = [];
let filterCustomer = ''; let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue' let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = 'none'; // 'none' | 'week' | 'month' let groupBy = localStorage.getItem('inv_groupBy') || 'none';
const OVERDUE_DAYS = 30; const OVERDUE_DAYS = 30;
@ -15,9 +15,23 @@ const OVERDUE_DAYS = 30;
// Helpers // 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) { function formatDate(date) {
if (!date) return '—'; 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 month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear(); const year = d.getFullYear();
@ -25,19 +39,23 @@ function formatDate(date) {
} }
function daysSince(date) { function daysSince(date) {
const d = new Date(date); const d = parseLocalDate(date);
if (!d) return 0;
const now = new Date(); const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.floor((now - d) / 86400000); return Math.floor((now - d) / 86400000);
} }
function getWeekNumber(date) { function getWeekNumber(date) {
const d = new Date(date); const d = parseLocalDate(date);
d.setHours(0, 0, 0, 0); if (!d) return { year: 0, week: 0 };
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); const copy = new Date(d.getTime());
const week1 = new Date(d.getFullYear(), 0, 4); copy.setHours(0, 0, 0, 0);
copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7));
const week1 = new Date(copy.getFullYear(), 0, 4);
return { return {
year: d.getFullYear(), year: copy.getFullYear(),
week: 1 + Math.round(((d - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7) 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); monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
const sunday = new Date(monday); const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6); 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) { function getMonthName(monthIndex) {
@ -60,10 +83,21 @@ function isPaid(inv) {
return !!inv.paid_date; return !!inv.paid_date;
} }
function isDraft(inv) {
return !inv.qbo_id;
}
function isOverdue(inv) { function isOverdue(inv) {
return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; 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 // Data Loading
// ============================================================ // ============================================================
@ -96,6 +130,8 @@ function getFilteredInvoices() {
filtered = filtered.filter(inv => isPaid(inv)); filtered = filtered.filter(inv => isPaid(inv));
} else if (filterStatus === 'overdue') { } else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv)); filtered = filtered.filter(inv => isOverdue(inv));
} else if (filterStatus === 'draft') {
filtered = filtered.filter(inv => isDraft(inv) && !isPaid(inv));
} }
// Customer Filter // Customer Filter
@ -107,7 +143,11 @@ function getFilteredInvoices() {
} }
// Sortierung: neueste zuerst // 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; return filtered;
} }
@ -118,7 +158,8 @@ function groupInvoices(filtered) {
const groups = new Map(); const groups = new Map();
filtered.forEach(inv => { filtered.forEach(inv => {
const d = new Date(inv.invoice_date); const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label; let key, label;
if (groupBy === 'week') { if (groupBy === 'week') {
@ -142,7 +183,11 @@ function groupInvoices(filtered) {
}); });
for (const group of groups.values()) { 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]))); 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 hasQbo = !!invoice.qbo_id;
const paid = isPaid(invoice); const paid = isPaid(invoice);
const overdue = isOverdue(invoice); const overdue = isOverdue(invoice);
const draft = isDraft(invoice);
// Invoice Number Display // Invoice Number Display
const invNumDisplay = invoice.invoice_number const invNumDisplay = invoice.invoice_number
? invoice.invoice_number ? invoice.invoice_number
: `<span class="text-gray-400 italic text-xs">Draft</span>`; : `<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 // Status Badge
let statusBadge = ''; let statusBadge = '';
if (paid) { if (paid) {
@ -183,10 +219,9 @@ function renderInvoiceRow(invoice) {
// Send Date display // Send Date display
let sendDateDisplay = '—'; let sendDateDisplay = '—';
if (invoice.scheduled_send_date) { if (invoice.scheduled_send_date) {
const sendDate = new Date(invoice.scheduled_send_date); const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date(); const today = new Date();
today.setHours(0,0,0,0); today.setHours(0, 0, 0, 0);
sendDate.setHours(0,0,0,0);
const daysUntil = Math.floor((sendDate - today) / 86400000); const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date); 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' : ''; const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return ` 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-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 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"> <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> ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
<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> </td>
</tr> </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 unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge'); const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) { if (unpaidBadge) {
@ -353,7 +418,7 @@ export function injectToolbar() {
</button> </button>
<button data-status-filter="unpaid" <button data-status-filter="unpaid"
onclick="window.invoiceView.setStatus('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> Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
</button> </button>
<button data-status-filter="paid" <button data-status-filter="paid"
@ -367,6 +432,12 @@ export function injectToolbar() {
Overdue 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> <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>
<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>
<div class="w-px h-8 bg-gray-300"></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"> <div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Customer:</label> <label class="text-sm font-medium text-gray-700">Customer:</label>
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." <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"> 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>
@ -384,9 +456,9 @@ export function injectToolbar() {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Group:</label> <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"> <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="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
<option value="week">Week</option> <option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
<option value="month">Month</option> <option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
</select> </select>
</div> </div>
@ -397,13 +469,18 @@ export function injectToolbar() {
</div> </div>
`; `;
// Restore active button state
updateStatusButtons();
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => { document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value; filterCustomer = e.target.value;
saveSettings();
renderInvoiceView(); renderInvoiceView();
}); });
document.getElementById('invoice-group-by').addEventListener('change', (e) => { document.getElementById('invoice-group-by').addEventListener('change', (e) => {
groupBy = e.target.value; groupBy = e.target.value;
saveSettings();
renderInvoiceView(); renderInvoiceView();
}); });
} }
@ -414,6 +491,7 @@ export function injectToolbar() {
export function setStatus(status) { export function setStatus(status) {
filterStatus = status; filterStatus = status;
saveSettings();
renderInvoiceView(); renderInvoiceView();
} }

View File

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