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 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');
} }

View File

@ -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
View File

@ -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();

View File

@ -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">

View File

@ -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">