diff --git a/public/invoice-view.js b/public/invoice-view.js index 5ac9782..3ebd91c 100644 --- a/public/invoice-view.js +++ b/public/invoice-view.js @@ -239,6 +239,17 @@ function renderInvoiceRow(invoice) { paidBtn = ``; } + // Send status button — only for QBO invoices + let sendBtn = ''; + if (hasQbo && !paid) { + const isSent = invoice.email_status === 'sent'; + if (isSent) { + sendBtn = ``; + } else { + sendBtn = ``; + } + } + const delBtn = ``; const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : ''; @@ -252,7 +263,7 @@ function renderInvoiceRow(invoice) { ${invoice.terms} ${amountDisplay} - ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${paidBtn} ${delBtn} + ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn} `; } @@ -440,6 +451,23 @@ export async function syncFromQBO() { finally { if (typeof hideSpinner === 'function') hideSpinner(); } } +export async function setEmailStatus(id, status) { + const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent'; + if (!confirm(`${label}?`)) return; + if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`); + try { + const r = await fetch(`/api/invoices/${id}/email-status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + const d = await r.json(); + if (r.ok) loadInvoices(); + else alert(`❌ ${d.error}`); + } catch (e) { alert('Network error.'); } + finally { if (typeof hideSpinner === 'function') hideSpinner(); } +} + export async function resetQbo(id) { if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return; try { @@ -471,6 +499,6 @@ export async function remove(id) { // ============================================================ window.invoiceView = { - viewPDF, viewHTML, exportToQBO, syncToQBO, syncFromQBO, resetQbo, markPaid, edit, remove, + viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove, loadInvoices, renderInvoiceView, setStatus }; \ No newline at end of file diff --git a/server.js b/server.js index 3dd3f70..56d59f0 100644 --- a/server.js +++ b/server.js @@ -177,7 +177,7 @@ async function exportInvoiceToQbo(invoiceId, client) { "TxnDate": invoice.invoice_date.toISOString().split('T')[0], "Line": lineItems, "CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }, - "EmailStatus": "EmailSent", + "EmailStatus": "NotSet", "BillEmail": { "Address": invoice.email || "" } }; @@ -292,7 +292,6 @@ async function syncInvoiceToQbo(invoiceId, client) { } - // Logo endpoints app.get('/api/logo-info', async (req, res) => { try { @@ -833,6 +832,7 @@ app.post('/api/invoices', async (req, res) => { } }); + app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { const { id } = req.params; @@ -1062,6 +1062,69 @@ app.delete('/api/invoices/:id', async (req, res) => { } }); +app.patch('/api/invoices/:id/email-status', async (req, res) => { + const { id } = req.params; + const { status } = req.body; // 'sent' or 'open' + + if (!['sent', 'open'].includes(status)) { + return res.status(400).json({ error: 'Status must be "sent" or "open".' }); + } + + try { + const invResult = await pool.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]); + if (invResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' }); + + const invoice = invResult.rows[0]; + + // QBO updaten falls vorhanden + if (invoice.qbo_id) { + const oauthClient = getOAuthClient(); + const companyId = oauthClient.getToken().realmId; + const baseUrl = process.env.QBO_ENVIRONMENT === 'production' + ? 'https://quickbooks.api.intuit.com' + : 'https://sandbox-quickbooks.api.intuit.com'; + + // SyncToken holen + const qboRes = await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`, + method: 'GET' + }); + const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json; + const syncToken = qboData.Invoice?.SyncToken; + + if (syncToken !== undefined) { + const emailStatus = status === 'sent' ? 'EmailSent' : 'NotSet'; + + await makeQboApiCall({ + url: `${baseUrl}/v3/company/${companyId}/invoice`, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Id: invoice.qbo_id, + SyncToken: syncToken, + sparse: true, + EmailStatus: emailStatus + }) + }); + + console.log(`✅ QBO Invoice ${invoice.qbo_id} email status → ${emailStatus}`); + } + } + + // Lokal updaten + await pool.query( + 'UPDATE invoices SET email_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [status, id] + ); + + res.json({ success: true, status }); + + } catch (error) { + console.error('Error updating email status:', error); + res.status(500).json({ error: 'Failed to update status: ' + error.message }); + } +}); + // PDF Generation code continues below... // PDF Generation using templates and persistent browser