update
This commit is contained in:
parent
84b0836234
commit
a0c62d639e
|
|
@ -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;
|
||||
const dateOnly = data.invoice.invoice_date.split('T')[0];
|
||||
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-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
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
|
|
@ -838,11 +846,12 @@ async function openInvoiceModal(invoiceId = null) {
|
|||
document.getElementById('invoice-form').reset();
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
document.getElementById('invoice-terms').value = 'Net 30';
|
||||
document.getElementById('invoice-number').value = ''; // Leer lassen!
|
||||
document.getElementById('invoice-send-date').value = '';
|
||||
itemCounter = 0;
|
||||
setDefaultDate();
|
||||
|
||||
// Fetch next invoice number
|
||||
fetchNextInvoiceNumber();
|
||||
// KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO
|
||||
|
||||
// Add one default item
|
||||
addInvoiceItem();
|
||||
|
|
@ -1068,39 +1077,49 @@ async function handleInvoiceSubmit(e) {
|
|||
return;
|
||||
}
|
||||
|
||||
const invoiceNumber = document.getElementById('invoice-number').value;
|
||||
const invoiceNumber = document.getElementById('invoice-number').value.trim();
|
||||
|
||||
if (!invoiceNumber || !/^\d+$/.test(invoiceNumber)) {
|
||||
alert('Invalid invoice number. Must be a numeric value.');
|
||||
// Invoice Number ist jetzt OPTIONAL
|
||||
// Wenn angegeben, muss sie numerisch sein
|
||||
if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) {
|
||||
alert('Invalid invoice number. Must be a numeric value or left empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_number: invoiceNumber || null, // null wenn leer
|
||||
customer_id: parseInt(document.getElementById('invoice-customer').value),
|
||||
invoice_date: document.getElementById('invoice-date').value,
|
||||
terms: document.getElementById('invoice-terms').value,
|
||||
auth_code: document.getElementById('invoice-authorization').value,
|
||||
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
|
||||
scheduled_send_date: document.getElementById('invoice-send-date').value || null,
|
||||
items: items
|
||||
};
|
||||
|
||||
try {
|
||||
const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices';
|
||||
const method = currentInvoiceId ? 'PUT' : 'POST';
|
||||
let response;
|
||||
if (currentInvoiceId) {
|
||||
response = await fetch(`/api/invoices/${currentInvoiceId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
loadInvoices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Error saving invoice');
|
||||
alert(result.error || 'Error saving invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<!-- Invoices Tab -->
|
||||
<div id="invoices-tab" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||
|
|
@ -81,12 +81,13 @@
|
|||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 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-6 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-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</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">Invoice #</th>
|
||||
<th class="px-4 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">Date</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-4 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">Total</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
|
||||
|
|
@ -444,12 +445,13 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
||||
<input type="text" id="invoice-number" required pattern="[0-9]+"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
title="Must be a numeric value">
|
||||
<input type="text" id="invoice-number" pattern="[0-9]*"
|
||||
placeholder="Auto (QBO)"
|
||||
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 x-data="customerSearch('invoice')" class="relative">
|
||||
<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
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
|
||||
<input type="text" id="invoice-terms" value="Net 30" required
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// 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
|
||||
// Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
|
|
@ -17,6 +16,7 @@ const OVERDUE_DAYS = 30;
|
|||
// ============================================================
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '—';
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
|
@ -97,7 +97,6 @@ function getFilteredInvoices() {
|
|||
} else if (filterStatus === 'overdue') {
|
||||
filtered = filtered.filter(inv => isOverdue(inv));
|
||||
}
|
||||
// 'all' → kein Filter
|
||||
|
||||
// Customer Filter
|
||||
if (filterCustomer.trim()) {
|
||||
|
|
@ -142,12 +141,10 @@ function groupInvoices(filtered) {
|
|||
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])));
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +157,17 @@ function renderInvoiceRow(invoice) {
|
|||
const paid = isPaid(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
|
||||
? `<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>`;
|
||||
|
||||
// Paid/Unpaid Toggle Button
|
||||
// Paid/Unpaid Toggle
|
||||
const paidButton = paid
|
||||
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
|
||||
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
|
||||
|
|
@ -175,28 +177,50 @@ function renderInvoiceRow(invoice) {
|
|||
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>`;
|
||||
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' : '';
|
||||
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
${invoice.invoice_number} ${statusBadge}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
${invNumDisplay} ${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">
|
||||
<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">
|
||||
<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>
|
||||
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
|
@ -205,7 +229,7 @@ function renderInvoiceRow(invoice) {
|
|||
function renderGroupHeader(label) {
|
||||
return `
|
||||
<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}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -215,8 +239,8 @@ function renderGroupHeader(label) {
|
|||
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 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>
|
||||
`;
|
||||
|
|
@ -245,8 +269,8 @@ export function renderInvoiceView() {
|
|||
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 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>
|
||||
`;
|
||||
|
|
@ -260,8 +284,8 @@ export function renderInvoiceView() {
|
|||
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 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>
|
||||
`;
|
||||
|
|
@ -269,16 +293,14 @@ export function renderInvoiceView() {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
// Update count badge
|
||||
const countEl = document.getElementById('invoice-count');
|
||||
if (countEl) countEl.textContent = filtered.length;
|
||||
|
||||
// Update status button active states
|
||||
updateStatusButtons();
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +316,6 @@ function updateStatusButtons() {
|
|||
}
|
||||
});
|
||||
|
||||
// Update overdue count badge
|
||||
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
|
||||
const overdueBadge = document.getElementById('overdue-badge');
|
||||
if (overdueBadge) {
|
||||
|
|
@ -306,7 +327,6 @@ function updateStatusButtons() {
|
|||
}
|
||||
}
|
||||
|
||||
// Update unpaid count
|
||||
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
|
||||
const unpaidBadge = document.getElementById('unpaid-badge');
|
||||
if (unpaidBadge) {
|
||||
|
|
@ -315,7 +335,7 @@ function updateStatusButtons() {
|
|||
}
|
||||
|
||||
// ============================================================
|
||||
// Toolbar HTML
|
||||
// Toolbar
|
||||
// ============================================================
|
||||
|
||||
export function injectToolbar() {
|
||||
|
|
@ -377,7 +397,6 @@ export function injectToolbar() {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
||||
filterCustomer = e.target.value;
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
|
||||
|
|
@ -493,6 +528,7 @@ window.invoiceView = {
|
|||
viewPDF,
|
||||
viewHTML,
|
||||
exportToQBO,
|
||||
resetQbo,
|
||||
markPaid,
|
||||
markUnpaid,
|
||||
edit,
|
||||
|
|
|
|||
103
server.js
103
server.js
|
|
@ -457,31 +457,32 @@ app.get('/api/invoices/:id', 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();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Validate invoice_number is provided and is numeric
|
||||
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
||||
}
|
||||
// invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
|
||||
// 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');
|
||||
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(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1',
|
||||
[invoice_number]
|
||||
);
|
||||
const existingInvoice = await client.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1',
|
||||
[invoice_number]
|
||||
);
|
||||
|
||||
if (existingInvoice.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||
if (existingInvoice.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||
}
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
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 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(
|
||||
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[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, $12) RETURNING *`,
|
||||
[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;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
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)',
|
||||
[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.' });
|
||||
}
|
||||
|
||||
const invoice_number = await getNextInvoiceNumber();
|
||||
const invoice_number = null;
|
||||
const invoiceDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
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) => {
|
||||
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();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Validate invoice_number is provided and is numeric
|
||||
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
|
||||
// invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
|
||||
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
|
||||
|
||||
if (invNum && !/^\d+$/.test(invNum)) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
||||
}
|
||||
|
||||
// Check if invoice number already exists (excluding current invoice)
|
||||
const existingInvoice = await client.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
|
||||
[invoice_number, id]
|
||||
);
|
||||
|
||||
if (existingInvoice.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||
if (invNum) {
|
||||
const existingInvoice = await client.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
|
||||
[invNum, id]
|
||||
);
|
||||
if (existingInvoice.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: `Invoice number ${invNum} already exists.` });
|
||||
}
|
||||
}
|
||||
|
||||
let subtotal = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||
if (!isNaN(amount)) {
|
||||
|
|
@ -623,12 +628,13 @@ app.put('/api/invoices/:id', async (req, res) => {
|
|||
const tax_rate = 8.25;
|
||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||
const total = subtotal + tax_amount;
|
||||
const sendDate = scheduled_send_date || null;
|
||||
|
||||
await client.query(
|
||||
`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
|
||||
WHERE id = $11`,
|
||||
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id]
|
||||
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $12`,
|
||||
[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]);
|
||||
|
|
@ -651,6 +657,7 @@ app.put('/api/invoices/:id', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
app.delete('/api/invoices/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue