invoice-system/public/invoice-view.js

622 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// invoice-view.js — ES Module für die Invoice View
// v3: UTC date fix, Draft filter, persistent settings, PDF disabled for drafts
// ============================================================
// State
// ============================================================
let invoices = [];
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
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 = 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();
return `${month}/${day}/${year}`;
}
function daysSince(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 = 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: copy.getFullYear(),
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
};
}
function getWeekRange(year, weekNum) {
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7;
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
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) {
return ['January','February','March','April','May','June',
'July','August','September','October','November','December'][monthIndex];
}
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
// ============================================================
export async function loadInvoices() {
try {
const response = await fetch('/api/invoices');
invoices = await response.json();
renderInvoiceView();
} catch (error) {
console.error('Error loading invoices:', error);
}
}
export function getInvoicesData() {
return invoices;
}
// ============================================================
// Filtering & Sorting & Grouping
// ============================================================
function getFilteredInvoices() {
let filtered = [...invoices];
// Status Filter
if (filterStatus === 'unpaid') {
filtered = filtered.filter(inv => !isPaid(inv));
} else if (filterStatus === 'paid') {
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
if (filterCustomer.trim()) {
const search = filterCustomer.toLowerCase();
filtered = filtered.filter(inv =>
(inv.customer_name || '').toLowerCase().includes(search)
);
}
// Sortierung: neueste zuerst
filtered.sort((a, b) => {
const da = parseLocalDate(a.invoice_date);
const db = parseLocalDate(b.invoice_date);
return (db || 0) - (da || 0);
});
return filtered;
}
function groupInvoices(filtered) {
if (groupBy === 'none') return null;
const groups = new Map();
filtered.forEach(inv => {
const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label;
if (groupBy === 'week') {
const wk = getWeekNumber(inv.invoice_date);
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
const range = getWeekRange(wk.year, wk.week);
label = `Week ${wk.week}, ${wk.year} (${range.start} ${range.end})`;
} else if (groupBy === 'month') {
const month = d.getMonth();
const year = d.getFullYear();
key = `${year}-${String(month).padStart(2, '0')}`;
label = `${getMonthName(month)} ${year}`;
}
if (!groups.has(key)) {
groups.set(key, { label, invoices: [], total: 0 });
}
const group = groups.get(key);
group.invoices.push(inv);
group.total += parseFloat(inv.total) || 0;
});
for (const group of groups.values()) {
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])));
}
// ============================================================
// Rendering
// ============================================================
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>`;
// Status Badge
let statusBadge = '';
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">Overdue</span>`;
}
// Send Date display
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date();
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>`;
} 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>`;
}
}
}
// --- 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 — wenn in QBO: Payment-Modal öffnen, sonst lokal markieren
let paidBtn;
if (paid) {
paidBtn = `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`;
} else if (hasQbo && window.paymentModal) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
} else {
paidBtn = `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid (local)">💰 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 `
<tr class="${rowClass}">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${invNumDisplay} ${statusBadge}
</td>
<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">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn}
</td>
</tr>
`;
}
function renderGroupHeader(label) {
return `
<tr class="bg-blue-50">
<td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">
📅 ${label}
</td>
</tr>
`;
}
function renderGroupFooter(total, count) {
return `
<tr class="bg-gray-50 border-t-2 border-gray-300">
<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>
`;
}
export function renderInvoiceView() {
const tbody = document.getElementById('invoices-list');
if (!tbody) return;
const filtered = getFilteredInvoices();
const groups = groupInvoices(filtered);
let html = '';
let grandTotal = 0;
if (groups) {
for (const [key, group] of groups) {
html += renderGroupHeader(group.label);
group.invoices.forEach(inv => {
html += renderInvoiceRow(inv);
});
html += renderGroupFooter(group.total, group.invoices.length);
grandTotal += group.total;
}
if (groups.size > 1) {
html += `
<tr class="bg-blue-100 border-t-4 border-blue-400">
<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>
`;
}
} else {
filtered.forEach(inv => {
html += renderInvoiceRow(inv);
grandTotal += parseFloat(inv.total) || 0;
});
if (filtered.length > 0) {
html += `
<tr class="bg-gray-100 border-t-2 border-gray-300">
<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>
`;
}
}
if (filtered.length === 0) {
html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
}
tbody.innerHTML = html;
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
updateStatusButtons();
}
function updateStatusButtons() {
document.querySelectorAll('[data-status-filter]').forEach(btn => {
const status = btn.getAttribute('data-status-filter');
if (status === filterStatus) {
btn.classList.remove('bg-white', 'text-gray-600');
btn.classList.add('bg-blue-600', 'text-white');
} else {
btn.classList.remove('bg-blue-600', 'text-white');
btn.classList.add('bg-white', 'text-gray-600');
}
});
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
const overdueBadge = document.getElementById('overdue-badge');
if (overdueBadge) {
if (overdueCount > 0) {
overdueBadge.textContent = overdueCount;
overdueBadge.classList.remove('hidden');
} else {
overdueBadge.classList.add('hidden');
}
}
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) {
unpaidBadge.textContent = unpaidCount;
}
}
// ============================================================
// Toolbar
// ============================================================
export function injectToolbar() {
const container = document.getElementById('invoice-toolbar');
if (!container) return;
container.innerHTML = `
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<!-- Status Filter Buttons -->
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
<button data-status-filter="all"
onclick="window.invoiceView.setStatus('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
All
</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-white text-gray-600">
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
</button>
<button data-status-filter="paid"
onclick="window.invoiceView.setStatus('paid')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
Paid
</button>
<button data-status-filter="overdue"
onclick="window.invoiceView.setStatus('overdue')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
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>
<!-- Customer Filter -->
<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>
<div class="w-px h-8 bg-gray-300"></div>
<!-- Group By -->
<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" ${groupBy === 'none' ? 'selected' : ''}>None</option>
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
</select>
</div>
<!-- Invoice Count -->
<div class="ml-auto text-sm text-gray-500">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
</div>
</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();
});
}
// ============================================================
// Actions
// ============================================================
export function setStatus(status) {
filterStatus = status;
saveSettings();
renderInvoiceView();
}
export function viewPDF(id) {
window.open(`/api/invoices/${id}/pdf`, '_blank');
}
export function viewHTML(id) {
window.open(`/api/invoices/${id}/html`, '_blank');
}
export async function exportToQBO(id) {
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = "⏳...";
btn.disabled = true;
try {
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`);
loadInvoices();
} else {
alert(`❌ Fehler: ${result.error}`);
}
} catch (error) {
console.error(error);
alert('Netzwerkfehler beim Export.');
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
}
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`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
});
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error marking paid:', error);
}
}
export async function markUnpaid(id) {
try {
const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' });
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error marking unpaid:', error);
}
}
export async function edit(id) {
if (typeof window.openInvoiceModal === 'function') {
await window.openInvoiceModal(id);
}
}
export async function remove(id) {
if (!confirm('Are you sure you want to delete this invoice?')) return;
try {
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
if (response.ok) {
loadInvoices();
} else {
alert('Error deleting invoice');
}
} catch (error) {
console.error('Error:', error);
}
}
// ============================================================
// Expose to window
// ============================================================
window.invoiceView = {
viewPDF,
viewHTML,
exportToQBO,
resetQbo,
markPaid,
markUnpaid,
edit,
remove,
loadInvoices,
renderInvoiceView,
setStatus
};