invoice-system/public/invoice-view.js

503 lines
18 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
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter,
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
// ============================================================
// State
// ============================================================
let invoices = [];
let filterCustomer = '';
let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue'
let groupBy = 'none'; // 'none' | 'week' | 'month'
const OVERDUE_DAYS = 30;
// ============================================================
// Helpers
// ============================================================
function formatDate(date) {
const d = new Date(date);
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 = new Date(date);
const now = new Date();
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);
return {
year: d.getFullYear(),
week: 1 + Math.round(((d - 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);
return { start: formatDate(monday), end: formatDate(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 isOverdue(inv) {
return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS;
}
// ============================================================
// 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));
}
// 'all' → kein Filter
// 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) => new Date(b.invoice_date) - new Date(a.invoice_date));
return filtered;
}
function groupInvoices(filtered) {
if (groupBy === 'none') return null;
const groups = new Map();
filtered.forEach(inv => {
const d = new Date(inv.invoice_date);
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;
});
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
for (const group of groups.values()) {
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
}
// Gruppen nach Key sortieren (neueste zuerst)
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);
// QBO Button
const qboButton = hasQbo
? `<span class="text-gray-400 text-xs" title="Already in QBO (ID: ${invoice.qbo_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 Button
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) {
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 old">Overdue</span>`;
}
// Row styling
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return `
<tr class="${rowClass}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${invoice.invoice_number} ${statusBadge}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<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">Delete</button>
</td>
</tr>
`;
}
function renderGroupHeader(label) {
return `
<tr class="bg-blue-50">
<td colspan="6" class="px-6 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="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-6 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="4" class="px-6 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-6 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="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<td></td>
</tr>
`;
}
}
if (filtered.length === 0) {
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
}
tbody.innerHTML = html;
// Update count badge
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
// Update status button active states
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');
}
});
// Update overdue count badge
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');
}
}
// Update unpaid count
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) {
unpaidBadge.textContent = unpaidCount;
}
}
// ============================================================
// Toolbar HTML
// ============================================================
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-blue-600 text-white">
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>
</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..."
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">None</option>
<option value="week">Week</option>
<option value="month">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>
`;
// Event Listeners
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value;
renderInvoiceView();
});
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
groupBy = e.target.value;
renderInvoiceView();
});
}
// ============================================================
// Actions
// ============================================================
export function setStatus(status) {
filterStatus = status;
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 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,
markPaid,
markUnpaid,
edit,
remove,
loadInvoices,
renderInvoiceView,
setStatus
};