// 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 = `
`;
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 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()}`;
}