/** * invoice-modal.js β€” Invoice create/edit modal * Uses shared item-editor for accordion items * * Features: * - Auto-sets tax-exempt based on customer's taxable flag * - Recurring invoice support (monthly/yearly) */ import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js'; import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; import '../utils/api.js'; let currentInvoiceId = null; let qboLaborRate = null; let workerList = []; export async function loadWorkers() { try { const result = await window.API.settings.get('invoice_workers'); if (result && result.value) { workerList = result.value.split(',').map(w => w.trim()).filter(Boolean); } else { workerList = []; } populateWorkerDropdown(); console.log(`πŸ‘· ${workerList.length} Bearbeiter geladen`); } catch (e) { console.log('Worker-Liste konnte nicht geladen werden.'); } } function populateWorkerDropdown() { const sel = document.getElementById('invoice-worker'); if (!sel) return; const current = sel.value; sel.innerHTML = `` + workerList.map(w => ``).join(''); if (current) sel.value = current; } 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.'); } } export function getLaborRate() { return qboLaborRate; } /** * Auto-set tax exempt based on customer's taxable flag */ function applyCustomerTaxStatus(customerId) { const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []); const customer = allCust.find(c => c.id === parseInt(customerId)); if (customer) { const cb = document.getElementById('invoice-tax-exempt'); if (cb) { cb.checked = (customer.taxable === false); updateInvoiceTotals(); } } } // ──────────────────────────────────────────────────────────────────── // Customer Credit // // Das Guthaben selbst wird in QBO gefuehrt (negativer A/R-Saldo). Die App // merkt sich nur, welcher Betrag auf DIESER Rechnung ausgewiesen wird, damit // der Kunde den Endbetrag nachvollziehen kann. Subtotal, Tax und Total bleiben // unberuehrt β€” ein Guthaben ist eine Zahlungsverrechnung, kein Rabatt. // ──────────────────────────────────────────────────────────────────── /** Guthaben des aktuell gewaehlten Kunden laut QBO (null = noch nicht geprueft). */ let availableCredit = null; function creditEls() { return { banner: document.getElementById('invoice-credit-banner'), text: document.getElementById('invoice-credit-available-text'), fields: document.getElementById('invoice-credit-fields'), amount: document.getElementById('invoice-credit-applied'), memo: document.getElementById('invoice-credit-memo'), balance: document.getElementById('invoice-balance-due') }; } /** Betrag im Guthabenfeld β€” 0, wenn leer oder ungueltig. */ function appliedCredit() { const el = document.getElementById('invoice-credit-applied'); const n = parseFloat(el?.value); return isFinite(n) && n > 0 ? n : 0; } function resetCreditUi() { availableCredit = null; const e = creditEls(); if (e.banner) e.banner.classList.add('hidden'); if (e.fields) e.fields.style.display = 'none'; if (e.amount) e.amount.value = '0'; if (e.memo) e.memo.value = ''; } /** * Fragt QBO nach Guthaben des gewaehlten Kunden. Fehler bleiben stumm: ein * nicht erreichbares QBO darf das Anlegen einer Rechnung nicht blockieren. */ async function checkCustomerCredit(customerId) { availableCredit = null; const e = creditEls(); if (e.banner) e.banner.classList.add('hidden'); if (!customerId) return; try { const credits = await window.API.accounting.getCustomerCredits(); if (!Array.isArray(credits)) return; const match = credits.find(c => c.customerId === parseInt(customerId)); if (!match || !(match.credit > 0)) return; availableCredit = match.credit; if (e.text) e.text.textContent = `${match.name} has a credit of $${match.credit.toFixed(2)} on account.`; if (e.banner) e.banner.classList.remove('hidden'); } catch (err) { console.log('Customer credit check skipped:', err.message); } } /** Uebernimmt das Guthaben in die Rechnung β€” gedeckelt auf das Rechnungstotal. */ export function applyCustomerCredit() { if (!(availableCredit > 0)) return; const e = creditEls(); const total = currentInvoiceTotal(); const value = Math.min(availableCredit, total); if (value <= 0) { alert('Please add items first β€” a credit can only be applied to an invoice amount.'); return; } if (e.fields) e.fields.style.display = 'block'; if (e.amount) e.amount.value = value.toFixed(2); if (e.memo && !e.memo.value) e.memo.value = 'Credit on account'; if (value < availableCredit) { alert(`Only $${value.toFixed(2)} of the $${availableCredit.toFixed(2)} credit fits on this invoice. ` + 'The remainder stays on account in QBO.'); } updateInvoiceTotals(); } export function clearCustomerCredit() { const e = creditEls(); if (e.amount) e.amount.value = '0'; if (e.memo) e.memo.value = ''; if (e.fields) e.fields.style.display = 'none'; updateInvoiceTotals(); } function currentInvoiceTotal() { const items = getItems('invoice-items'); const taxExempt = document.getElementById('invoice-tax-exempt')?.checked; let subtotal = 0; items.forEach(item => { subtotal += parseFloat(String(item.amount).replace(/[$,]/g, '')) || 0; }); return subtotal + (taxExempt ? 0 : subtotal * 8.25 / 100); } function updateRecurringChildUi(invoice = null) { const recurringCb = document.getElementById('invoice-recurring'); if (!recurringCb) return; const recurringWrapper = recurringCb.closest('label') || recurringCb.parentElement; if (!recurringWrapper) return; let labelText = document.getElementById('invoice-recurring-label-text'); let childBadge = document.getElementById('invoice-recurring-child-badge'); let childNote = document.getElementById('invoice-recurring-child-note'); // Find or create a stable label span if (!labelText) { // Prefer an existing span/text element containing "Recurring" const existingTextElement = Array.from(recurringWrapper.children).find(el => el !== recurringCb && el.id !== 'invoice-recurring-child-badge' && el.textContent && el.textContent.trim() === 'Recurring' ); if (existingTextElement) { existingTextElement.id = 'invoice-recurring-label-text'; labelText = existingTextElement; } else { // Fallback: try to replace a raw text node const textNode = Array.from(recurringWrapper.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim().includes('Recurring') ); if (textNode) { const span = document.createElement('span'); span.id = 'invoice-recurring-label-text'; span.textContent = 'Recurring'; recurringWrapper.replaceChild(span, textNode); labelText = span; } else { // Final fallback: create label text after checkbox labelText = document.createElement('span'); labelText.id = 'invoice-recurring-label-text'; labelText.textContent = 'Recurring'; labelText.className = 'ml-2'; if (recurringCb.nextSibling) { recurringWrapper.insertBefore(labelText, recurringCb.nextSibling); } else { recurringWrapper.appendChild(labelText); } } } } if (!childBadge) { childBadge = document.createElement('span'); childBadge.id = 'invoice-recurring-child-badge'; childBadge.className = 'ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800 hidden'; childBadge.textContent = 'Child invoice'; recurringWrapper.appendChild(childBadge); } if (!childNote) { childNote = document.createElement('div'); childNote.id = 'invoice-recurring-child-note'; childNote.className = 'mt-1 ml-8 text-xs text-gray-500 hidden'; const parent = recurringWrapper.parentElement || recurringWrapper; parent.appendChild(childNote); } const isChild = !!invoice?.recurring_source_id; if (isChild) { labelText.textContent = 'Recurring child invoice'; childBadge.classList.remove('hidden'); const sourceNumber = invoice.recurring_source_invoice_number || invoice.source_invoice_number || invoice.recurring_source_id; childNote.textContent = `This invoice was generated from recurring invoice #${sourceNumber} and will not create further recurring invoices.`; childNote.classList.remove('hidden'); } else { labelText.textContent = 'Recurring'; childBadge.classList.add('hidden'); childNote.classList.add('hidden'); childNote.textContent = ''; } } 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 || ''; // Guthaben einer bestehenden Rechnung wiederherstellen resetCreditUi(); const savedCredit = parseFloat(data.invoice.credit_applied) || 0; if (savedCredit > 0) { const ce = creditEls(); if (ce.fields) ce.fields.style.display = 'block'; if (ce.amount) ce.amount.value = savedCredit.toFixed(2); if (ce.memo) ce.memo.value = data.invoice.credit_memo || ''; } checkCustomerCredit(data.invoice.customer_id); // Worker populateWorkerDropdown(); const workerEl = document.getElementById('invoice-worker'); if (workerEl) workerEl.value = data.invoice.worker || ''; const sendDateEl = document.getElementById('invoice-send-date'); if (sendDateEl) { sendDateEl.value = data.invoice.scheduled_send_date ? data.invoice.scheduled_send_date.split('T')[0] : ''; } // Recurring fields const recurringCb = document.getElementById('invoice-recurring'); const recurringInterval = document.getElementById('invoice-recurring-interval'); const recurringGroup = document.getElementById('invoice-recurring-group'); if (recurringCb) { const isGeneratedRecurringChild = !!data.invoice.recurring_source_id; const canBeRecurringMaster = !isGeneratedRecurringChild; recurringCb.checked = canBeRecurringMaster && (data.invoice.is_recurring || false); recurringCb.disabled = !canBeRecurringMaster; if (recurringInterval) { recurringInterval.value = data.invoice.recurring_interval || 'monthly'; recurringInterval.disabled = !canBeRecurringMaster; } if (recurringGroup) { recurringGroup.style.display = recurringCb.checked ? 'block' : 'none'; } // Make recurring child invoices obvious in the UI updateRecurringChildUi(data.invoice); } // Load items 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 = ''; resetCreditUi(); document.getElementById('invoice-terms').value = 'Net 14'; document.getElementById('invoice-number').value = ''; document.getElementById('invoice-send-date').value = ''; // Worker zurΓΌcksetzen populateWorkerDropdown(); const workerEl = document.getElementById('invoice-worker'); if (workerEl) workerEl.value = ''; // Reset recurring const recurringCb = document.getElementById('invoice-recurring'); const recurringGroup = document.getElementById('invoice-recurring-group'); if (recurringCb) recurringCb.checked = false; if (recurringGroup) recurringGroup.style.display = 'none'; const recurringInterval = document.getElementById('invoice-recurring-interval'); if (recurringCb) recurringCb.disabled = false; if (recurringInterval) recurringInterval.disabled = false; updateRecurringChildUi(null); resetItemCounter(); setDefaultDate(); 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'; // Guthabenzeile: Betrag nie ueber dem Total, sonst waere die Balance Due negativ. const credit = appliedCredit(); const e = creditEls(); if (credit > 0) { if (credit > total && e.amount) e.amount.value = total.toFixed(2); const applied = Math.min(credit, total); if (e.fields) e.fields.style.display = 'block'; if (e.balance) e.balance.textContent = `$${(total - applied).toFixed(2)}`; } else if (e.fields) { e.fields.style.display = 'none'; } } export async function handleInvoiceSubmit(e) { e.preventDefault(); const isRecurring = document.getElementById('invoice-recurring')?.checked || false; const recurringInterval = isRecurring ? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null; 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, worker: document.getElementById('invoice-worker')?.value || null, is_recurring: isRecurring, recurring_interval: recurringInterval, credit_applied: appliedCredit(), credit_memo: document.getElementById('invoice-credit-memo')?.value?.trim() || 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(); } } export function initInvoiceModal() { const form = document.getElementById('invoice-form'); if (form) form.addEventListener('submit', handleInvoiceSubmit); loadWorkers(); // Bearbeiterliste laden const taxExempt = document.getElementById('invoice-tax-exempt'); if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals); const creditAmount = document.getElementById('invoice-credit-applied'); if (creditAmount) creditAmount.addEventListener('input', updateInvoiceTotals); // Recurring toggle const recurringCb = document.getElementById('invoice-recurring'); const recurringGroup = document.getElementById('invoice-recurring-group'); if (recurringCb && recurringGroup) { recurringCb.addEventListener('change', () => { recurringGroup.style.display = recurringCb.checked ? 'block' : 'none'; }); } // Watch for customer selection β†’ auto-set tax exempt (only for new invoices) const customerHidden = document.getElementById('invoice-customer'); if (customerHidden) { const observer = new MutationObserver(() => { // Only auto-apply when creating new (not editing existing) if (!currentInvoiceId && customerHidden.value) { applyCustomerTaxStatus(customerHidden.value); checkCustomerCredit(customerHidden.value); } }); observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] }); } } window.invoiceModal = { applyCustomerCredit, clearCustomerCredit }; window.openInvoiceModal = openInvoiceModal; window.closeInvoiceModal = closeInvoiceModal; window.addInvoiceItem = addInvoiceItem; window.reloadInvoiceWorkers = loadWorkers;