// Global state let customers = window.customers || []; let quotes = []; let invoices = []; let currentQuoteId = null; let currentInvoiceId = null; let currentCustomerId = null; let itemCounter = 0; let currentLogoFile = null; // Alpine.js Customer Search Component function customerSearch(type) { return { search: '', selectedId: '', selectedName: '', open: false, highlighted: 0, get filteredCustomers() { // Wenn Suche leer ist: ALLE Kunden zurückgeben (kein .slice mehr!) if (!this.search) { return (window.getCustomers ? window.getCustomers() : customers); } const searchLower = this.search.toLowerCase(); // Filtern: Sucht im Namen, Line1, Stadt oder Account Nummer // Auch hier: Kein .slice mehr, damit alle Ergebnisse (z.B. alle mit 'C') angezeigt werden return (window.getCustomers ? window.getCustomers() : customers).filter(c => (c.name || '').toLowerCase().includes(searchLower) || (c.line1 || '').toLowerCase().includes(searchLower) || (c.city || '').toLowerCase().includes(searchLower) || (c.account_number && c.account_number.includes(searchLower)) ); }, selectCustomer(customer) { this.selectedId = customer.id; this.selectedName = customer.name; this.search = customer.name; this.open = false; this.highlighted = 0; }, highlightNext() { if (this.highlighted < this.filteredCustomers.length - 1) { this.highlighted++; } }, highlightPrev() { if (this.highlighted > 0) { this.highlighted--; } }, selectHighlighted() { if (this.filteredCustomers[this.highlighted]) { this.selectCustomer(this.filteredCustomers[this.highlighted]); } }, reset() { this.search = ''; this.selectedId = ''; this.selectedName = ''; this.open = false; this.highlighted = 0; } }; } // Make it globally available for Alpine window.customerSearch = customerSearch; // Initialize app document.addEventListener('DOMContentLoaded', () => { loadQuotes(); setDefaultDate(); checkCurrentLogo(); loadLaborRate(); // *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) *** const savedTab = localStorage.getItem('activeTab') || 'quotes'; showTab(savedTab); // Hash-basierte Tab-Navigation (z.B. nach OAuth Redirect /#settings) if (window.location.hash) { const hashTab = window.location.hash.replace('#', ''); if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) { showTab(hashTab); } } // Setup form handlers document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit); document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit); document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals); document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals); // Setup logo upload handler document.getElementById('logo-upload').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { currentLogoFile = file; document.getElementById('logo-filename').textContent = file.name; document.getElementById('upload-btn').disabled = false; } }); }); // Tab Management function showTab(tabName) { document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden')); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800')); document.getElementById(`${tabName}-tab`).classList.remove('hidden'); document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800'); // *** FIX 3: Tab-Auswahl persistieren *** localStorage.setItem('activeTab', tabName); if (tabName === 'quotes') { loadQuotes(); } else if (tabName === 'invoices') { loadInvoices(); } else if (tabName === 'customers') { if (window.customerView) { window.customerView.loadCustomers(); } } else if (tabName === 'settings') { checkCurrentLogo(); } } // Date helper function setDefaultDate() { const today = new Date().toISOString().split('T')[0]; const quoteDateEl = document.getElementById('quote-date'); const invoiceDateEl = document.getElementById('invoice-date'); if (quoteDateEl) quoteDateEl.value = today; if (invoiceDateEl) invoiceDateEl.value = today; } 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}`; } // Logo Management async function checkCurrentLogo() { try { const response = await fetch('/api/logo-info'); if (response.ok) { const data = await response.json(); if (data.hasLogo) { document.getElementById('logo-preview').classList.remove('hidden'); document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now(); } } } catch (error) { console.error('Error checking logo:', error); } } async function uploadLogo() { if (!currentLogoFile) { alert('Please select a file first'); return; } const formData = new FormData(); formData.append('logo', currentLogoFile); const statusDiv = document.getElementById('upload-status'); statusDiv.innerHTML = '

Uploading...

'; try { const response = await fetch('/api/upload-logo', { method: 'POST', body: formData }); if (response.ok) { const data = await response.json(); statusDiv.innerHTML = '

✓ Logo uploaded successfully!

'; document.getElementById('logo-preview').classList.remove('hidden'); document.getElementById('logo-image').src = data.path + '?t=' + Date.now(); document.getElementById('upload-btn').disabled = true; currentLogoFile = null; document.getElementById('logo-filename').textContent = ''; document.getElementById('logo-upload').value = ''; } else { const error = await response.json(); statusDiv.innerHTML = `

✗ Error: ${error.error}

`; } } catch (error) { console.error('Upload error:', error); statusDiv.innerHTML = '

✗ Upload failed

'; } } // Quote Management async function loadQuotes() { try { const response = await fetch('/api/quotes'); quotes = await response.json(); renderQuotes(); } catch (error) { console.error('Error loading quotes:', error); alert('Error loading quotes'); } } function renderQuotes() { const tbody = document.getElementById('quotes-list'); tbody.innerHTML = quotes.map(quote => { const total = quote.has_tbd ? `$${parseFloat(quote.total).toFixed(2)}*` : `$${parseFloat(quote.total).toFixed(2)}`; return ` ${quote.quote_number} ${quote.customer_name || 'N/A'} ${formatDate(quote.quote_date)} ${total} `; }).join(''); } async function openQuoteModal(quoteId = null) { currentQuoteId = quoteId; const modal = document.getElementById('quote-modal'); const title = document.getElementById('quote-modal-title'); if (quoteId) { title.textContent = 'Edit Quote'; const response = await fetch(`/api/quotes/${quoteId}`); const data = await response.json(); // Set customer in Alpine component const customer = customers.find(c => c.id === data.quote.customer_id); if (customer) { // Find the Alpine component and update it const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]'); if (customerInput) { // Trigger Alpine to update customerInput.value = customer.name; customerInput.dispatchEvent(new Event('input')); // Set the values directly on the Alpine component const alpineData = Alpine.$data(customerInput.closest('[x-data]')); if (alpineData) { alpineData.search = customer.name; alpineData.selectedId = customer.id; alpineData.selectedName = customer.name; } } } document.getElementById('quote-customer').value = data.quote.customer_id; const dateOnly = data.quote.quote_date.split('T')[0]; document.getElementById('quote-date').value = dateOnly; document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt; // Load items document.getElementById('quote-items').innerHTML = ''; itemCounter = 0; data.items.forEach(item => { addQuoteItem(item); }); updateQuoteTotals(); } else { title.textContent = 'New Quote'; document.getElementById('quote-form').reset(); document.getElementById('quote-items').innerHTML = ''; itemCounter = 0; setDefaultDate(); // Add one default item addQuoteItem(); } modal.classList.add('active'); } function closeQuoteModal() { document.getElementById('quote-modal').classList.remove('active'); currentQuoteId = null; } function addQuoteItem(item = null) { const itemId = itemCounter++; const itemsDiv = document.getElementById('quote-items'); const itemDiv = document.createElement('div'); itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white'; itemDiv.id = `quote-item-${itemId}`; itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`); // Preview Text logic const previewQty = item ? item.quantity : ''; const previewAmount = item ? item.amount : '$0.00'; let previewDesc = 'New item'; if (item && item.description) { const temp = document.createElement('div'); temp.innerHTML = item.description; previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : ''); } // Preview Type Logic (NEU) const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts'; itemDiv.innerHTML = `
Qty: ${previewQty} ${typeLabel} ${previewDesc} ${previewAmount}
`; itemsDiv.appendChild(itemDiv); // Quill Init const editorDiv = itemDiv.querySelector('.quote-item-description-editor'); const quill = new Quill(editorDiv, { theme: 'snow', modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] } }); if (item && item.description) quill.root.innerHTML = item.description; quill.on('text-change', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); }); editorDiv.quillInstance = quill; // Auto-calculate logic const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const rateInput = itemDiv.querySelector('[data-field="rate"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const calculateAmount = () => { if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') { const qty = parseFloat(qtyInput.value) || 0; const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; amountInput.value = (qty * rateValue).toFixed(2); } updateItemPreview(itemDiv, itemId); updateQuoteTotals(); }; qtyInput.addEventListener('input', calculateAmount); rateInput.addEventListener('input', calculateAmount); amountInput.addEventListener('input', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); }); updateItemPreview(itemDiv, itemId); updateQuoteTotals(); } function updateItemPreview(itemDiv, itemId) { const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU const editorDiv = itemDiv.querySelector('.quote-item-description-editor'); const qtyPreview = itemDiv.querySelector('.item-qty-preview'); const descPreview = itemDiv.querySelector('.item-desc-preview'); const amountPreview = itemDiv.querySelector('.item-amount-preview'); const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0'; if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00'; // NEU: Update Type Label if (typePreview && typeInput) { typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts'; } if (descPreview && editorDiv.quillInstance) { const plainText = editorDiv.quillInstance.getText().trim(); const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : ''); descPreview.textContent = preview || 'New item'; } } function removeQuoteItem(itemId) { document.getElementById(`quote-item-${itemId}`).remove(); updateQuoteTotals(); } function moveQuoteItemUp(itemId) { const item = document.getElementById(`quote-item-${itemId}`); const prevItem = item.previousElementSibling; if (prevItem) { item.parentNode.insertBefore(item, prevItem); updateQuoteTotals(); } } function moveQuoteItemDown(itemId) { const item = document.getElementById(`quote-item-${itemId}`); const nextItem = item.nextElementSibling; if (nextItem) { item.parentNode.insertBefore(nextItem, item); updateQuoteTotals(); } } function updateQuoteTotals() { const items = getQuoteItems(); const taxExempt = document.getElementById('quote-tax-exempt').checked; let subtotal = 0; let hasTbd = false; items.forEach(item => { if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') { hasTbd = true; } else { const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0; subtotal += amount; } }); const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100); const total = subtotal + taxAmount; document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`; document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`; document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block'; } function getQuoteItems() { const items = []; const itemDivs = document.querySelectorAll('#quote-items > div'); itemDivs.forEach(div => { const descEditor = div.querySelector('.quote-item-description-editor'); const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : ''; const item = { quantity: div.querySelector('[data-field="quantity"]').value, // NEU: ID holen qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value, description: descriptionHTML, rate: div.querySelector('[data-field="rate"]').value, amount: div.querySelector('[data-field="amount"]').value }; items.push(item); }); return items; } async function handleQuoteSubmit(e) { e.preventDefault(); const items = getQuoteItems(); if (items.length === 0) { alert('Please add at least one item'); return; } const data = { customer_id: parseInt(document.getElementById('quote-customer').value), quote_date: document.getElementById('quote-date').value, tax_exempt: document.getElementById('quote-tax-exempt').checked, items: items }; try { const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes'; const method = currentQuoteId ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (response.ok) { closeQuoteModal(); loadQuotes(); } else { alert('Error saving quote'); } } catch (error) { console.error('Error:', error); alert('Error saving quote'); } } async function editQuote(id) { await openQuoteModal(id); } async function deleteQuote(id) { if (!confirm('Are you sure you want to delete this quote?')) return; try { const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' }); if (response.ok) { loadQuotes(); } else { alert('Error deleting quote'); } } catch (error) { console.error('Error:', error); alert('Error deleting quote'); } } function viewQuotePDF(id) { window.open(`/api/quotes/${id}/pdf`, '_blank'); } async function convertQuoteToInvoice(quoteId) { if (!confirm('Convert this quote to an invoice?')) return; try { const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, { method: 'POST' }); if (response.ok) { const invoice = await response.json(); alert(`Invoice ${invoice.invoice_number} created successfully!`); loadInvoices(); showTab('invoices'); } else { const error = await response.json(); alert(error.error || 'Error converting quote to invoice'); } } catch (error) { console.error('Error:', error); alert('Error converting quote to invoice'); } } // Invoice Management - Same accordion pattern async function fetchNextInvoiceNumber() { try { const response = await fetch('/api/invoices/next-number'); const data = await response.json(); document.getElementById('invoice-number').value = data.next_number; } catch (error) { console.error('Error fetching next invoice number:', error); } } 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(); // Falls das Modul geladen ist, nutze dessen Renderer if (window.invoiceView) { window.invoiceView.renderInvoiceView(); } else { renderInvoices(); } } catch (error) { console.error('Error loading invoices:', error); } } 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 => ` ${invoice.invoice_number} ${invoice.customer_name || 'N/A'} ${formatDate(invoice.invoice_date)} ${invoice.terms} $${parseFloat(invoice.total).toFixed(2)} Loading module... `).join(''); } async function openInvoiceModal(invoiceId = null) { currentInvoiceId = invoiceId; const modal = document.getElementById('invoice-modal'); const title = document.getElementById('invoice-modal-title'); if (invoiceId) { title.textContent = 'Edit Invoice'; const response = await fetch(`/api/invoices/${invoiceId}`); const data = await response.json(); // Set customer in Alpine component const customer = customers.find(c => c.id === data.invoice.customer_id); if (customer) { const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]'); if (customerInput) { customerInput.value = customer.name; customerInput.dispatchEvent(new Event('input')); const alpineData = Alpine.$data(customerInput.closest('[x-data]')); if (alpineData) { alpineData.search = customer.name; alpineData.selectedId = customer.id; alpineData.selectedName = customer.name; } } } 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; document.getElementById('invoice-terms').value = data.invoice.terms; 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; data.items.forEach(item => { addInvoiceItem(item); }); updateInvoiceTotals(); } else { title.textContent = 'New Invoice'; 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(); // KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO // Add one default item addInvoiceItem(); } modal.classList.add('active'); } function closeInvoiceModal() { document.getElementById('invoice-modal').classList.remove('active'); currentInvoiceId = null; } function addInvoiceItem(item = null) { const itemId = itemCounter++; const itemsDiv = document.getElementById('invoice-items'); const itemDiv = document.createElement('div'); itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white'; itemDiv.id = `invoice-item-${itemId}`; itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`); // Preview Text logic const previewQty = item ? item.quantity : ''; const previewAmount = item ? item.amount : '$0.00'; let previewDesc = 'New item'; if (item && item.description) { const temp = document.createElement('div'); temp.innerHTML = item.description; previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : ''); } // Preview Type Logic const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts'; itemDiv.innerHTML = `
Qty: ${previewQty} ${typeLabel} ${previewDesc} ${previewAmount}
`; itemsDiv.appendChild(itemDiv); // Quill Init (wie vorher) const editorDiv = itemDiv.querySelector('.invoice-item-description-editor'); const quill = new Quill(editorDiv, { theme: 'snow', modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] } }); if (item && item.description) quill.root.innerHTML = item.description; quill.on('text-change', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); }); editorDiv.quillInstance = quill; // Auto-calculate logic (wie vorher) const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const rateInput = itemDiv.querySelector('[data-field="rate"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const calculateAmount = () => { if (qtyInput.value && rateInput.value) { const qty = parseFloat(qtyInput.value) || 0; const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; amountInput.value = (qty * rateValue).toFixed(2); } updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); }; qtyInput.addEventListener('input', calculateAmount); rateInput.addEventListener('input', calculateAmount); amountInput.addEventListener('input', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); }); updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); } function updateInvoiceItemPreview(itemDiv, itemId) { const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU const editorDiv = itemDiv.querySelector('.invoice-item-description-editor'); const qtyPreview = itemDiv.querySelector('.item-qty-preview'); const descPreview = itemDiv.querySelector('.item-desc-preview'); const amountPreview = itemDiv.querySelector('.item-amount-preview'); const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0'; if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00'; // NEU: Update Type Label if (typePreview && typeInput) { typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts'; } if (descPreview && editorDiv.quillInstance) { const plainText = editorDiv.quillInstance.getText().trim(); const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : ''); descPreview.textContent = preview || 'New item'; } } function handleTypeChange(selectEl, itemId) { const itemDiv = selectEl.closest(`[id^=invoice-item-]`); // Wenn Labor gewählt und Rate leer → Labor Rate eintragen if (selectEl.value === '5' && qboLaborRate) { const rateInput = itemDiv.querySelector('[data-field="rate"]'); if (rateInput && (!rateInput.value || rateInput.value === '0')) { rateInput.value = qboLaborRate; // Amount neu berechnen const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); if (qtyInput.value) { const qty = parseFloat(qtyInput.value) || 0; amountInput.value = (qty * qboLaborRate).toFixed(2); } } } updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); } function removeInvoiceItem(itemId) { document.getElementById(`invoice-item-${itemId}`).remove(); updateInvoiceTotals(); } function moveInvoiceItemUp(itemId) { const item = document.getElementById(`invoice-item-${itemId}`); const prevItem = item.previousElementSibling; if (prevItem) { item.parentNode.insertBefore(item, prevItem); updateInvoiceTotals(); } } function moveInvoiceItemDown(itemId) { const item = document.getElementById(`invoice-item-${itemId}`); const nextItem = item.nextElementSibling; if (nextItem) { item.parentNode.insertBefore(nextItem, item); updateInvoiceTotals(); } } function updateInvoiceTotals() { const items = getInvoiceItems(); const taxExempt = document.getElementById('invoice-tax-exempt').checked; let subtotal = 0; items.forEach(item => { const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0; subtotal += amount; }); const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100); const total = subtotal + taxAmount; document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`; document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`; document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block'; } function getInvoiceItems() { const items = []; const itemDivs = document.querySelectorAll('#invoice-items > div'); itemDivs.forEach(div => { const descEditor = div.querySelector('.invoice-item-description-editor'); const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : ''; const item = { quantity: div.querySelector('[data-field="quantity"]').value, // NEU: ID holen qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value, description: descriptionHTML, rate: div.querySelector('[data-field="rate"]').value, amount: div.querySelector('[data-field="amount"]').value }; items.push(item); }); return items; } async function handleInvoiceSubmit(e) { e.preventDefault(); const data = { invoice_number: document.getElementById('invoice-number').value || null, customer_id: 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: getInvoiceItems() // Deine bestehende Funktion }; if (!data.customer_id) { alert('Please select a customer.'); return; } if (!data.items || data.items.length === 0) { alert('Please add at least one item.'); return; } const invoiceId = currentInvoiceId; const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices'; const method = invoiceId ? 'PUT' : 'POST'; // Spinner anzeigen showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...'); try { const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok) { closeInvoiceModal(); // Info über QBO Status if (result.qbo_doc_number) { console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`); } else if (result.qbo_synced) { console.log('✅ Invoice saved & synced to QBO'); } else { console.log('✅ Invoice saved locally (QBO sync pending)'); } // Invoices neu laden if (window.invoiceView) window.invoiceView.loadInvoices(); } else { alert(`Error: ${result.error}`); } } catch (error) { console.error('Error:', error); alert('Error saving invoice'); } finally { hideSpinner(); } } async function editInvoice(id) { await openInvoiceModal(id); } async function deleteInvoice(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); alert('Error deleting invoice'); } } function viewInvoicePDF(id) { 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'); const tbody = document.getElementById('qbo-result-list'); // UI Loading State const originalText = btn.innerHTML; btn.innerHTML = '⏳ Connecting to QBO...'; btn.disabled = true; resultDiv.classList.add('hidden'); tbody.innerHTML = ''; try { const response = await fetch('/api/qbo/overdue'); const invoices = await response.json(); if (response.ok) { resultDiv.classList.remove('hidden'); if (invoices.length === 0) { tbody.innerHTML = '✅ Good news! No overdue invoices found older than 30 days.'; } else { tbody.innerHTML = invoices.map(inv => ` ${inv.DocNumber || '(No Num)'} ${inv.CustomerRef?.name || 'Unknown'} ${inv.DueDate} $${inv.Balance} `).join(''); } alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`); } else { throw new Error(invoices.error || 'Unknown error'); } } catch (error) { console.error('QBO Test Error:', error); alert('❌ Connection Test Failed: ' + error.message); tbody.innerHTML = `Error: ${error.message}`; resultDiv.classList.remove('hidden'); } finally { btn.innerHTML = originalText; btn.disabled = false; } } async function importFromQBO() { if (!confirm( 'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' + '• Bereits importierte werden übersprungen\n' + '• Nur Kunden die lokal verknüpft sind\n\n' + 'Fortfahren?' )) return; const btn = document.querySelector('button[onclick="importFromQBO()"]'); const resultDiv = document.getElementById('qbo-import-result'); const originalText = btn.innerHTML; btn.innerHTML = '⏳ Importiere aus QBO...'; btn.disabled = true; resultDiv.classList.add('hidden'); try { const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' }); const result = await response.json(); resultDiv.classList.remove('hidden'); if (response.ok) { let html = `
`; html += `

Import abgeschlossen

`; html += `
`; resultDiv.innerHTML = html; // Invoice-Liste aktualisieren if (result.imported > 0) { loadInvoices(); } } else { resultDiv.innerHTML = `

Import fehlgeschlagen

${result.error}

`; } } catch (error) { console.error('Import Error:', error); resultDiv.classList.remove('hidden'); resultDiv.innerHTML = `

Netzwerkfehler beim Import.

`; } finally { btn.innerHTML = originalText; btn.disabled = false; } } // ===================================================== // 3. Labor Rate laden und in addInvoiceItem verwenden // NEUE globale Variable + Lade-Funktion // ===================================================== let qboLaborRate = null; // Wird beim Start geladen async function loadLaborRate() { try { const response = await fetch('/api/qbo/labor-rate'); const data = await response.json(); if (data.rate) { qboLaborRate = data.rate; console.log(`💰 Labor Rate geladen: $${qboLaborRate}`); } } catch (e) { console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.'); } } // ===================================================== // 5. Spinner Funktionen — NEUE Funktionen hinzufügen // Wird bei QBO-Operationen angezeigt // ===================================================== function showSpinner(message = 'Bitte warten...') { let overlay = document.getElementById('qbo-spinner'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'qbo-spinner'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;'; document.body.appendChild(overlay); } overlay.innerHTML = `
${message}
`; overlay.style.display = 'flex'; } function hideSpinner() { const overlay = document.getElementById('qbo-spinner'); if (overlay) overlay.style.display = 'none'; } window.openInvoiceModal = openInvoiceModal;