init
This commit is contained in:
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
825
public/app.js
Normal file
825
public/app.js
Normal file
@@ -0,0 +1,825 @@
|
||||
// Global state
|
||||
let customers = [];
|
||||
let quotes = [];
|
||||
let invoices = [];
|
||||
let currentQuoteId = null;
|
||||
let currentInvoiceId = null;
|
||||
let currentCustomerId = null;
|
||||
let itemCounter = 0;
|
||||
let currentLogoFile = null;
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadCustomers();
|
||||
loadQuotes();
|
||||
loadInvoices();
|
||||
setDefaultDate();
|
||||
checkCurrentLogo();
|
||||
|
||||
// Setup form handlers
|
||||
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
|
||||
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
|
||||
document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit);
|
||||
document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals);
|
||||
document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals);
|
||||
|
||||
// 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 === 'invoices') {
|
||||
loadInvoices();
|
||||
} else if (tabName === 'customers') {
|
||||
loadCustomers();
|
||||
} else if (tabName === 'settings') {
|
||||
checkCurrentLogo();
|
||||
}
|
||||
}
|
||||
|
||||
// Date helper
|
||||
function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const quoteDateEl = document.getElementById('quote-date');
|
||||
const invoiceDateEl = document.getElementById('invoice-date');
|
||||
if (quoteDateEl) quoteDateEl.value = today;
|
||||
if (invoiceDateEl) invoiceDateEl.value = today;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Management
|
||||
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 quoteSelect = document.getElementById('quote-customer');
|
||||
const invoiceSelect = document.getElementById('invoice-customer');
|
||||
|
||||
const options = '<option value="">Select Customer...</option>' +
|
||||
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
|
||||
if (quoteSelect) quoteSelect.innerHTML = options;
|
||||
if (invoiceSelect) invoiceSelect.innerHTML = options;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Quote Management
|
||||
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="convertQuoteToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</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 data = await response.json();
|
||||
|
||||
document.getElementById('quote-customer').value = data.quote.customer_id;
|
||||
const dateOnly = data.quote.quote_date.split('T')[0];
|
||||
document.getElementById('quote-date').value = dateOnly;
|
||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||
|
||||
// Load items
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
data.items.forEach(item => {
|
||||
addQuoteItem(item);
|
||||
});
|
||||
|
||||
updateQuoteTotals();
|
||||
} else {
|
||||
title.textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
setDefaultDate();
|
||||
|
||||
// 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 mb-3';
|
||||
itemDiv.id = `quote-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="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</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="quote-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||
style="min-height: 60px;">
|
||||
</div>
|
||||
</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="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</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="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</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);
|
||||
|
||||
// Initialize Quill editor
|
||||
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (item && item.description) {
|
||||
quill.root.innerHTML = item.description;
|
||||
}
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateQuoteTotals();
|
||||
});
|
||||
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// Auto-calculate amount
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
|
||||
const calculateAmount = () => {
|
||||
if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||
amountInput.value = (qty * rateValue).toFixed(2);
|
||||
}
|
||||
updateQuoteTotals();
|
||||
};
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', updateQuoteTotals);
|
||||
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function removeQuoteItem(itemId) {
|
||||
document.getElementById(`quote-item-${itemId}`).remove();
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function updateQuoteTotals() {
|
||||
const items = getQuoteItems();
|
||||
const taxExempt = document.getElementById('quote-tax-exempt').checked;
|
||||
|
||||
let subtotal = 0;
|
||||
let hasTbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
hasTbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 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)}`;
|
||||
|
||||
document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function getQuoteItems() {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll('#quote-items > div');
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('.quote-item-description-editor');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
|
||||
const item = {
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
};
|
||||
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
|
||||
};
|
||||
|
||||
try {
|
||||
const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
|
||||
const method = currentQuoteId ? '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');
|
||||
}
|
||||
}
|
||||
|
||||
function viewQuotePDF(id) {
|
||||
window.open(`/api/quotes/${id}/pdf`, '_blank');
|
||||
}
|
||||
|
||||
async function convertQuoteToInvoice(quoteId) {
|
||||
if (!confirm('Convert this quote to an invoice?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const invoice = await response.json();
|
||||
alert(`Invoice ${invoice.invoice_number} created successfully!`);
|
||||
loadInvoices();
|
||||
showTab('invoices');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Error converting quote to invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error converting quote to invoice');
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice Management
|
||||
async function loadInvoices() {
|
||||
try {
|
||||
const response = await fetch('/api/invoices');
|
||||
invoices = await response.json();
|
||||
renderInvoices();
|
||||
} catch (error) {
|
||||
console.error('Error loading invoices:', error);
|
||||
alert('Error loading invoices');
|
||||
}
|
||||
}
|
||||
|
||||
function renderInvoices() {
|
||||
const tbody = document.getElementById('invoices-list');
|
||||
tbody.innerHTML = invoices.map(invoice => `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function openInvoiceModal(invoiceId = null) {
|
||||
currentInvoiceId = invoiceId;
|
||||
const modal = document.getElementById('invoice-modal');
|
||||
const title = document.getElementById('invoice-modal-title');
|
||||
|
||||
if (invoiceId) {
|
||||
title.textContent = 'Edit Invoice';
|
||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
||||
const dateOnly = data.invoice.invoice_date.split('T')[0];
|
||||
document.getElementById('invoice-date').value = dateOnly;
|
||||
document.getElementById('invoice-terms').value = data.invoice.terms;
|
||||
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
|
||||
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
|
||||
|
||||
// Load items
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
itemCounter = 0;
|
||||
data.items.forEach(item => {
|
||||
addInvoiceItem(item);
|
||||
});
|
||||
|
||||
updateInvoiceTotals();
|
||||
} else {
|
||||
title.textContent = 'New Invoice';
|
||||
document.getElementById('invoice-form').reset();
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
document.getElementById('invoice-terms').value = 'Net 30';
|
||||
itemCounter = 0;
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addInvoiceItem();
|
||||
}
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeInvoiceModal() {
|
||||
document.getElementById('invoice-modal').classList.remove('active');
|
||||
currentInvoiceId = null;
|
||||
}
|
||||
|
||||
function addInvoiceItem(item = null) {
|
||||
const itemId = itemCounter++;
|
||||
const itemsDiv = document.getElementById('invoice-items');
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'grid grid-cols-12 gap-3 items-start mb-3';
|
||||
itemDiv.id = `invoice-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="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</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="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||
style="min-height: 60px;">
|
||||
</div>
|
||||
</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="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</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="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-1 flex items-end">
|
||||
<button type="button" onclick="removeInvoiceItem(${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);
|
||||
|
||||
// Initialize Quill editor
|
||||
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (item && item.description) {
|
||||
quill.root.innerHTML = item.description;
|
||||
}
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateInvoiceTotals();
|
||||
});
|
||||
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// Auto-calculate amount
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
|
||||
const calculateAmount = () => {
|
||||
if (qtyInput.value && rateInput.value) {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||
amountInput.value = (qty * rateValue).toFixed(2);
|
||||
}
|
||||
updateInvoiceTotals();
|
||||
};
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', updateInvoiceTotals);
|
||||
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function removeInvoiceItem(itemId) {
|
||||
document.getElementById(`invoice-item-${itemId}`).remove();
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function updateInvoiceTotals() {
|
||||
const items = getInvoiceItems();
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt').checked;
|
||||
|
||||
let subtotal = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
});
|
||||
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
|
||||
document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
|
||||
document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
|
||||
|
||||
document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function getInvoiceItems() {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll('#invoice-items > div');
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('.invoice-item-description-editor');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
|
||||
const item = {
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
};
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function handleInvoiceSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const items = getInvoiceItems();
|
||||
|
||||
if (items.length === 0) {
|
||||
alert('Please add at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
customer_id: parseInt(document.getElementById('invoice-customer').value),
|
||||
invoice_date: document.getElementById('invoice-date').value,
|
||||
terms: document.getElementById('invoice-terms').value,
|
||||
auth_code: document.getElementById('invoice-authorization').value,
|
||||
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
|
||||
items: items
|
||||
};
|
||||
|
||||
try {
|
||||
const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices';
|
||||
const method = currentInvoiceId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert('Error saving invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving invoice');
|
||||
}
|
||||
}
|
||||
|
||||
async function editInvoice(id) {
|
||||
await openInvoiceModal(id);
|
||||
}
|
||||
|
||||
async function deleteInvoice(id) {
|
||||
if (!confirm('Are you sure you want to delete this invoice?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
loadInvoices();
|
||||
} else {
|
||||
alert('Error deleting invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting invoice');
|
||||
}
|
||||
}
|
||||
|
||||
function viewInvoicePDF(id) {
|
||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||
}
|
||||
384
public/index.html
Normal file
384
public/index.html
Normal file
@@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quote & Invoice Management - Bay Area Affiliates</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></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 & Invoice Management System</p>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<button onclick="showTab('quotes')" id="tab-quotes" class="px-4 py-2 rounded bg-blue-800 tab-btn">Quotes</button>
|
||||
<button onclick="showTab('invoices')" id="tab-invoices" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Invoices</button>
|
||||
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
|
||||
<button onclick="showTab('settings')" id="tab-settings" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Settings</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>
|
||||
|
||||
<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">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<div id="invoices-tab" class="tab-content hidden">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
||||
+ New Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">Invoice #</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">Terms</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="invoices-list" class="bg-white divide-y divide-gray-200">
|
||||
</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>
|
||||
|
||||
<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">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-content hidden">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-3xl font-bold text-gray-800">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold mb-4">Company Logo</h3>
|
||||
<p class="text-gray-600 mb-4">Upload your company logo to appear on quotes and invoices. Recommended size: 200x200px (PNG or JPG)</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<div id="logo-preview" class="mb-4 hidden">
|
||||
<p class="text-sm text-gray-600 mb-2">Current Logo:</p>
|
||||
<img id="logo-image" src="" alt="Company Logo" class="h-20 border border-gray-300 rounded">
|
||||
</div>
|
||||
|
||||
<input type="file" id="logo-upload" accept="image/png,image/jpeg,image/jpg,image/gif" class="hidden">
|
||||
<button onclick="document.getElementById('logo-upload').click()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
|
||||
Choose Logo
|
||||
</button>
|
||||
<span id="logo-filename" class="ml-4 text-gray-600"></span>
|
||||
</div>
|
||||
|
||||
<button onclick="uploadLogo()" id="upload-btn" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg disabled:bg-gray-400" disabled>
|
||||
Upload Logo
|
||||
</button>
|
||||
|
||||
<div id="upload-status" class="mt-4"></div>
|
||||
</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 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
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 z-50">
|
||||
<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">
|
||||
<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">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 class="flex items-center pt-6">
|
||||
<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</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Items</label>
|
||||
<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"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="space-y-2 text-right">
|
||||
<div class="flex justify-end items-center">
|
||||
<span class="text-sm font-medium text-gray-700 mr-4">Subtotal:</span>
|
||||
<span id="quote-subtotal" class="text-lg font-semibold">$0.00</span>
|
||||
</div>
|
||||
<div id="quote-tax-row" class="flex justify-end items-center">
|
||||
<span class="text-sm font-medium text-gray-700 mr-4">Tax (8.25%):</span>
|
||||
<span id="quote-tax" class="text-lg font-semibold">$0.00</span>
|
||||
</div>
|
||||
<div class="flex justify-end items-center pt-2 border-t border-gray-300">
|
||||
<span class="text-lg font-bold text-gray-900 mr-4">TOTAL:</span>
|
||||
<span id="quote-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeQuoteModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
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>
|
||||
|
||||
<!-- Invoice Modal -->
|
||||
<div id="invoice-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full items-center justify-center z-50">
|
||||
<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="invoice-modal-title">New Invoice</h3>
|
||||
<button onclick="closeInvoiceModal()" 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="invoice-form" class="space-y-6">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||
<select id="invoice-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">Date</label>
|
||||
<input type="date" id="invoice-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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
|
||||
<input type="text" id="invoice-terms" value="Net 30" 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="flex items-center pt-6">
|
||||
<input type="checkbox" id="invoice-tax-exempt"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Authorization (optional)</label>
|
||||
<input type="text" id="invoice-authorization"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="P.O. Number, Authorization Code, etc.">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Items</label>
|
||||
<button type="button" onclick="addInvoiceItem()"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
<div id="invoice-items"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="space-y-2 text-right">
|
||||
<div class="flex justify-end items-center">
|
||||
<span class="text-sm font-medium text-gray-700 mr-4">Subtotal:</span>
|
||||
<span id="invoice-subtotal" class="text-lg font-semibold">$0.00</span>
|
||||
</div>
|
||||
<div id="invoice-tax-row" class="flex justify-end items-center">
|
||||
<span class="text-sm font-medium text-gray-700 mr-4">Tax (8.25%):</span>
|
||||
<span id="invoice-tax" class="text-lg font-semibold">$0.00</span>
|
||||
</div>
|
||||
<div class="flex justify-end items-center pt-2 border-t border-gray-300">
|
||||
<span class="text-lg font-bold text-gray-900 mr-4">TOTAL:</span>
|
||||
<span id="invoice-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeInvoiceModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Invoice
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user