This commit is contained in:
Andreas Knuth 2026-02-17 20:53:00 -06:00
parent 84b0836234
commit a0c62d639e
4 changed files with 191 additions and 99 deletions

View File

@ -817,7 +817,7 @@ async function openInvoiceModal(invoiceId = null) {
} }
} }
document.getElementById('invoice-number').value = data.invoice.invoice_number; document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
document.getElementById('invoice-customer').value = data.invoice.customer_id; document.getElementById('invoice-customer').value = data.invoice.customer_id;
const dateOnly = data.invoice.invoice_date.split('T')[0]; const dateOnly = data.invoice.invoice_date.split('T')[0];
document.getElementById('invoice-date').value = dateOnly; document.getElementById('invoice-date').value = dateOnly;
@ -825,6 +825,14 @@ async function openInvoiceModal(invoiceId = null) {
document.getElementById('invoice-authorization').value = data.invoice.auth_code || ''; document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt; document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
// Scheduled Send Date
const sendDateEl = document.getElementById('invoice-send-date');
if (sendDateEl) {
sendDateEl.value = data.invoice.scheduled_send_date
? data.invoice.scheduled_send_date.split('T')[0]
: '';
}
// Load items // Load items
document.getElementById('invoice-items').innerHTML = ''; document.getElementById('invoice-items').innerHTML = '';
itemCounter = 0; itemCounter = 0;
@ -838,11 +846,12 @@ async function openInvoiceModal(invoiceId = null) {
document.getElementById('invoice-form').reset(); document.getElementById('invoice-form').reset();
document.getElementById('invoice-items').innerHTML = ''; document.getElementById('invoice-items').innerHTML = '';
document.getElementById('invoice-terms').value = 'Net 30'; document.getElementById('invoice-terms').value = 'Net 30';
document.getElementById('invoice-number').value = ''; // Leer lassen!
document.getElementById('invoice-send-date').value = '';
itemCounter = 0; itemCounter = 0;
setDefaultDate(); setDefaultDate();
// Fetch next invoice number // KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO
fetchNextInvoiceNumber();
// Add one default item // Add one default item
addInvoiceItem(); addInvoiceItem();
@ -1068,39 +1077,49 @@ async function handleInvoiceSubmit(e) {
return; return;
} }
const invoiceNumber = document.getElementById('invoice-number').value; const invoiceNumber = document.getElementById('invoice-number').value.trim();
if (!invoiceNumber || !/^\d+$/.test(invoiceNumber)) { // Invoice Number ist jetzt OPTIONAL
alert('Invalid invoice number. Must be a numeric value.'); // Wenn angegeben, muss sie numerisch sein
if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) {
alert('Invalid invoice number. Must be a numeric value or left empty.');
return; return;
} }
const data = { const data = {
invoice_number: invoiceNumber, invoice_number: invoiceNumber || null, // null wenn leer
customer_id: parseInt(document.getElementById('invoice-customer').value), customer_id: parseInt(document.getElementById('invoice-customer').value),
invoice_date: document.getElementById('invoice-date').value, invoice_date: document.getElementById('invoice-date').value,
terms: document.getElementById('invoice-terms').value, terms: document.getElementById('invoice-terms').value,
auth_code: document.getElementById('invoice-authorization').value, auth_code: document.getElementById('invoice-authorization').value,
tax_exempt: document.getElementById('invoice-tax-exempt').checked, tax_exempt: document.getElementById('invoice-tax-exempt').checked,
scheduled_send_date: document.getElementById('invoice-send-date').value || null,
items: items items: items
}; };
try { try {
const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices'; let response;
const method = currentInvoiceId ? 'PUT' : 'POST'; if (currentInvoiceId) {
response = await fetch(`/api/invoices/${currentInvoiceId}`, {
const response = await fetch(url, { method: 'PUT',
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
} else {
response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (response.ok) { if (response.ok) {
closeInvoiceModal(); closeInvoiceModal();
loadInvoices(); loadInvoices();
} else { } else {
const error = await response.json(); alert(result.error || 'Error saving invoice');
alert(error.error || 'Error saving invoice');
} }
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);

View File

@ -81,12 +81,13 @@
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Send Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200"> <tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
@ -444,12 +445,13 @@
</div> </div>
<form id="invoice-form" class="space-y-6"> <form id="invoice-form" class="space-y-6">
<div class="grid grid-cols-5 gap-4"> <div class="grid grid-cols-6 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label> <label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
<input type="text" id="invoice-number" required pattern="[0-9]+" <input type="text" id="invoice-number" pattern="[0-9]*"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" placeholder="Auto (QBO)"
title="Must be a numeric value"> class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
title="Optional — wird beim QBO Export automatisch vergeben">
</div> </div>
<div x-data="customerSearch('invoice')" class="relative"> <div x-data="customerSearch('invoice')" class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label> <label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
@ -492,6 +494,12 @@
<input type="date" id="invoice-date" required <input type="date" id="invoice-date" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"> class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Send Date</label>
<input type="date" id="invoice-send-date"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
title="Wann soll die Rechnung versendet werden?">
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label> <label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
<input type="text" id="invoice-terms" value="Net 30" required <input type="text" id="invoice-terms" value="Net 30" required

View File

@ -1,6 +1,5 @@
// invoice-view.js — ES Module für die Invoice View // invoice-view.js — ES Module für die Invoice View
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter, // Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
// ============================================================ // ============================================================
// State // State
@ -17,6 +16,7 @@ const OVERDUE_DAYS = 30;
// ============================================================ // ============================================================
function formatDate(date) { function formatDate(date) {
if (!date) return '—';
const d = new Date(date); const d = new Date(date);
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');
@ -97,7 +97,6 @@ function getFilteredInvoices() {
} else if (filterStatus === 'overdue') { } else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv)); filtered = filtered.filter(inv => isOverdue(inv));
} }
// 'all' → kein Filter
// Customer Filter // Customer Filter
if (filterCustomer.trim()) { if (filterCustomer.trim()) {
@ -142,12 +141,10 @@ function groupInvoices(filtered) {
group.total += parseFloat(inv.total) || 0; group.total += parseFloat(inv.total) || 0;
}); });
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
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) => 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]))); return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
} }
@ -160,12 +157,17 @@ function renderInvoiceRow(invoice) {
const paid = isPaid(invoice); const paid = isPaid(invoice);
const overdue = isOverdue(invoice); const overdue = isOverdue(invoice);
// QBO Button // Invoice Number Display
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 const qboButton = hasQbo
? `<span class="text-gray-400 text-xs" title="Already in QBO (ID: ${invoice.qbo_id})">✓ QBO</span>` ? `<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>`; : `<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 // Paid/Unpaid Toggle
const paidButton = paid 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.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>`; : `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
@ -175,28 +177,50 @@ function renderInvoiceRow(invoice) {
if (paid) { 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>`; 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) { } 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>`; 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 = new Date(invoice.scheduled_send_date);
const today = new Date();
today.setHours(0,0,0,0);
sendDate.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>`;
}
}
} }
// Row styling
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 `
<tr class="${rowClass}"> <tr class="${rowClass}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${invoice.invoice_number} ${statusBadge} ${invNumDisplay} ${statusBadge}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td> <td class="px-4 py-3 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-4 py-3 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-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</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-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> <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.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> <button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>
${qboButton} ${qboButton}
${paidButton} ${paidButton}
<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button> <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> <button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>
</td> </td>
</tr> </tr>
`; `;
@ -205,7 +229,7 @@ function renderInvoiceRow(invoice) {
function renderGroupHeader(label) { function renderGroupHeader(label) {
return ` return `
<tr class="bg-blue-50"> <tr class="bg-blue-50">
<td colspan="6" class="px-6 py-3 text-sm font-bold text-blue-800"> <td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">
📅 ${label} 📅 ${label}
</td> </td>
</tr> </tr>
@ -215,8 +239,8 @@ function renderGroupHeader(label) {
function renderGroupFooter(total, count) { function renderGroupFooter(total, count) {
return ` return `
<tr class="bg-gray-50 border-t-2 border-gray-300"> <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 colspan="5" class="px-4 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 class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
<td></td> <td></td>
</tr> </tr>
`; `;
@ -245,8 +269,8 @@ export function renderInvoiceView() {
if (groups.size > 1) { if (groups.size > 1) {
html += ` html += `
<tr class="bg-blue-100 border-t-4 border-blue-400"> <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 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-6 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td> <td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
<td></td> <td></td>
</tr> </tr>
`; `;
@ -260,8 +284,8 @@ export function renderInvoiceView() {
if (filtered.length > 0) { if (filtered.length > 0) {
html += ` html += `
<tr class="bg-gray-100 border-t-2 border-gray-300"> <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 colspan="5" class="px-4 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 class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<td></td> <td></td>
</tr> </tr>
`; `;
@ -269,16 +293,14 @@ export function renderInvoiceView() {
} }
if (filtered.length === 0) { if (filtered.length === 0) {
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`; html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
} }
tbody.innerHTML = html; tbody.innerHTML = html;
// Update count badge
const countEl = document.getElementById('invoice-count'); const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length; if (countEl) countEl.textContent = filtered.length;
// Update status button active states
updateStatusButtons(); updateStatusButtons();
} }
@ -294,7 +316,6 @@ function updateStatusButtons() {
} }
}); });
// Update overdue count badge
const overdueCount = invoices.filter(inv => isOverdue(inv)).length; const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
const overdueBadge = document.getElementById('overdue-badge'); const overdueBadge = document.getElementById('overdue-badge');
if (overdueBadge) { if (overdueBadge) {
@ -306,7 +327,6 @@ function updateStatusButtons() {
} }
} }
// Update unpaid count
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) {
@ -315,7 +335,7 @@ function updateStatusButtons() {
} }
// ============================================================ // ============================================================
// Toolbar HTML // Toolbar
// ============================================================ // ============================================================
export function injectToolbar() { export function injectToolbar() {
@ -377,7 +397,6 @@ export function injectToolbar() {
</div> </div>
`; `;
// Event Listeners
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => { document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value; filterCustomer = e.target.value;
renderInvoiceView(); renderInvoiceView();
@ -433,6 +452,22 @@ 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) {
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) { export async function markPaid(id) {
try { try {
const response = await fetch(`/api/invoices/${id}/mark-paid`, { const response = await fetch(`/api/invoices/${id}/mark-paid`, {
@ -493,6 +528,7 @@ window.invoiceView = {
viewPDF, viewPDF,
viewHTML, viewHTML,
exportToQBO, exportToQBO,
resetQbo,
markPaid, markPaid,
markUnpaid, markUnpaid,
edit, edit,

View File

@ -457,19 +457,20 @@ app.get('/api/invoices/:id', async (req, res) => {
}); });
app.post('/api/invoices', async (req, res) => { app.post('/api/invoices', async (req, res) => {
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id } = req.body; const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body;
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
// Validate invoice_number is provided and is numeric // invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
if (!invoice_number || !/^\d+$/.test(invoice_number)) { // Wenn angegeben, muss sie numerisch sein und darf nicht existieren
if (invoice_number && invoice_number.trim() !== '') {
if (!/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
} }
// Check if invoice number already exists
const existingInvoice = await client.query( const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1', 'SELECT id FROM invoices WHERE invoice_number = $1',
[invoice_number] [invoice_number]
@ -479,9 +480,9 @@ app.post('/api/invoices', async (req, res) => {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
} }
}
let subtotal = 0; let subtotal = 0;
for (const item of items) { for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')); const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) { if (!isNaN(amount)) {
@ -493,19 +494,22 @@ app.post('/api/invoices', async (req, res) => {
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount; const total = subtotal + tax_amount;
// invoice_number kann NULL sein
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
const sendDate = scheduled_send_date || null;
const invoiceResult = await client.query( const invoiceResult = await client.query(
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id) `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, scheduled_send_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id] [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate]
); );
const invoiceId = invoiceResult.rows[0].id; const invoiceId = invoiceResult.rows[0].id;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
await client.query( await client.query(
// qbo_item_id hinzugefügt
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', 'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] // Default '9' (Parts) [invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
); );
} }
@ -556,7 +560,7 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' }); return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
} }
const invoice_number = await getNextInvoiceNumber(); const invoice_number = null;
const invoiceDate = new Date().toISOString().split('T')[0]; const invoiceDate = new Date().toISOString().split('T')[0];
const invoiceResult = await client.query( const invoiceResult = await client.query(
@ -588,31 +592,32 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
app.put('/api/invoices/:id', async (req, res) => { app.put('/api/invoices/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body; const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date } = req.body;
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
// Validate invoice_number is provided and is numeric // invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
if (!invoice_number || !/^\d+$/.test(invoice_number)) { const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
if (invNum && !/^\d+$/.test(invNum)) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' }); return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
} }
// Check if invoice number already exists (excluding current invoice) if (invNum) {
const existingInvoice = await client.query( const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', 'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
[invoice_number, id] [invNum, id]
); );
if (existingInvoice.rows.length > 0) { if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); return res.status(400).json({ error: `Invoice number ${invNum} already exists.` });
}
} }
let subtotal = 0; let subtotal = 0;
for (const item of items) { for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')); const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) { if (!isNaN(amount)) {
@ -623,12 +628,13 @@ app.put('/api/invoices/:id', async (req, res) => {
const tax_rate = 8.25; const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount; const total = subtotal + tax_amount;
const sendDate = scheduled_send_date || null;
await client.query( await client.query(
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6, `UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, updated_at = CURRENT_TIMESTAMP tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
WHERE id = $11`, WHERE id = $12`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id] [invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id]
); );
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
@ -651,6 +657,7 @@ app.put('/api/invoices/:id', async (req, res) => {
} }
}); });
app.delete('/api/invoices/:id', async (req, res) => { app.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const client = await pool.connect(); const client = await pool.connect();
@ -1580,7 +1587,29 @@ app.patch('/api/invoices/:id/mark-unpaid', async (req, res) => {
} }
}); });
app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE invoices
SET qbo_id = NULL, qbo_sync_token = NULL, qbo_doc_number = NULL, invoice_number = 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 ID ${id} QBO-Verknüpfung zurückgesetzt`);
res.json(result.rows[0]);
} catch (error) {
console.error('Error resetting QBO link:', error);
res.status(500).json({ error: 'Error resetting QBO link' });
}
});