update
This commit is contained in:
parent
a0c62d639e
commit
2bb304babe
|
|
@ -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
|
||||
: `<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,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 += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue