688 lines
26 KiB
JavaScript
688 lines
26 KiB
JavaScript
// 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 = '<p class="text-blue-600">Uploading...</p>';
|
||
|
||
try {
|
||
const response = await fetch('/api/upload-logo', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
|
||
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 = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
|
||
}
|
||
}
|
||
|
||
// 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 => `
|
||
<tr>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
|
||
<td class="px-6 py-4 text-sm text-gray-500">${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function updateCustomerDropdown() {
|
||
const select = document.getElementById('quote-customer');
|
||
select.innerHTML = '<option value="">Select Customer...</option>' +
|
||
customers.map(c => `<option value="${c.id}">${c.name}</option>`).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 `
|
||
<tr>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
|
||
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||
<button onclick="viewQuotePDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||
<button onclick="editQuote(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||
<button onclick="deleteQuote(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).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 = `
|
||
<!-- Summary Header (always visible) -->
|
||
<div class="item-header bg-gray-50 px-4 py-3 flex items-center justify-between">
|
||
<div class="flex items-center space-x-2">
|
||
<!-- Move buttons -->
|
||
<button type="button" onclick="moveItemUp(${itemId})" class="text-gray-500 hover:text-gray-700 p-1" title="Move up">
|
||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
<button type="button" onclick="moveItemDown(${itemId})" class="text-gray-500 hover:text-gray-700 p-1" title="Move down">
|
||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center space-x-4 flex-1 cursor-pointer" onclick="toggleItem(${itemId})">
|
||
<span class="text-sm font-medium text-gray-700">Qty: <span class="item-summary-qty">${summaryQty}</span></span>
|
||
<span class="text-sm text-gray-600 flex-1 truncate item-summary-desc">${summaryDesc}</span>
|
||
<span class="text-sm font-medium text-gray-900">$<span class="item-summary-amount">${summaryAmount}</span></span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<svg class="item-chevron w-5 h-5 text-gray-500 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Expandable Content (hidden by default) -->
|
||
<div class="item-content hidden">
|
||
<div class="grid grid-cols-12 gap-3 items-start p-4">
|
||
<div class="col-span-1">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||
<input type="text" data-item="${itemId}" data-field="quantity"
|
||
value="${item ? item.quantity : ''}"
|
||
class="item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div class="col-span-5">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||
<div data-item="${itemId}" data-field="description"
|
||
class="item-description-editor border border-gray-300 rounded-md bg-white"
|
||
style="min-height: 80px;">
|
||
</div>
|
||
<input type="hidden" data-item="${itemId}" data-field="description-html" class="item-description-html">
|
||
</div>
|
||
<div class="col-span-2">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||
<input type="text" data-item="${itemId}" data-field="rate"
|
||
value="${item ? item.rate : ''}"
|
||
class="item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div class="col-span-2">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||
<input type="text" data-item="${itemId}" data-field="amount"
|
||
value="${item ? item.amount : ''}"
|
||
class="item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||
</div>
|
||
<div class="col-span-1">
|
||
<label class="block text-xs font-medium text-gray-700 mb-1">TBD</label>
|
||
<input type="checkbox" data-item="${itemId}" data-field="is_tbd"
|
||
${item && item.is_tbd ? 'checked' : ''}
|
||
class="item-tbd h-5 w-5 mt-2 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
</div>
|
||
<div class="col-span-1 flex items-end">
|
||
<button type="button" onclick="removeQuoteItem(${itemId})"
|
||
class="w-full px-2 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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()}`;
|
||
} |