last state

This commit is contained in:
Andreas Knuth 2026-02-02 16:15:53 -06:00
parent 5e46fa06f1
commit eef316402c
5 changed files with 592 additions and 93 deletions

View File

@ -8,6 +8,66 @@ let currentCustomerId = null;
let itemCounter = 0;
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
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
@ -129,7 +189,6 @@ async function loadCustomers() {
const response = await fetch('/api/customers');
customers = await response.json();
renderCustomers();
updateCustomerDropdown();
} catch (error) {
console.error('Error loading customers:', error);
alert('Error loading customers');
@ -151,17 +210,6 @@ function renderCustomers() {
`).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');
@ -289,7 +337,28 @@ async function openQuoteModal(quoteId = null) {
const response = await fetch(`/api/quotes/${quoteId}`);
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;
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;
@ -326,40 +395,71 @@ function addQuoteItem(item = null) {
const itemsDiv = document.getElementById('quote-items');
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.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 = `
<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;">
<!-- Accordion Header -->
<div @click="open = !open" class="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50">
<div class="flex items-center space-x-4 flex-1">
<svg x-show="!open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<svg x-show="open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
<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 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>
<!-- Accordion Content -->
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
<div class="grid grid-cols-12 gap-3 items-start">
<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>
</div>
</div>
`;
@ -383,12 +483,13 @@ function addQuoteItem(item = null) {
}
quill.on('text-change', () => {
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
});
editorDiv.quillInstance = quill;
// Auto-calculate amount
// Auto-calculate amount and update preview
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const rateInput = itemDiv.querySelector('[data-field="rate"]');
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;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
amountInput.addEventListener('input', updateQuoteTotals);
amountInput.addEventListener('input', () => {
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
});
updateItemPreview(itemDiv, itemId);
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) {
document.getElementById(`quote-item-${itemId}`).remove();
updateQuoteTotals();
@ -548,7 +673,7 @@ async function convertQuoteToInvoice(quoteId) {
}
}
// Invoice Management
// Invoice Management - Same accordion pattern
async function loadInvoices() {
try {
const response = await fetch('/api/invoices');
@ -588,6 +713,23 @@ async function openInvoiceModal(invoiceId = null) {
const response = await fetch(`/api/invoices/${invoiceId}`);
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;
const dateOnly = data.invoice.invoice_date.split('T')[0];
document.getElementById('invoice-date').value = dateOnly;
@ -628,40 +770,71 @@ function addInvoiceItem(item = null) {
const itemsDiv = document.getElementById('invoice-items');
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.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 = `
<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;">
<!-- Accordion Header -->
<div @click="open = !open" class="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50">
<div class="flex items-center space-x-4 flex-1">
<svg x-show="!open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<svg x-show="open" class="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
<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 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>
<!-- Accordion Content -->
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
<div class="grid grid-cols-12 gap-3 items-start">
<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>
</div>
</div>
`;
@ -685,12 +858,13 @@ function addInvoiceItem(item = null) {
}
quill.on('text-change', () => {
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
});
editorDiv.quillInstance = quill;
// Auto-calculate amount
// Auto-calculate amount and update preview
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const rateInput = itemDiv.querySelector('[data-field="rate"]');
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;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
amountInput.addEventListener('input', updateInvoiceTotals);
amountInput.addEventListener('input', () => {
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
});
updateInvoiceItemPreview(itemDiv, itemId);
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) {
document.getElementById(`invoice-item-${itemId}`).remove();
updateInvoiceTotals();

View File

@ -7,6 +7,7 @@
<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>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
.modal {
display: none;
@ -231,12 +232,41 @@
<form id="quote-form" class="space-y-6">
<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>
<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 class="relative">
<input
type="text"
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>
<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">
<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>
<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 class="relative">
<input
type="text"
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>
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>

234
server.js
View File

@ -638,14 +638,23 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
}
// 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>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${item.rate}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`).join('');
`;
}).join('');
// Add totals
itemsHTML += `
@ -758,14 +767,23 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
}
// 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>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${item.rate}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>
`).join('');
`;
}).join('');
// Add totals
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
async function startServer() {
await initBrowser();

View File

@ -29,6 +29,7 @@
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #333;
position: relative;
}
.company-info {
@ -193,6 +194,14 @@
.logo-size {
height: 40px;
}
.document-type {
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
position: absolute;
bottom: 0;
}
</style>
</head>
<body>
@ -219,6 +228,7 @@
accounting@bayarea-cc.com
</div>
</div>
<div class="document-type">INVOICE</div>
</div>
<div class="bill-to-section">

View File

@ -29,6 +29,7 @@
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #333;
position: relative;
}
.company-info {
@ -193,6 +194,14 @@
.logo-size {
height: 40px;
}
.document-type {
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
position: absolute;
bottom: 0;
}
</style>
</head>
<body>
@ -219,6 +228,7 @@
support@bayarea-cc.com
</div>
</div>
<div class="document-type">QUOTE</div>
</div>
<div class="bill-to-section">