From e333628f1c4bce9cb4806395cd27217d938d4c8f Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 4 Mar 2026 17:03:51 -0600 Subject: [PATCH] refactoring --- Dockerfile | 1 - public/app.js | 1192 ----------------------- public/css/styles.css | 54 + public/index.html | 104 +- public/invoice-view-init.js | 16 - public/js/app.js | 85 ++ public/js/components/customer-search.js | 67 ++ public/js/modals/invoice-modal.js | 221 +++++ public/{ => js/modals}/payment-modal.js | 0 public/js/modals/quote-modal.js | 154 +++ public/js/utils/helpers.js | 48 + public/js/utils/item-editor.js | 293 ++++++ public/{ => js/views}/customer-view.js | 0 public/{ => js/views}/invoice-view.js | 0 public/js/views/quote-view.js | 101 ++ public/js/views/settings-view.js | 182 ++++ 16 files changed, 1222 insertions(+), 1296 deletions(-) delete mode 100644 public/app.js create mode 100644 public/css/styles.css delete mode 100644 public/invoice-view-init.js create mode 100644 public/js/app.js create mode 100644 public/js/components/customer-search.js create mode 100644 public/js/modals/invoice-modal.js rename public/{ => js/modals}/payment-modal.js (100%) create mode 100644 public/js/modals/quote-modal.js create mode 100644 public/js/utils/helpers.js create mode 100644 public/js/utils/item-editor.js rename public/{ => js/views}/customer-view.js (100%) rename public/{ => js/views}/invoice-view.js (100%) create mode 100644 public/js/views/quote-view.js create mode 100644 public/js/views/settings-view.js diff --git a/Dockerfile b/Dockerfile index 93c7d41..e63c09b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,6 @@ COPY package*.json ./ RUN npm install --omit=dev # Copy application files -COPY server.js ./ COPY qbo_helper.js ./ COPY src ./src COPY public ./public diff --git a/public/app.js b/public/app.js deleted file mode 100644 index ba79841..0000000 --- a/public/app.js +++ /dev/null @@ -1,1192 +0,0 @@ -// 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 allCust = window.getCustomers ? window.getCustomers() : customers; - const customer = allCust.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 allCust = window.getCustomers ? window.getCustomers() : customers; - const customer = allCust.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; - document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || ''; - // 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, - bill_to_name: document.getElementById('invoice-bill-to-name')?.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; \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..f751a40 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,54 @@ +/* styles.css — Application styles extracted from index.html */ + +.modal { + display: none; +} +.modal.active { + display: flex; +} + +/* Invoice/Quote Modal — visible field borders */ +#invoice-modal input, +#invoice-modal select, +#invoice-modal textarea, +#quote-modal input, +#quote-modal select, +#quote-modal textarea { + border: 1.5px solid #9ca3af !important; +} + +#invoice-modal input:focus, +#invoice-modal select:focus, +#invoice-modal textarea:focus, +#quote-modal input:focus, +#quote-modal select:focus, +#quote-modal textarea:focus { + border-color: #3b82f6 !important; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important; +} + +/* Rich Text Editor borders */ +#invoice-modal .ql-container, +#invoice-modal .ql-toolbar, +#quote-modal .ql-container, +#quote-modal .ql-toolbar { + border: 1.5px solid #9ca3af !important; +} + +.item-row input, +.item-row select, +.invoice-item input, +.invoice-item select, +#invoice-items input, +#invoice-items select, +#quote-items input, +#quote-items select { + border: 1.5px solid #9ca3af !important; +} + +#invoice-items > div, +#quote-items > div, +#invoice-items .border, +#quote-items .border { + border: 1.5px solid #9ca3af !important; +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 8d40f41..41eef6c 100644 --- a/public/index.html +++ b/public/index.html @@ -7,61 +7,12 @@ + - +
@@ -111,7 +62,7 @@
- + - + @@ -361,13 +315,9 @@
+ class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel + class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote
@@ -386,7 +336,7 @@
-
+
+ class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel + class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice
- - - + + \ No newline at end of file diff --git a/public/invoice-view-init.js b/public/invoice-view-init.js deleted file mode 100644 index 17fcb2e..0000000 --- a/public/invoice-view-init.js +++ /dev/null @@ -1,16 +0,0 @@ -// invoice-view-init.js — Bootstrap-Script (type="module") -import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js'; -import './payment-modal.js'; - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} - -function init() { - injectToolbar(); - window.loadInvoices = loadInvoices; - window.renderInvoices = renderInvoiceView; - loadInvoices(); -} \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..252fec0 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,85 @@ +/** + * app.js — Application Bootstrap + * + * This is the main entry point. All business logic has been moved to modules: + * - js/views/quote-view.js → Quote list + * - js/views/invoice-view.js → Invoice list (existing) + * - js/views/settings-view.js → Logo, QBO import/test + * - js/modals/quote-modal.js → Quote create/edit + * - js/modals/invoice-modal.js → Invoice create/edit + * - js/modals/payment-modal.js → Payment recording (existing) + * - js/components/customer-search.js → Alpine dropdown + * - js/utils/item-editor.js → Shared accordion item editor + * - js/utils/helpers.js → formatDate, spinner + * - js/utils/api.js → API wrapper (existing) + */ + +// --- Imports --- +import { loadQuotes } from './views/quote-view.js'; +import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js'; +import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js'; +import { checkCurrentLogo, initSettingsView } from './views/settings-view.js'; +import { initQuoteModal } from './modals/quote-modal.js'; +import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js'; +import './modals/payment-modal.js'; +import { setDefaultDate } from './utils/helpers.js'; + +// ============================================================ +// 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'); + + localStorage.setItem('activeTab', tabName); + + if (tabName === 'quotes') { + loadQuotes(); + } else if (tabName === 'invoices') { + injectInvoiceToolbar(); + loadInvoices(); + } else if (tabName === 'customers') { + injectCustomerToolbar(); + renderCustomerView(); + } else if (tabName === 'settings') { + checkCurrentLogo(); + } +} + +// ============================================================ +// Init +// ============================================================ + +document.addEventListener('DOMContentLoaded', () => { + // Load shared data + loadCustomers(); + loadLaborRate(); + setDefaultDate(); + + // Init modals (wire up form handlers) + initQuoteModal(); + initInvoiceModal(); + initSettingsView(); + + // Restore saved tab (or default to quotes) + const savedTab = localStorage.getItem('activeTab') || 'quotes'; + showTab(savedTab); + + // Hash-based navigation (e.g. after OAuth redirect /#settings) + if (window.location.hash) { + const hashTab = window.location.hash.replace('#', ''); + if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) { + showTab(hashTab); + } + } +}); + +// ============================================================ +// Expose to HTML onclick handlers +// ============================================================ + +window.showTab = showTab; \ No newline at end of file diff --git a/public/js/components/customer-search.js b/public/js/components/customer-search.js new file mode 100644 index 0000000..a3cfdbb --- /dev/null +++ b/public/js/components/customer-search.js @@ -0,0 +1,67 @@ +/** + * customer-search.js — Alpine.js Customer Search Component + * Used in Quote and Invoice modals for customer dropdown + */ + +function customerSearch(type) { + return { + search: '', + selectedId: '', + selectedName: '', + open: false, + highlighted: 0, + + get filteredCustomers() { + const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []); + + if (!this.search) { + return allCustomers; + } + + const searchLower = this.search.toLowerCase(); + return allCustomers.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 globally available for Alpine x-data +window.customerSearch = customerSearch; \ No newline at end of file diff --git a/public/js/modals/invoice-modal.js b/public/js/modals/invoice-modal.js new file mode 100644 index 0000000..acfc230 --- /dev/null +++ b/public/js/modals/invoice-modal.js @@ -0,0 +1,221 @@ +/** + * invoice-modal.js — Invoice create/edit modal + * Uses shared item-editor for accordion items + */ +import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js'; +import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; + +let currentInvoiceId = null; +let qboLaborRate = null; + +/** + * Load labor rate from QBO (called once at startup) + */ +export 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.'); + } +} + +export function getLaborRate() { + return qboLaborRate; +} + +export async function openInvoiceModal(invoiceId = null) { + currentInvoiceId = invoiceId; + + if (invoiceId) { + await loadInvoiceForEdit(invoiceId); + } else { + prepareNewInvoice(); + } + + document.getElementById('invoice-modal').classList.add('active'); +} + +export function closeInvoiceModal() { + document.getElementById('invoice-modal').classList.remove('active'); + currentInvoiceId = null; +} + +async function loadInvoiceForEdit(invoiceId) { + document.getElementById('invoice-modal-title').textContent = 'Edit Invoice'; + + const response = await fetch(`/api/invoices/${invoiceId}`); + const data = await response.json(); + + // Set customer in Alpine component + const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []); + const customer = allCust.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; + document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0]; + 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; + document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || ''; + + 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 using shared editor + document.getElementById('invoice-items').innerHTML = ''; + resetItemCounter(); + data.items.forEach(item => { + addItem('invoice-items', { + item, + type: 'invoice', + laborRate: qboLaborRate, + onUpdate: updateInvoiceTotals + }); + }); + + updateInvoiceTotals(); +} + +function prepareNewInvoice() { + document.getElementById('invoice-modal-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 = ''; + document.getElementById('invoice-send-date').value = ''; + resetItemCounter(); + setDefaultDate(); + + // Add one default item + addItem('invoice-items', { + type: 'invoice', + laborRate: qboLaborRate, + onUpdate: updateInvoiceTotals + }); +} + +export function addInvoiceItem(item = null) { + addItem('invoice-items', { + item, + type: 'invoice', + laborRate: qboLaborRate, + onUpdate: updateInvoiceTotals + }); +} + +export function updateInvoiceTotals() { + const items = getItems('invoice-items'); + 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'; +} + +export 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, + bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null, + items: getItems('invoice-items') + }; + + 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'; + + 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(); + + 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)'); + } + + if (window.invoiceView) window.invoiceView.loadInvoices(); + } else { + alert(`Error: ${result.error}`); + } + } catch (error) { + console.error('Error:', error); + alert('Error saving invoice'); + } finally { + hideSpinner(); + } +} + +// Wire up form submit and tax-exempt checkbox +export function initInvoiceModal() { + const form = document.getElementById('invoice-form'); + if (form) form.addEventListener('submit', handleInvoiceSubmit); + + const taxExempt = document.getElementById('invoice-tax-exempt'); + if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals); +} + +// Expose for onclick handlers +window.openInvoiceModal = openInvoiceModal; +window.closeInvoiceModal = closeInvoiceModal; +window.addInvoiceItem = addInvoiceItem; \ No newline at end of file diff --git a/public/payment-modal.js b/public/js/modals/payment-modal.js similarity index 100% rename from public/payment-modal.js rename to public/js/modals/payment-modal.js diff --git a/public/js/modals/quote-modal.js b/public/js/modals/quote-modal.js new file mode 100644 index 0000000..e3bc601 --- /dev/null +++ b/public/js/modals/quote-modal.js @@ -0,0 +1,154 @@ +/** + * quote-modal.js — Quote create/edit modal + * Uses shared item-editor for accordion items + */ +import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js'; +import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; + +let currentQuoteId = null; + +export function openQuoteModal(quoteId = null) { + currentQuoteId = quoteId; + + if (quoteId) { + loadQuoteForEdit(quoteId); + } else { + prepareNewQuote(); + } + + document.getElementById('quote-modal').classList.add('active'); +} + +export function closeQuoteModal() { + document.getElementById('quote-modal').classList.remove('active'); + currentQuoteId = null; +} + +async function loadQuoteForEdit(quoteId) { + document.getElementById('quote-modal-title').textContent = 'Edit Quote'; + + const response = await fetch(`/api/quotes/${quoteId}`); + const data = await response.json(); + + // Set customer in Alpine component + const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []); + const customer = allCust.find(c => c.id === data.quote.customer_id); + if (customer) { + const customerInput = document.querySelector('#quote-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('quote-customer').value = data.quote.customer_id; + document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0]; + document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt; + + // Load items using shared editor + document.getElementById('quote-items').innerHTML = ''; + resetItemCounter(); + data.items.forEach(item => { + addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals }); + }); + + updateQuoteTotals(); +} + +function prepareNewQuote() { + document.getElementById('quote-modal-title').textContent = 'New Quote'; + document.getElementById('quote-form').reset(); + document.getElementById('quote-items').innerHTML = ''; + resetItemCounter(); + setDefaultDate(); + + // Add one default item + addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals }); +} + +export function addQuoteItem(item = null) { + addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals }); +} + +export function updateQuoteTotals() { + const items = getItems('quote-items'); + 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'; +} + +export async function handleQuoteSubmit(e) { + e.preventDefault(); + + const items = getItems('quote-items'); + 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(); + if (window.quoteView) window.quoteView.loadQuotes(); + } else { + alert('Error saving quote'); + } + } catch (error) { + console.error('Error:', error); + alert('Error saving quote'); + } +} + +// Wire up form submit and tax-exempt checkbox +export function initQuoteModal() { + const form = document.getElementById('quote-form'); + if (form) form.addEventListener('submit', handleQuoteSubmit); + + const taxExempt = document.getElementById('quote-tax-exempt'); + if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals); +} + +// Expose for onclick handlers +window.openQuoteModal = openQuoteModal; +window.closeQuoteModal = closeQuoteModal; +window.addQuoteItem = addQuoteItem; \ No newline at end of file diff --git a/public/js/utils/helpers.js b/public/js/utils/helpers.js new file mode 100644 index 0000000..7aadfef --- /dev/null +++ b/public/js/utils/helpers.js @@ -0,0 +1,48 @@ +/** + * helpers.js — Shared UI utility functions + * Extracted from app.js + */ + +export 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}`; +} + +export 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; +} + +export 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'; +} + +export function hideSpinner() { + const overlay = document.getElementById('qbo-spinner'); + if (overlay) overlay.style.display = 'none'; +} + +// Keep backward compat for onclick handlers and modules using typeof check +window.showSpinner = showSpinner; +window.hideSpinner = hideSpinner; \ No newline at end of file diff --git a/public/js/utils/item-editor.js b/public/js/utils/item-editor.js new file mode 100644 index 0000000..62805bd --- /dev/null +++ b/public/js/utils/item-editor.js @@ -0,0 +1,293 @@ +/** + * item-editor.js — Shared accordion item editor for Quotes and Invoices + * + * Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function). + * + * Usage: + * import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js'; + * addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 }); + */ + +let itemCounter = 0; + +export function resetItemCounter() { + itemCounter = 0; +} + +export function getItemCounter() { + return itemCounter; +} + +/** + * Add an item row to the specified container. + * + * @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items') + * @param {object} options + * @param {object|null} options.item - Existing item data (null for new empty item) + * @param {string} options.type - 'quote' or 'invoice' + * @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only) + * @param {function} options.onUpdate - Callback after any change (for recalculating totals) + */ +export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) { + const itemId = itemCounter++; + const itemsDiv = document.getElementById(containerId); + if (!itemsDiv) return; + + const prefix = type; // 'quote' or 'invoice' + const cssClass = `${prefix}-item-input`; + const editorClass = `${prefix}-item-description-editor`; + const amountClass = `${prefix}-item-amount`; + + // Preview defaults + 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 ? '...' : ''); + } + const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts'; + + const itemDiv = document.createElement('div'); + itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white'; + itemDiv.id = `${prefix}-item-${itemId}`; + itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`); + + itemDiv.innerHTML = ` +
+
+ + +
+ +
+ + + + Qty: ${previewQty} + ${typeLabel} + + ${previewDesc} + ${previewAmount} +
+ + +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ `; + + itemsDiv.appendChild(itemDiv); + + // --- Quill Rich Text Editor --- + const editorDiv = itemDiv.querySelector(`.${editorClass}`); + 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); + onUpdate(); + }); + editorDiv.quillInstance = quill; + + // --- Auto-calculate Amount --- + 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) { + // Quote supports TBD + if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') { + // Don't auto-calculate for TBD + } else { + const qty = parseFloat(qtyInput.value) || 0; + const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; + amountInput.value = (qty * rateValue).toFixed(2); + } + } + updateItemPreview(itemDiv); + onUpdate(); + }; + + qtyInput.addEventListener('input', calculateAmount); + rateInput.addEventListener('input', calculateAmount); + amountInput.addEventListener('input', () => { + updateItemPreview(itemDiv); + onUpdate(); + }); + + // Store metadata on the div for later retrieval + itemDiv._itemEditor = { type, laborRate, onUpdate }; + + updateItemPreview(itemDiv); + onUpdate(); +} + +/** + * Update the collapsed preview bar of an item + */ +function updateItemPreview(itemDiv) { + const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); + const amountInput = itemDiv.querySelector('[data-field="amount"]'); + const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); + const editorDivs = itemDiv.querySelectorAll('[data-field="description"]'); + const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null; + + 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'); + + if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0'; + if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00'; + + if (typePreview && typeInput) { + typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts'; + } + + if (descPreview && editorDiv && editorDiv.quillInstance) { + const plainText = editorDiv.quillInstance.getText().trim(); + const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : ''); + descPreview.textContent = preview || 'New item'; + } +} + +/** + * Handle type change (Labor/Parts). + * When Labor is selected and rate is empty, auto-fill with labor rate. + */ +export function handleTypeChange(selectEl, prefix, itemId) { + const itemDiv = document.getElementById(`${prefix}-item-${itemId}`); + if (!itemDiv) return; + + const meta = itemDiv._itemEditor || {}; + const laborRate = meta.laborRate; + const onUpdate = meta.onUpdate || (() => {}); + + // Auto-fill labor rate when switching to Labor and rate is empty + if (selectEl.value === '5' && laborRate) { + const rateInput = itemDiv.querySelector('[data-field="rate"]'); + if (rateInput && (!rateInput.value || rateInput.value === '0')) { + rateInput.value = laborRate; + // Recalculate amount + 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 * laborRate).toFixed(2); + } + } + } + + updateItemPreview(itemDiv); + onUpdate(); +} + +/** + * Get all items from a container as an array of objects. + */ +export function getItems(containerId) { + const items = []; + const itemDivs = document.querySelectorAll(`#${containerId} > div`); + + itemDivs.forEach(div => { + const descEditor = div.querySelector('[data-field="description"]'); + const descriptionHTML = descEditor && descEditor.quillInstance + ? descEditor.quillInstance.root.innerHTML + : ''; + + items.push({ + quantity: div.querySelector('[data-field="quantity"]').value, + 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 + }); + }); + return items; +} + +/** + * Remove an item by prefix and itemId + */ +export function removeItem(prefix, itemId) { + const el = document.getElementById(`${prefix}-item-${itemId}`); + if (!el) return; + const meta = el._itemEditor || {}; + el.remove(); + if (meta.onUpdate) meta.onUpdate(); +} + +/** + * Move an item up + */ +export function moveItemUp(prefix, itemId) { + const item = document.getElementById(`${prefix}-item-${itemId}`); + if (!item) return; + const prevItem = item.previousElementSibling; + if (prevItem) { + item.parentNode.insertBefore(item, prevItem); + const meta = item._itemEditor || {}; + if (meta.onUpdate) meta.onUpdate(); + } +} + +/** + * Move an item down + */ +export function moveItemDown(prefix, itemId) { + const item = document.getElementById(`${prefix}-item-${itemId}`); + if (!item) return; + const nextItem = item.nextElementSibling; + if (nextItem) { + item.parentNode.insertBefore(nextItem, item); + const meta = item._itemEditor || {}; + if (meta.onUpdate) meta.onUpdate(); + } +} + +// ============================================================ +// Expose to window for onclick handlers in HTML +// ============================================================ + +window.itemEditor = { + moveUp: moveItemUp, + moveDown: moveItemDown, + remove: removeItem, + handleTypeChange: handleTypeChange +}; \ No newline at end of file diff --git a/public/customer-view.js b/public/js/views/customer-view.js similarity index 100% rename from public/customer-view.js rename to public/js/views/customer-view.js diff --git a/public/invoice-view.js b/public/js/views/invoice-view.js similarity index 100% rename from public/invoice-view.js rename to public/js/views/invoice-view.js diff --git a/public/js/views/quote-view.js b/public/js/views/quote-view.js new file mode 100644 index 0000000..ec281b3 --- /dev/null +++ b/public/js/views/quote-view.js @@ -0,0 +1,101 @@ +/** + * quote-view.js — Quote list rendering and actions + * Analog to invoice-view.js + */ +import { formatDate } from '../utils/helpers.js'; + +let quotes = []; + +export async function loadQuotes() { + try { + const response = await fetch('/api/quotes'); + quotes = await response.json(); + renderQuotes(); + } catch (error) { + console.error('Error loading quotes:', error); + } +} + +export function getQuotesData() { + return quotes; +} + +export function renderQuotes() { + const tbody = document.getElementById('quotes-list'); + if (!tbody) return; + + 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(''); + + if (quotes.length === 0) { + tbody.innerHTML = `No quotes found.`; + } +} + +export function viewPDF(id) { + window.open(`/api/quotes/${id}/pdf`, '_blank'); +} + +export async function edit(id) { + if (typeof window.openQuoteModal === 'function') { + await window.openQuoteModal(id); + } +} + +export async function remove(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'); + } +} + +export async function convertToInvoice(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 || '(Draft)'} created successfully!`); + if (window.invoiceView) window.invoiceView.loadInvoices(); + if (typeof window.showTab === 'function') window.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'); + } +} + +// Expose for onclick handlers +window.quoteView = { + loadQuotes, + renderQuotes, + viewPDF, + edit, + remove, + convertToInvoice +}; \ No newline at end of file diff --git a/public/js/views/settings-view.js b/public/js/views/settings-view.js new file mode 100644 index 0000000..506e262 --- /dev/null +++ b/public/js/views/settings-view.js @@ -0,0 +1,182 @@ +/** + * settings-view.js — Logo upload, QBO import, QBO connection test + * Extracted from app.js + */ + +let currentLogoFile = null; + +export 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); + } +} + +export 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

'; + } +} + +export function initSettingsView() { + const logoUpload = document.getElementById('logo-upload'); + if (logoUpload) { + logoUpload.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; + } + }); + } +} + +export async function checkQboOverdue() { + const btn = document.querySelector('button[onclick="checkQboOverdue()"]'); + const resultDiv = document.getElementById('qbo-result'); + const tbody = document.getElementById('qbo-result-list'); + + 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; + } +} + +export 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; + + if (result.imported > 0 && window.invoiceView) { + window.invoiceView.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; + } +} + +// Expose for onclick handlers +window.uploadLogo = uploadLogo; +window.checkQboOverdue = checkQboOverdue; +window.importFromQBO = importFromQBO; \ No newline at end of file