-
+
@@ -492,6 +494,12 @@
+
+
+
+
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
+ : `Draft`;
+
+ // QBO Button — if already in QBO, show checkmark + optional reset
const qboButton = hasQbo
- ? `✓ QBO`
+ ? `✓ QBO`
: ``;
- // Paid/Unpaid Toggle Button
+ // Paid/Unpaid Toggle
const paidButton = paid
? ``
: ``;
@@ -175,28 +177,50 @@ function renderInvoiceRow(invoice) {
if (paid) {
statusBadge = `Paid`;
} else if (overdue) {
- statusBadge = `Overdue`;
+ statusBadge = `Overdue`;
+ }
+
+ // 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 += ` (${Math.abs(daysUntil)}d ago)`;
+ } else if (daysUntil === 0) {
+ sendDateDisplay += ` (today)`;
+ } else if (daysUntil <= 3) {
+ sendDateDisplay += ` (in ${daysUntil}d)`;
+ }
+ }
}
- // Row styling
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return `
- |
- ${invoice.invoice_number} ${statusBadge}
+ |
+ ${invNumDisplay} ${statusBadge}
|
- ${invoice.customer_name || 'N/A'} |
- ${formatDate(invoice.invoice_date)} |
- ${invoice.terms} |
- $${parseFloat(invoice.total).toFixed(2)} |
-
+ | ${invoice.customer_name || 'N/A'} |
+ ${formatDate(invoice.invoice_date)} |
+ ${sendDateDisplay} |
+ ${invoice.terms} |
+ $${parseFloat(invoice.total).toFixed(2)} |
+
${qboButton}
${paidButton}
-
+
|
`;
@@ -205,7 +229,7 @@ function renderInvoiceRow(invoice) {
function renderGroupHeader(label) {
return `
- |
+ |
📅 ${label}
|
@@ -215,8 +239,8 @@ function renderGroupHeader(label) {
function renderGroupFooter(total, count) {
return `
- | Group Total (${count} invoices): |
- $${total.toFixed(2)} |
+ Group Total (${count} invoices): |
+ $${total.toFixed(2)} |
|
`;
@@ -245,8 +269,8 @@ export function renderInvoiceView() {
if (groups.size > 1) {
html += `
- | Grand Total (${filtered.length} invoices): |
- $${grandTotal.toFixed(2)} |
+ Grand Total (${filtered.length} invoices): |
+ $${grandTotal.toFixed(2)} |
|
`;
@@ -260,8 +284,8 @@ export function renderInvoiceView() {
if (filtered.length > 0) {
html += `
- | Total (${filtered.length} invoices): |
- $${grandTotal.toFixed(2)} |
+ Total (${filtered.length} invoices): |
+ $${grandTotal.toFixed(2)} |
|
`;
@@ -269,16 +293,14 @@ export function renderInvoiceView() {
}
if (filtered.length === 0) {
- html = `| No invoices found. |
`;
+ html = `| No invoices found. |
`;
}
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() {
`;
- // 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,
diff --git a/server.js b/server.js
index 8818e1e..2e1dff5 100644
--- a/server.js
+++ b/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.' });
- }
-
- // Check if invoice number already exists
- 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.` });
+ // 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.' });
+ }
+
+ 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.` });
+ }
}
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' });
+ }
+});