update
This commit is contained in:
parent
31f03b0d7c
commit
2d5be21bf2
|
|
@ -78,7 +78,7 @@ window.customerSearch = customerSearch;
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCustomers();
|
||||
loadQuotes();
|
||||
loadInvoices();
|
||||
//loadInvoices();
|
||||
setDefaultDate();
|
||||
checkCurrentLogo();
|
||||
|
||||
|
|
@ -755,33 +755,37 @@ async function fetchNextInvoiceNumber() {
|
|||
}
|
||||
|
||||
async function loadInvoices() {
|
||||
// Wird vom invoice-view.js Modul überschrieben (window.loadInvoices)
|
||||
// Dieser Fallback lädt die Daten falls das Modul noch nicht geladen ist
|
||||
try {
|
||||
const response = await fetch('/api/invoices');
|
||||
invoices = await response.json();
|
||||
renderInvoices();
|
||||
// Falls das Modul geladen ist, nutze dessen Renderer
|
||||
if (window.invoiceView) {
|
||||
window.invoiceView.renderInvoiceView();
|
||||
} else {
|
||||
renderInvoices();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading invoices:', error);
|
||||
alert('Error loading invoices');
|
||||
}
|
||||
}
|
||||
|
||||
function renderInvoices() {
|
||||
if (window.invoiceView) {
|
||||
window.invoiceView.renderInvoiceView();
|
||||
return;
|
||||
}
|
||||
// Minimaler Fallback falls Modul nicht geladen
|
||||
const tbody = document.getElementById('invoices-list');
|
||||
tbody.innerHTML = invoices.map(invoice => `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">${invoice.invoice_number}</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="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||
<button onclick="exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">
|
||||
QBO Export
|
||||
</button>
|
||||
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${invoice.terms}</td>
|
||||
<td class="px-6 py-4 text-sm font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||
<td class="px-6 py-4 text-sm">Loading module...</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
|
@ -1125,38 +1129,13 @@ async function deleteInvoice(id) {
|
|||
}
|
||||
|
||||
function viewInvoicePDF(id) {
|
||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||
}
|
||||
|
||||
// *** FIX 2: Verbesserte Erfolgsmeldung mit QBO DocNumber ***
|
||||
async function exportToQBO(id) {
|
||||
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
|
||||
|
||||
// UI Feedback (einfach, aber wirksam)
|
||||
const btn = event.target; // Der geklickte Button
|
||||
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}`);
|
||||
// Liste neu laden um aktualisierte invoice_number anzuzeigen
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Fehler: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Netzwerkfehler beim Export.');
|
||||
} finally {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
if (window.invoiceView) {
|
||||
window.invoiceView.viewPDF(id);
|
||||
} else {
|
||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkQboOverdue() {
|
||||
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||
const resultDiv = document.getElementById('qbo-result');
|
||||
|
|
@ -1263,3 +1242,4 @@ async function importFromQBO() {
|
|||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
window.openInvoiceModal = openInvoiceModal;
|
||||
|
|
@ -67,13 +67,16 @@
|
|||
|
||||
<!-- Invoices Tab -->
|
||||
<div id="invoices-tab" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
||||
+ New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
||||
<div id="invoice-toolbar"></div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
|
|
@ -551,5 +554,6 @@
|
|||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script type="module" src="invoice-view-init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// invoice-view-init.js — Bootstrap-Script (type="module")
|
||||
// Wird in index.html als <script type="module"> geladen.
|
||||
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
|
||||
|
||||
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
||||
|
||||
// Warte bis DOM fertig ist
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Toolbar injizieren
|
||||
injectToolbar();
|
||||
|
||||
// Globale Funktionen für app.js verfügbar machen
|
||||
// (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird)
|
||||
window.loadInvoices = loadInvoices;
|
||||
window.renderInvoices = renderInvoiceView;
|
||||
|
||||
// Initiales Laden
|
||||
loadInvoices();
|
||||
}
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
// 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
|
||||
};
|
||||
49
server.js
49
server.js
|
|
@ -1534,6 +1534,55 @@ app.post('/api/qbo/import-unpaid', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Mark invoice as paid
|
||||
app.patch('/api/invoices/:id/mark-paid', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { paid_date } = req.body; // Optional: explizites Datum, sonst heute
|
||||
|
||||
try {
|
||||
const dateToUse = paid_date || new Date().toISOString().split('T')[0];
|
||||
const result = await pool.query(
|
||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||||
[dateToUse, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Invoice not found' });
|
||||
}
|
||||
|
||||
console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error marking invoice as paid:', error);
|
||||
res.status(500).json({ error: 'Error marking invoice as paid' });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark invoice as unpaid
|
||||
app.patch('/api/invoices/:id/mark-unpaid', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Invoice not found' });
|
||||
}
|
||||
|
||||
console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error marking invoice as unpaid:', error);
|
||||
res.status(500).json({ error: 'Error marking invoice as unpaid' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Start server and browser
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@
|
|||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-div {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
margin-top: auto;
|
||||
}
|
||||
.info-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
|
@ -110,7 +114,7 @@
|
|||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
margin: 40px 0 20px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
|
@ -238,24 +242,26 @@
|
|||
{{CUSTOMER_CITY}}, {{CUSTOMER_STATE}} {{CUSTOMER_ZIP}}
|
||||
</div>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
<th>TERMS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{INVOICE_NUMBER}}</td>
|
||||
<td>{{ACCOUNT_NUMBER}}</td>
|
||||
<td>{{INVOICE_DATE}}</td>
|
||||
<td>{{TERMS}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="info-div">
|
||||
<table class="info-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>INVOICE #</th>
|
||||
<th>ACCOUNT NO.</th>
|
||||
<th>DATE</th>
|
||||
<th>TERMS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{INVOICE_NUMBER}}</td>
|
||||
<td>{{ACCOUNT_NUMBER}}</td>
|
||||
<td>{{INVOICE_DATE}}</td>
|
||||
<td>{{TERMS}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{AUTHORIZATION}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue