// Global state let customers = []; let quotes = []; let currentQuoteId = null; let currentCustomerId = null; let itemCounter = 0; let currentLogoFile = null; // Initialize app document.addEventListener('DOMContentLoaded', () => { loadCustomers(); loadQuotes(); setDefaultDate(); checkCurrentLogo(); // Setup form handlers document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit); document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit); document.getElementById('quote-tax-exempt').addEventListener('change', updateTotals); // 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'); if (tabName === 'quotes') { loadQuotes(); } else if (tabName === 'customers') { loadCustomers(); } else if (tabName === 'settings') { checkCurrentLogo(); } } // 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

'; } } // Customers async function loadCustomers() { try { const response = await fetch('/api/customers'); customers = await response.json(); renderCustomers(); updateCustomerDropdown(); } catch (error) { console.error('Error loading customers:', error); alert('Error loading customers'); } } function renderCustomers() { const tbody = document.getElementById('customers-list'); tbody.innerHTML = customers.map(customer => ` ${customer.name} ${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code} ${customer.account_number || '-'} `).join(''); } function updateCustomerDropdown() { const select = document.getElementById('quote-customer'); select.innerHTML = '' + customers.map(c => ``).join(''); } function openCustomerModal(customerId = null) { currentCustomerId = customerId; const modal = document.getElementById('customer-modal'); const title = document.getElementById('customer-modal-title'); if (customerId) { title.textContent = 'Edit Customer'; const customer = customers.find(c => c.id === customerId); document.getElementById('customer-id').value = customer.id; document.getElementById('customer-name').value = customer.name; document.getElementById('customer-street').value = customer.street; document.getElementById('customer-city').value = customer.city; document.getElementById('customer-state').value = customer.state; document.getElementById('customer-zip').value = customer.zip_code; document.getElementById('customer-account').value = customer.account_number || ''; } else { title.textContent = 'New Customer'; document.getElementById('customer-form').reset(); document.getElementById('customer-id').value = ''; } modal.classList.add('active'); } function closeCustomerModal() { document.getElementById('customer-modal').classList.remove('active'); currentCustomerId = null; } async function handleCustomerSubmit(e) { e.preventDefault(); const data = { name: document.getElementById('customer-name').value, street: document.getElementById('customer-street').value, city: document.getElementById('customer-city').value, state: document.getElementById('customer-state').value.toUpperCase(), zip_code: document.getElementById('customer-zip').value, account_number: document.getElementById('customer-account').value }; try { const customerId = document.getElementById('customer-id').value; const url = customerId ? `/api/customers/${customerId}` : '/api/customers'; const method = customerId ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (response.ok) { closeCustomerModal(); loadCustomers(); } else { alert('Error saving customer'); } } catch (error) { console.error('Error:', error); alert('Error saving customer'); } } async function editCustomer(id) { openCustomerModal(id); } async function deleteCustomer(id) { if (!confirm('Are you sure you want to delete this customer?')) return; try { const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' }); if (response.ok) { loadCustomers(); } else { alert('Error deleting customer'); } } catch (error) { console.error('Error:', error); alert('Error deleting customer'); } } // Quotes 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 quote = await response.json(); document.getElementById('quote-id').value = quote.id; document.getElementById('quote-customer').value = quote.customer_id; document.getElementById('quote-number').value = quote.quote_number; // Convert date from YYYY-MM-DD format (may include time) const dateOnly = quote.quote_date.split('T')[0]; document.getElementById('quote-date').value = dateOnly; document.getElementById('quote-tax-exempt').checked = quote.tax_exempt; document.getElementById('quote-tbd-note').value = quote.tbd_note || ''; // Load items document.getElementById('quote-items').innerHTML = ''; itemCounter = 0; quote.items.forEach(item => { addQuoteItem(item); }); updateTotals(); } else { title.textContent = 'New Quote'; document.getElementById('quote-form').reset(); document.getElementById('quote-id').value = ''; document.getElementById('quote-items').innerHTML = ''; itemCounter = 0; setDefaultDate(); // Get next quote number const response = await fetch('/api/quotes/next-number'); const data = await response.json(); document.getElementById('quote-number').value = data.quote_number; // 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'; itemDiv.id = `item-${itemId}`; // Create summary text const summaryQty = item ? item.quantity : ''; const summaryDesc = item ? (item.description.replace(/<[^>]*>/g, '').substring(0, 50) + '...') : 'New item'; const summaryAmount = item ? item.amount : ''; itemDiv.innerHTML = `
Qty: ${summaryQty} ${summaryDesc} $${summaryAmount}
`; itemsDiv.appendChild(itemDiv); // Initialize Quill editor for description const editorDiv = itemDiv.querySelector('.item-description-editor'); const hiddenInput = itemDiv.querySelector('.item-description-html'); const quill = new Quill(editorDiv, { theme: 'snow', modules: { toolbar: [ ['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean'] ] } }); // Load existing content if editing if (item && item.description) { quill.root.innerHTML = item.description; } // Save HTML content on change quill.on('text-change', () => { hiddenInput.value = quill.root.innerHTML; updateItemSummary(itemId); updateTotals(); }); // Store quill instance for later access editorDiv.quillInstance = quill; // Get references to inputs for auto-calculation const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const rateInput = itemDiv.querySelector('[data-field="rate"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const tbdCheckbox = itemDiv.querySelector('[data-field="is_tbd"]'); // Auto-calculate amount when qty or rate changes const calculateAmount = () => { if (!tbdCheckbox.checked && qtyInput.value && rateInput.value) { const qty = parseFloat(qtyInput.value) || 0; // Extract numeric value from rate (handles "125.00/hr" format) const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0; const amount = qty * rateValue; amountInput.value = amount.toFixed(2); } updateItemSummary(itemId); updateTotals(); }; // Add event listeners for auto-calculation and summary update qtyInput.addEventListener('input', calculateAmount); rateInput.addEventListener('input', calculateAmount); amountInput.addEventListener('input', () => { updateItemSummary(itemId); updateTotals(); }); // Add event listeners for totals update itemDiv.querySelectorAll('.item-input, .item-amount').forEach(input => { input.addEventListener('input', updateTotals); }); itemDiv.querySelector('.item-tbd').addEventListener('change', function() { const amountInput = itemDiv.querySelector('.item-amount'); if (this.checked) { amountInput.value = 'TBD'; amountInput.readOnly = true; amountInput.classList.add('bg-gray-100'); } else { if (amountInput.value === 'TBD') { amountInput.value = ''; calculateAmount(); // Recalculate when unchecking TBD } amountInput.readOnly = false; amountInput.classList.remove('bg-gray-100'); } updateTotals(); }); // Trigger TBD state if loaded if (item && item.is_tbd) { itemDiv.querySelector('.item-tbd').dispatchEvent(new Event('change')); } updateTotals(); } function removeQuoteItem(itemId) { document.getElementById(`item-${itemId}`).remove(); updateTotals(); } function moveItemUp(itemId) { const item = document.getElementById(`item-${itemId}`); const prev = item.previousElementSibling; if (prev) { item.parentNode.insertBefore(item, prev); } } function moveItemDown(itemId) { const item = document.getElementById(`item-${itemId}`); const next = item.nextElementSibling; if (next) { item.parentNode.insertBefore(next, item); } } function toggleItem(itemId) { const itemDiv = document.getElementById(`item-${itemId}`); const content = itemDiv.querySelector('.item-content'); const chevron = itemDiv.querySelector('.item-chevron'); if (content.classList.contains('hidden')) { content.classList.remove('hidden'); chevron.style.transform = 'rotate(180deg)'; } else { content.classList.add('hidden'); chevron.style.transform = 'rotate(0deg)'; } } function updateItemSummary(itemId) { const itemDiv = document.getElementById(`item-${itemId}`); const qtyInput = itemDiv.querySelector('[data-field="quantity"]'); const amountInput = itemDiv.querySelector('[data-field="amount"]'); const descEditor = itemDiv.querySelector('.item-description-editor'); // Update summary displays const summaryQty = itemDiv.querySelector('.item-summary-qty'); const summaryDesc = itemDiv.querySelector('.item-summary-desc'); const summaryAmount = itemDiv.querySelector('.item-summary-amount'); if (summaryQty) summaryQty.textContent = qtyInput.value || '0'; if (summaryAmount) summaryAmount.textContent = amountInput.value || '0.00'; if (summaryDesc && descEditor.quillInstance) { const plainText = descEditor.quillInstance.getText().trim(); const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : ''); summaryDesc.textContent = preview || 'New item'; } } function updateTotals() { const items = getQuoteItems(); const taxExempt = document.getElementById('quote-tax-exempt').checked; let subtotal = 0; let hasTbd = false; items.forEach(item => { if (item.is_tbd || item.amount === 'TBD') { hasTbd = true; } else { const amount = parseFloat(item.amount) || 0; subtotal += amount; } }); const taxRate = taxExempt ? 0 : 8.25; const taxAmount = subtotal * taxRate / 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)}`; // Show/hide tax row document.getElementById('tax-row').style.display = taxExempt ? 'none' : 'block'; // Show/hide TBD note section document.getElementById('tbd-note-section').classList.toggle('hidden', !hasTbd); } function getQuoteItems() { const items = []; const itemDivs = document.querySelectorAll('#quote-items > div'); itemDivs.forEach(div => { const content = div.querySelector('.item-content'); const descEditor = content.querySelector('.item-description-editor'); const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : ''; const item = { quantity: content.querySelector('[data-field="quantity"]').value, description: descriptionHTML, rate: content.querySelector('[data-field="rate"]').value, amount: content.querySelector('[data-field="amount"]').value, is_tbd: content.querySelector('[data-field="is_tbd"]').checked }; 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, tbd_note: document.getElementById('quote-tbd-note').value }; try { const quoteId = document.getElementById('quote-id').value; const url = quoteId ? `/api/quotes/${quoteId}` : '/api/quotes'; const method = quoteId ? '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'); } } async function viewQuotePDF(id) { try { const response = await fetch(`/api/quotes/${id}/pdf`, { method: 'POST' }); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Get quote number for filename const quote = quotes.find(q => q.id === id); a.download = `Quote_${quote.quote_number}.pdf`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); } else { alert('Error generating PDF'); } } catch (error) { console.error('Error:', error); alert('Error generating PDF'); } } // Utility functions function setDefaultDate() { const today = new Date().toISOString().split('T')[0]; document.getElementById('quote-date').value = today; } function formatDate(dateString) { const date = new Date(dateString); return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`; }