quote-system/public/app.js

465 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global state
let customers = [];
let quotes = [];
let currentQuoteId = null;
let currentCustomerId = null;
let itemCounter = 0;
// Initialize app
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
loadQuotes();
setDefaultDate();
// 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);
});
// 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();
}
}
// 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;
document.getElementById('quote-date').value = quote.quote_date;
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 = 'grid grid-cols-12 gap-3 items-start';
itemDiv.id = `item-${itemId}`;
itemDiv.innerHTML = `
<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>
<textarea data-item="${itemId}" data-field="description" rows="2"
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">${item ? item.description : ''}</textarea>
</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>
`;
itemsDiv.appendChild(itemDiv);
// Add event listeners
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 = '';
}
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 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 item = {
quantity: div.querySelector('[data-field="quantity"]').value,
description: div.querySelector('[data-field="description"]').value,
rate: div.querySelector('[data-field="rate"]').value,
amount: div.querySelector('[data-field="amount"]').value,
is_tbd: div.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()}`;
}