last state
This commit is contained in:
parent
5e46fa06f1
commit
eef316402c
350
public/app.js
350
public/app.js
|
|
@ -8,6 +8,66 @@ let currentCustomerId = null;
|
||||||
let itemCounter = 0;
|
let itemCounter = 0;
|
||||||
let currentLogoFile = null;
|
let currentLogoFile = null;
|
||||||
|
|
||||||
|
// Alpine.js Customer Search Component
|
||||||
|
function customerSearch(type) {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
selectedId: '',
|
||||||
|
selectedName: '',
|
||||||
|
open: false,
|
||||||
|
highlighted: 0,
|
||||||
|
|
||||||
|
get filteredCustomers() {
|
||||||
|
if (!this.search) {
|
||||||
|
return customers.slice(0, 50); // Show first 50 if no search
|
||||||
|
}
|
||||||
|
const searchLower = this.search.toLowerCase();
|
||||||
|
return customers.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(searchLower) ||
|
||||||
|
c.city.toLowerCase().includes(searchLower) ||
|
||||||
|
(c.account_number && c.account_number.includes(searchLower))
|
||||||
|
).slice(0, 50); // Limit to 50 results
|
||||||
|
},
|
||||||
|
|
||||||
|
selectCustomer(customer) {
|
||||||
|
this.selectedId = customer.id;
|
||||||
|
this.selectedName = customer.name;
|
||||||
|
this.search = customer.name;
|
||||||
|
this.open = false;
|
||||||
|
this.highlighted = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightNext() {
|
||||||
|
if (this.highlighted < this.filteredCustomers.length - 1) {
|
||||||
|
this.highlighted++;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPrev() {
|
||||||
|
if (this.highlighted > 0) {
|
||||||
|
this.highlighted--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectHighlighted() {
|
||||||
|
if (this.filteredCustomers[this.highlighted]) {
|
||||||
|
this.selectCustomer(this.filteredCustomers[this.highlighted]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.search = '';
|
||||||
|
this.selectedId = '';
|
||||||
|
this.selectedName = '';
|
||||||
|
this.open = false;
|
||||||
|
this.highlighted = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make it globally available for Alpine
|
||||||
|
window.customerSearch = customerSearch;
|
||||||
|
|
||||||
// Initialize app
|
// Initialize app
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
|
@ -129,7 +189,6 @@ async function loadCustomers() {
|
||||||
const response = await fetch('/api/customers');
|
const response = await fetch('/api/customers');
|
||||||
customers = await response.json();
|
customers = await response.json();
|
||||||
renderCustomers();
|
renderCustomers();
|
||||||
updateCustomerDropdown();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading customers:', error);
|
console.error('Error loading customers:', error);
|
||||||
alert('Error loading customers');
|
alert('Error loading customers');
|
||||||
|
|
@ -151,17 +210,6 @@ function renderCustomers() {
|
||||||
`).join('');
|
`).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) {
|
function openCustomerModal(customerId = null) {
|
||||||
currentCustomerId = customerId;
|
currentCustomerId = customerId;
|
||||||
const modal = document.getElementById('customer-modal');
|
const modal = document.getElementById('customer-modal');
|
||||||
|
|
@ -289,7 +337,28 @@ async function openQuoteModal(quoteId = null) {
|
||||||
const response = await fetch(`/api/quotes/${quoteId}`);
|
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Set customer in Alpine component
|
||||||
|
const customer = customers.find(c => c.id === data.quote.customer_id);
|
||||||
|
if (customer) {
|
||||||
|
// Find the Alpine component and update it
|
||||||
|
const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
|
||||||
|
if (customerInput) {
|
||||||
|
// Trigger Alpine to update
|
||||||
|
customerInput.value = customer.name;
|
||||||
|
customerInput.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
// Set the values directly on the Alpine component
|
||||||
|
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||||
|
if (alpineData) {
|
||||||
|
alpineData.search = customer.name;
|
||||||
|
alpineData.selectedId = customer.id;
|
||||||
|
alpineData.selectedName = customer.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('quote-customer').value = data.quote.customer_id;
|
document.getElementById('quote-customer').value = data.quote.customer_id;
|
||||||
|
|
||||||
const dateOnly = data.quote.quote_date.split('T')[0];
|
const dateOnly = data.quote.quote_date.split('T')[0];
|
||||||
document.getElementById('quote-date').value = dateOnly;
|
document.getElementById('quote-date').value = dateOnly;
|
||||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||||
|
|
@ -326,40 +395,71 @@ function addQuoteItem(item = null) {
|
||||||
const itemsDiv = document.getElementById('quote-items');
|
const itemsDiv = document.getElementById('quote-items');
|
||||||
|
|
||||||
const itemDiv = document.createElement('div');
|
const itemDiv = document.createElement('div');
|
||||||
itemDiv.className = 'grid grid-cols-12 gap-3 items-start mb-3';
|
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||||
itemDiv.id = `quote-item-${itemId}`;
|
itemDiv.id = `quote-item-${itemId}`;
|
||||||
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
|
// Get preview text
|
||||||
|
const previewQty = item ? item.quantity : '';
|
||||||
|
const previewAmount = item ? item.amount : '$0.00';
|
||||||
|
let previewDesc = 'New item';
|
||||||
|
if (item && item.description) {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = item.description;
|
||||||
|
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
||||||
|
}
|
||||||
|
|
||||||
itemDiv.innerHTML = `
|
itemDiv.innerHTML = `
|
||||||
<div class="col-span-1">
|
<!-- Accordion Header -->
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
<div @click="open = !open" class="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50">
|
||||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
<div class="flex items-center space-x-4 flex-1">
|
||||||
value="${item ? item.quantity : ''}"
|
<svg x-show="!open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</div>
|
</svg>
|
||||||
<div class="col-span-5">
|
<svg x-show="open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
<div data-item="${itemId}" data-field="description"
|
</svg>
|
||||||
class="quote-item-description-editor border border-gray-300 rounded-md bg-white"
|
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||||
style="min-height: 60px;">
|
<span class="text-sm text-gray-600 flex-1 truncate item-desc-preview">${previewDesc}</span>
|
||||||
|
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
<!-- Accordion Content -->
|
||||||
<input type="text" data-item="${itemId}" data-field="rate"
|
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||||
value="${item ? item.rate : ''}"
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<div class="col-span-1">
|
||||||
</div>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||||
<div class="col-span-2">
|
<input type="text" data-item="${itemId}" data-field="quantity"
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
value="${item ? item.quantity : ''}"
|
||||||
<input type="text" data-item="${itemId}" data-field="amount"
|
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.amount : ''}"
|
</div>
|
||||||
class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<div class="col-span-5">
|
||||||
</div>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||||
<div class="col-span-1 flex items-end">
|
<div data-item="${itemId}" data-field="description"
|
||||||
<button type="button" onclick="removeQuoteItem(${itemId})"
|
class="quote-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||||
class="w-full px-2 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
style="min-height: 60px;">
|
||||||
×
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -383,12 +483,13 @@ function addQuoteItem(item = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
quill.on('text-change', () => {
|
||||||
|
updateItemPreview(itemDiv, itemId);
|
||||||
updateQuoteTotals();
|
updateQuoteTotals();
|
||||||
});
|
});
|
||||||
|
|
||||||
editorDiv.quillInstance = quill;
|
editorDiv.quillInstance = quill;
|
||||||
|
|
||||||
// Auto-calculate amount
|
// Auto-calculate amount and update preview
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
|
@ -399,16 +500,40 @@ function addQuoteItem(item = null) {
|
||||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||||
amountInput.value = (qty * rateValue).toFixed(2);
|
amountInput.value = (qty * rateValue).toFixed(2);
|
||||||
}
|
}
|
||||||
|
updateItemPreview(itemDiv, itemId);
|
||||||
updateQuoteTotals();
|
updateQuoteTotals();
|
||||||
};
|
};
|
||||||
|
|
||||||
qtyInput.addEventListener('input', calculateAmount);
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
rateInput.addEventListener('input', calculateAmount);
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
amountInput.addEventListener('input', updateQuoteTotals);
|
amountInput.addEventListener('input', () => {
|
||||||
|
updateItemPreview(itemDiv, itemId);
|
||||||
|
updateQuoteTotals();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateItemPreview(itemDiv, itemId);
|
||||||
updateQuoteTotals();
|
updateQuoteTotals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateItemPreview(itemDiv, itemId) {
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||||
|
|
||||||
|
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||||
|
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||||
|
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||||
|
|
||||||
|
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
|
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
if (descPreview && editorDiv.quillInstance) {
|
||||||
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
|
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
||||||
|
descPreview.textContent = preview || 'New item';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeQuoteItem(itemId) {
|
function removeQuoteItem(itemId) {
|
||||||
document.getElementById(`quote-item-${itemId}`).remove();
|
document.getElementById(`quote-item-${itemId}`).remove();
|
||||||
updateQuoteTotals();
|
updateQuoteTotals();
|
||||||
|
|
@ -548,7 +673,7 @@ async function convertQuoteToInvoice(quoteId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice Management
|
// Invoice Management - Same accordion pattern
|
||||||
async function loadInvoices() {
|
async function loadInvoices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/invoices');
|
const response = await fetch('/api/invoices');
|
||||||
|
|
@ -588,6 +713,23 @@ async function openInvoiceModal(invoiceId = null) {
|
||||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Set customer in Alpine component
|
||||||
|
const customer = customers.find(c => c.id === data.invoice.customer_id);
|
||||||
|
if (customer) {
|
||||||
|
const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
|
||||||
|
if (customerInput) {
|
||||||
|
customerInput.value = customer.name;
|
||||||
|
customerInput.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||||
|
if (alpineData) {
|
||||||
|
alpineData.search = customer.name;
|
||||||
|
alpineData.selectedId = customer.id;
|
||||||
|
alpineData.selectedName = customer.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
||||||
const dateOnly = data.invoice.invoice_date.split('T')[0];
|
const dateOnly = data.invoice.invoice_date.split('T')[0];
|
||||||
document.getElementById('invoice-date').value = dateOnly;
|
document.getElementById('invoice-date').value = dateOnly;
|
||||||
|
|
@ -628,40 +770,71 @@ function addInvoiceItem(item = null) {
|
||||||
const itemsDiv = document.getElementById('invoice-items');
|
const itemsDiv = document.getElementById('invoice-items');
|
||||||
|
|
||||||
const itemDiv = document.createElement('div');
|
const itemDiv = document.createElement('div');
|
||||||
itemDiv.className = 'grid grid-cols-12 gap-3 items-start mb-3';
|
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||||
itemDiv.id = `invoice-item-${itemId}`;
|
itemDiv.id = `invoice-item-${itemId}`;
|
||||||
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
|
// Get preview text
|
||||||
|
const previewQty = item ? item.quantity : '';
|
||||||
|
const previewAmount = item ? item.amount : '$0.00';
|
||||||
|
let previewDesc = 'New item';
|
||||||
|
if (item && item.description) {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = item.description;
|
||||||
|
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
||||||
|
}
|
||||||
|
|
||||||
itemDiv.innerHTML = `
|
itemDiv.innerHTML = `
|
||||||
<div class="col-span-1">
|
<!-- Accordion Header -->
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
<div @click="open = !open" class="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50">
|
||||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
<div class="flex items-center space-x-4 flex-1">
|
||||||
value="${item ? item.quantity : ''}"
|
<svg x-show="!open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</div>
|
</svg>
|
||||||
<div class="col-span-5">
|
<svg x-show="open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
<div data-item="${itemId}" data-field="description"
|
</svg>
|
||||||
class="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
|
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||||
style="min-height: 60px;">
|
<span class="text-sm text-gray-600 flex-1 truncate item-desc-preview">${previewDesc}</span>
|
||||||
|
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
<!-- Accordion Content -->
|
||||||
<input type="text" data-item="${itemId}" data-field="rate"
|
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||||
value="${item ? item.rate : ''}"
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<div class="col-span-1">
|
||||||
</div>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||||
<div class="col-span-2">
|
<input type="text" data-item="${itemId}" data-field="quantity"
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
value="${item ? item.quantity : ''}"
|
||||||
<input type="text" data-item="${itemId}" data-field="amount"
|
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.amount : ''}"
|
</div>
|
||||||
class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
<div class="col-span-5">
|
||||||
</div>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||||
<div class="col-span-1 flex items-end">
|
<div data-item="${itemId}" data-field="description"
|
||||||
<button type="button" onclick="removeInvoiceItem(${itemId})"
|
class="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
|
||||||
class="w-full px-2 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
style="min-height: 60px;">
|
||||||
×
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -685,12 +858,13 @@ function addInvoiceItem(item = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
quill.on('text-change', () => {
|
||||||
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
});
|
});
|
||||||
|
|
||||||
editorDiv.quillInstance = quill;
|
editorDiv.quillInstance = quill;
|
||||||
|
|
||||||
// Auto-calculate amount
|
// Auto-calculate amount and update preview
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
|
@ -701,16 +875,40 @@ function addInvoiceItem(item = null) {
|
||||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||||
amountInput.value = (qty * rateValue).toFixed(2);
|
amountInput.value = (qty * rateValue).toFixed(2);
|
||||||
}
|
}
|
||||||
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
};
|
};
|
||||||
|
|
||||||
qtyInput.addEventListener('input', calculateAmount);
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
rateInput.addEventListener('input', calculateAmount);
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
amountInput.addEventListener('input', updateInvoiceTotals);
|
amountInput.addEventListener('input', () => {
|
||||||
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
|
updateInvoiceTotals();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateInvoiceItemPreview(itemDiv, itemId) {
|
||||||
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||||
|
|
||||||
|
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||||
|
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||||
|
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||||
|
|
||||||
|
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
|
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
if (descPreview && editorDiv.quillInstance) {
|
||||||
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
|
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
||||||
|
descPreview.textContent = preview || 'New item';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeInvoiceItem(itemId) {
|
function removeInvoiceItem(itemId) {
|
||||||
document.getElementById(`invoice-item-${itemId}`).remove();
|
document.getElementById(`invoice-item-${itemId}`).remove();
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
|
|
@ -822,4 +1020,4 @@ async function deleteInvoice(id) {
|
||||||
|
|
||||||
function viewInvoicePDF(id) {
|
function viewInvoicePDF(id) {
|
||||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
<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>
|
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -231,12 +232,41 @@
|
||||||
|
|
||||||
<form id="quote-form" class="space-y-6">
|
<form id="quote-form" class="space-y-6">
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div x-data="customerSearch('quote')" class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||||
<select id="quote-customer" required
|
<div class="relative">
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
<input
|
||||||
<option value="">Select Customer...</option>
|
type="text"
|
||||||
</select>
|
x-model="search"
|
||||||
|
@click="open = true"
|
||||||
|
@focus="open = true"
|
||||||
|
@keydown.escape="open = false"
|
||||||
|
@keydown.arrow-down.prevent="highlightNext()"
|
||||||
|
@keydown.arrow-up.prevent="highlightPrev()"
|
||||||
|
@keydown.enter.prevent="selectHighlighted()"
|
||||||
|
placeholder="Search customer..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<input type="hidden" id="quote-customer" :value="selectedId" required>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open && filteredCustomers.length > 0"
|
||||||
|
@click.away="open = false"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||||
|
>
|
||||||
|
<template x-for="(customer, index) in filteredCustomers" :key="customer.id">
|
||||||
|
<div
|
||||||
|
@click="selectCustomer(customer)"
|
||||||
|
:class="{'bg-blue-100': index === highlighted, 'hover:bg-gray-100': index !== highlighted}"
|
||||||
|
class="px-4 py-2 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium" x-text="customer.name"></div>
|
||||||
|
<div class="text-xs text-gray-500" x-text="customer.city + ', ' + customer.state"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
||||||
|
|
@ -306,12 +336,41 @@
|
||||||
|
|
||||||
<form id="invoice-form" class="space-y-6">
|
<form id="invoice-form" class="space-y-6">
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-4 gap-4">
|
||||||
<div>
|
<div x-data="customerSearch('invoice')" class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||||
<select id="invoice-customer" required
|
<div class="relative">
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
<input
|
||||||
<option value="">Select Customer...</option>
|
type="text"
|
||||||
</select>
|
x-model="search"
|
||||||
|
@click="open = true"
|
||||||
|
@focus="open = true"
|
||||||
|
@keydown.escape="open = false"
|
||||||
|
@keydown.arrow-down.prevent="highlightNext()"
|
||||||
|
@keydown.arrow-up.prevent="highlightPrev()"
|
||||||
|
@keydown.enter.prevent="selectHighlighted()"
|
||||||
|
placeholder="Search customer..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<input type="hidden" id="invoice-customer" :value="selectedId" required>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open && filteredCustomers.length > 0"
|
||||||
|
@click.away="open = false"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||||
|
>
|
||||||
|
<template x-for="(customer, index) in filteredCustomers" :key="customer.id">
|
||||||
|
<div
|
||||||
|
@click="selectCustomer(customer)"
|
||||||
|
:class="{'bg-blue-100': index === highlighted, 'hover:bg-gray-100': index !== highlighted}"
|
||||||
|
class="px-4 py-2 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium" x-text="customer.name"></div>
|
||||||
|
<div class="text-xs text-gray-500" x-text="customer.city + ', ' + customer.state"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
|
||||||
|
|
@ -381,4 +440,4 @@
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
234
server.js
234
server.js
|
|
@ -638,14 +638,23 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate items HTML
|
// Generate items HTML
|
||||||
let itemsHTML = itemsResult.rows.map(item => `
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) {
|
||||||
|
rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="qty">${item.quantity}</td>
|
<td class="qty">${item.quantity}</td>
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${item.rate}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
// Add totals
|
// Add totals
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
|
|
@ -758,14 +767,23 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate items HTML
|
// Generate items HTML
|
||||||
let itemsHTML = itemsResult.rows.map(item => `
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) {
|
||||||
|
rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="qty">${item.quantity}</td>
|
<td class="qty">${item.quantity}</td>
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${item.rate}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
// Add totals
|
// Add totals
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
|
|
@ -839,6 +857,210 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Nach den PDF-Endpoints, vor "Start server", einfügen:
|
||||||
|
|
||||||
|
// HTML Debug Endpoints
|
||||||
|
app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quoteResult = await pool.query(`
|
||||||
|
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM quotes q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (quoteResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Quote not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = quoteResult.rows[0];
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
||||||
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
|
let logoHTML = '';
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
||||||
|
const logoData = await fs.readFile(logoPath);
|
||||||
|
const logoBase64 = logoData.toString('base64');
|
||||||
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) {
|
||||||
|
rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="qty">${item.quantity}</td>
|
||||||
|
<td class="description">${item.description}</td>
|
||||||
|
<td class="rate">${rateFormatted}</td>
|
||||||
|
<td class="amount">${item.amount}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
|
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
if (!quote.tax_exempt) {
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%):</td>
|
||||||
|
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
<td class="total-amount">$${parseFloat(quote.total).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
let tbdNote = '';
|
||||||
|
if (quote.has_tbd) {
|
||||||
|
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||||
|
.replace('{{CUSTOMER_STREET}}', quote.street || '')
|
||||||
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||||
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||||
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||||
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||||
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||||
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||||
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
|
.replace('{{TBD_NOTE}}', tbdNote);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTML] ERROR:', error);
|
||||||
|
res.status(500).json({ error: 'Error generating HTML' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoiceResult = await pool.query(`
|
||||||
|
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (invoiceResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
const itemsResult = await pool.query(
|
||||||
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
||||||
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
|
let logoHTML = '';
|
||||||
|
try {
|
||||||
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
||||||
|
const logoData = await fs.readFile(logoPath);
|
||||||
|
const logoBase64 = logoData.toString('base64');
|
||||||
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
|
let rateFormatted = item.rate;
|
||||||
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
|
if (!isNaN(rateNum)) {
|
||||||
|
rateFormatted = rateNum.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="qty">${item.quantity}</td>
|
||||||
|
<td class="description">${item.description}</td>
|
||||||
|
<td class="rate">${rateFormatted}</td>
|
||||||
|
<td class="amount">${item.amount}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
|
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
if (!invoice.tax_exempt) {
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
||||||
|
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsHTML += `
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
<td class="total-amount">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="footer-row">
|
||||||
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
const authHTML = invoice.auth_code ?
|
||||||
|
`<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
||||||
|
.replace('{{CUSTOMER_STREET}}', invoice.street || '')
|
||||||
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||||||
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||||||
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||||||
|
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number)
|
||||||
|
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
||||||
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||||||
|
.replace('{{TERMS}}', invoice.terms)
|
||||||
|
.replace('{{AUTHORIZATION}}', authHTML)
|
||||||
|
.replace('{{ITEMS}}', itemsHTML);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTML] ERROR:', error);
|
||||||
|
res.status(500).json({ error: 'Error generating HTML' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Start server and browser
|
// Start server and browser
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
await initBrowser();
|
await initBrowser();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
border-bottom: 2px solid #333;
|
border-bottom: 2px solid #333;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-info {
|
.company-info {
|
||||||
|
|
@ -193,6 +194,14 @@
|
||||||
.logo-size {
|
.logo-size {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
.document-type {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -219,6 +228,7 @@
|
||||||
accounting@bayarea-cc.com
|
accounting@bayarea-cc.com
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="document-type">INVOICE</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bill-to-section">
|
<div class="bill-to-section">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
border-bottom: 2px solid #333;
|
border-bottom: 2px solid #333;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-info {
|
.company-info {
|
||||||
|
|
@ -193,6 +194,14 @@
|
||||||
.logo-size {
|
.logo-size {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
.document-type {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -219,6 +228,7 @@
|
||||||
support@bayarea-cc.com
|
support@bayarea-cc.com
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="document-type">QUOTE</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bill-to-section">
|
<div class="bill-to-section">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue