This commit is contained in:
2026-01-21 19:06:57 -06:00
commit bf1b7dc0f9
14 changed files with 4699 additions and 0 deletions

464
public/app.js Normal file
View File

@@ -0,0 +1,464 @@
// 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()}`;
}

260
public/index.html Normal file
View File

@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quote Management System - Bay Area Affiliates</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.modal {
display: none;
}
.modal.active {
display: flex;
}
</style>
</head>
<body class="bg-gray-100">
<div class="min-h-screen">
<!-- Navigation -->
<nav class="bg-blue-900 text-white shadow-lg">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Bay Area Affiliates, Inc.</h1>
<p class="text-sm text-blue-200">Quote Management System</p>
</div>
<div class="flex space-x-4">
<button onclick="showTab('quotes')" id="tab-quotes" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Quotes</button>
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
<!-- Quotes Tab -->
<div id="quotes-tab" class="tab-content">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Quotes</h2>
<button onclick="openQuoteModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Quote
</button>
</div>
<!-- Quotes List -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Quote #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="quotes-list" class="bg-white divide-y divide-gray-200">
<!-- Quotes will be loaded here -->
</tbody>
</table>
</div>
</div>
<!-- Customers Tab -->
<div id="customers-tab" class="tab-content hidden">
<div class="flex justify-between items-center mb-6">
<h2 class="text-3xl font-bold text-gray-800">Customers</h2>
<button onclick="openCustomerModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
+ New Customer
</button>
</div>
<!-- Customers List -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Account #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="customers-list" class="bg-white divide-y divide-gray-200">
<!-- Customers will be loaded here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Customer Modal -->
<div id="customer-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center">
<div class="relative mx-auto p-8 border w-full max-w-2xl shadow-lg rounded-lg bg-white">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="customer-modal-title">New Customer</h3>
<button onclick="closeCustomerModal()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="customer-form" class="space-y-4">
<input type="hidden" id="customer-id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input type="text" id="customer-name" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address</label>
<input type="text" id="customer-street" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" id="customer-city" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
<input type="text" id="customer-state" required maxlength="2" placeholder="TX"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
<input type="text" id="customer-zip" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Account Number</label>
<input type="text" id="customer-account"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeCustomerModal()"
class="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Customer
</button>
</div>
</form>
</div>
</div>
<!-- Quote Modal -->
<div id="quote-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center">
<div class="relative mx-auto p-8 border w-full max-w-6xl shadow-lg rounded-lg bg-white my-8">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-bold text-gray-900" id="quote-modal-title">New Quote</h3>
<button onclick="closeQuoteModal()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form id="quote-form" class="space-y-6">
<input type="hidden" id="quote-id">
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
<select id="quote-customer" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select Customer...</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Quote Number</label>
<input type="text" id="quote-number" readonly
class="w-full px-4 py-2 border border-gray-300 rounded-md bg-gray-50">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input type="date" id="quote-date" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex items-center">
<input type="checkbox" id="quote-tax-exempt" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="quote-tax-exempt" class="ml-2 block text-sm text-gray-900">
Tax Exempt (Church, Non-Profit, etc.)
</label>
</div>
<!-- Items Section -->
<div>
<div class="flex justify-between items-center mb-3">
<h4 class="text-lg font-semibold text-gray-900">Line Items</h4>
<button type="button" onclick="addQuoteItem()"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
+ Add Item
</button>
</div>
<div id="quote-items" class="space-y-3">
<!-- Items will be added here -->
</div>
</div>
<!-- TBD Note -->
<div id="tbd-note-section" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">TBD Footnote</label>
<input type="text" id="quote-tbd-note"
placeholder="e.g., Total excludes labor charges which will be determined..."
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- Total -->
<div class="border-t pt-4">
<div class="flex justify-end space-x-8 text-lg">
<div class="text-right">
<div class="text-gray-600">Subtotal:</div>
<div class="text-gray-600" id="tax-row">Tax (8.25%):</div>
<div class="font-bold text-xl">Total:</div>
</div>
<div class="text-right">
<div id="quote-subtotal">$0.00</div>
<div id="quote-tax">$0.00</div>
<div class="font-bold text-xl" id="quote-total">$0.00</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 border-t">
<button type="button" onclick="closeQuoteModal()"
class="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Save Quote
</button>
</div>
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>