invoice-system/public/app.js

1116 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global state
let customers = [];
let quotes = [];
let invoices = [];
let currentQuoteId = null;
let currentInvoiceId = null;
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();
loadQuotes();
loadInvoices();
setDefaultDate();
checkCurrentLogo();
// Setup form handlers
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
document.getElementById('invoice-form').addEventListener('submit', handleInvoiceSubmit);
document.getElementById('quote-tax-exempt').addEventListener('change', updateQuoteTotals);
document.getElementById('invoice-tax-exempt').addEventListener('change', updateInvoiceTotals);
// Setup logo upload handler
document.getElementById('logo-upload').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
currentLogoFile = file;
document.getElementById('logo-filename').textContent = file.name;
document.getElementById('upload-btn').disabled = false;
}
});
});
// Tab Management
function showTab(tabName) {
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800'));
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
if (tabName === 'quotes') {
loadQuotes();
} else if (tabName === 'invoices') {
loadInvoices();
} else if (tabName === 'customers') {
loadCustomers();
} else if (tabName === 'settings') {
checkCurrentLogo();
}
}
// Date helper
function setDefaultDate() {
const today = new Date().toISOString().split('T')[0];
const quoteDateEl = document.getElementById('quote-date');
const invoiceDateEl = document.getElementById('invoice-date');
if (quoteDateEl) quoteDateEl.value = today;
if (invoiceDateEl) invoiceDateEl.value = today;
}
function formatDate(date) {
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
}
// Logo Management
async function checkCurrentLogo() {
try {
const response = await fetch('/api/logo-info');
if (response.ok) {
const data = await response.json();
if (data.hasLogo) {
document.getElementById('logo-preview').classList.remove('hidden');
document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now();
}
}
} catch (error) {
console.error('Error checking logo:', error);
}
}
async function uploadLogo() {
if (!currentLogoFile) {
alert('Please select a file first');
return;
}
const formData = new FormData();
formData.append('logo', currentLogoFile);
const statusDiv = document.getElementById('upload-status');
statusDiv.innerHTML = '<p class="text-blue-600">Uploading...</p>';
try {
const response = await fetch('/api/upload-logo', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
document.getElementById('logo-preview').classList.remove('hidden');
document.getElementById('logo-image').src = data.path + '?t=' + Date.now();
document.getElementById('upload-btn').disabled = true;
currentLogoFile = null;
document.getElementById('logo-filename').textContent = '';
document.getElementById('logo-upload').value = '';
} else {
const error = await response.json();
statusDiv.innerHTML = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
}
} catch (error) {
console.error('Upload error:', error);
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
}
}
// Customer Management
async function loadCustomers() {
try {
const response = await fetch('/api/customers');
customers = await response.json();
renderCustomers();
} catch (error) {
console.error('Error loading customers:', error);
alert('Error loading customers');
}
}
function renderCustomers() {
const tbody = document.getElementById('customers-list');
tbody.innerHTML = customers.map(customer => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
<td class="px-6 py-4 text-sm text-gray-500">${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`).join('');
}
function openCustomerModal(customerId = null) {
currentCustomerId = customerId;
const modal = document.getElementById('customer-modal');
const title = document.getElementById('customer-modal-title');
if (customerId) {
title.textContent = 'Edit Customer';
const customer = customers.find(c => c.id === customerId);
document.getElementById('customer-id').value = customer.id;
document.getElementById('customer-name').value = customer.name;
document.getElementById('customer-street').value = customer.street;
document.getElementById('customer-city').value = customer.city;
document.getElementById('customer-state').value = customer.state;
document.getElementById('customer-zip').value = customer.zip_code;
document.getElementById('customer-account').value = customer.account_number || '';
} else {
title.textContent = 'New Customer';
document.getElementById('customer-form').reset();
document.getElementById('customer-id').value = '';
}
modal.classList.add('active');
}
function closeCustomerModal() {
document.getElementById('customer-modal').classList.remove('active');
currentCustomerId = null;
}
async function handleCustomerSubmit(e) {
e.preventDefault();
const data = {
name: document.getElementById('customer-name').value,
street: document.getElementById('customer-street').value,
city: document.getElementById('customer-city').value,
state: document.getElementById('customer-state').value.toUpperCase(),
zip_code: document.getElementById('customer-zip').value,
account_number: document.getElementById('customer-account').value
};
try {
const customerId = document.getElementById('customer-id').value;
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
const method = customerId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeCustomerModal();
loadCustomers();
} else {
alert('Error saving customer');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving customer');
}
}
async function editCustomer(id) {
openCustomerModal(id);
}
async function deleteCustomer(id) {
if (!confirm('Are you sure you want to delete this customer?')) return;
try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' });
if (response.ok) {
loadCustomers();
} else {
alert('Error deleting customer');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting customer');
}
}
// Quote Management
async function loadQuotes() {
try {
const response = await fetch('/api/quotes');
quotes = await response.json();
renderQuotes();
} catch (error) {
console.error('Error loading quotes:', error);
alert('Error loading quotes');
}
}
function renderQuotes() {
const tbody = document.getElementById('quotes-list');
tbody.innerHTML = quotes.map(quote => {
const total = quote.has_tbd ? `$${parseFloat(quote.total).toFixed(2)}*` : `$${parseFloat(quote.total).toFixed(2)}`;
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="viewQuotePDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="convertQuoteToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
<button onclick="editQuote(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteQuote(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`;
}).join('');
}
async function openQuoteModal(quoteId = null) {
currentQuoteId = quoteId;
const modal = document.getElementById('quote-modal');
const title = document.getElementById('quote-modal-title');
if (quoteId) {
title.textContent = 'Edit Quote';
const response = await fetch(`/api/quotes/${quoteId}`);
const data = await response.json();
// 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;
// Load items
document.getElementById('quote-items').innerHTML = '';
itemCounter = 0;
data.items.forEach(item => {
addQuoteItem(item);
});
updateQuoteTotals();
} else {
title.textContent = 'New Quote';
document.getElementById('quote-form').reset();
document.getElementById('quote-items').innerHTML = '';
itemCounter = 0;
setDefaultDate();
// Add one default item
addQuoteItem();
}
modal.classList.add('active');
}
function closeQuoteModal() {
document.getElementById('quote-modal').classList.remove('active');
currentQuoteId = null;
}
function addQuoteItem(item = null) {
const itemId = itemCounter++;
const itemsDiv = document.getElementById('quote-items');
const itemDiv = document.createElement('div');
itemDiv.className = '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 = `
<!-- Accordion Header -->
<div class="flex items-center p-4">
<!-- Move Buttons (Left) - Outside accordion click area -->
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
<button type="button" onclick="moveQuoteItemUp(${itemId})"
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
title="Move Up">
</button>
<button type="button" onclick="moveQuoteItemDown(${itemId})"
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
title="Move Down">
</button>
</div>
<!-- Accordion Toggle & Content (Center) -->
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" 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 mr-4" 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 mx-4 item-desc-preview">${previewDesc}</span>
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
</div>
<!-- Delete Button (Right) - Outside accordion click area -->
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();"
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
×
</button>
</div>
<!-- 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-6">
<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-3">
<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>
</div>
`;
itemsDiv.appendChild(itemDiv);
// Initialize Quill editor
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
const quill = new Quill(editorDiv, {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['clean']
]
}
});
if (item && item.description) {
quill.root.innerHTML = item.description;
}
quill.on('text-change', () => {
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
});
editorDiv.quillInstance = quill;
// 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"]');
const calculateAmount = () => {
if (qtyInput.value && rateInput.value && rateInput.value.toUpperCase() !== 'TBD') {
const qty = parseFloat(qtyInput.value) || 0;
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateItemPreview(itemDiv, itemId);
updateQuoteTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
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();
}
function moveQuoteItemUp(itemId) {
const item = document.getElementById(`quote-item-${itemId}`);
const prevItem = item.previousElementSibling;
if (prevItem) {
item.parentNode.insertBefore(item, prevItem);
updateQuoteTotals();
}
}
function moveQuoteItemDown(itemId) {
const item = document.getElementById(`quote-item-${itemId}`);
const nextItem = item.nextElementSibling;
if (nextItem) {
item.parentNode.insertBefore(nextItem, item);
updateQuoteTotals();
}
}
function updateQuoteTotals() {
const items = getQuoteItems();
const taxExempt = document.getElementById('quote-tax-exempt').checked;
let subtotal = 0;
let hasTbd = false;
items.forEach(item => {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
hasTbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
subtotal += amount;
}
});
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
const total = subtotal + taxAmount;
document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`;
document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`;
document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
}
function getQuoteItems() {
const items = [];
const itemDivs = document.querySelectorAll('#quote-items > div');
itemDivs.forEach(div => {
const descEditor = div.querySelector('.quote-item-description-editor');
const descriptionHTML = descEditor && descEditor.quillInstance
? descEditor.quillInstance.root.innerHTML
: '';
const item = {
quantity: div.querySelector('[data-field="quantity"]').value,
description: descriptionHTML,
rate: div.querySelector('[data-field="rate"]').value,
amount: div.querySelector('[data-field="amount"]').value
};
items.push(item);
});
return items;
}
async function handleQuoteSubmit(e) {
e.preventDefault();
const items = getQuoteItems();
if (items.length === 0) {
alert('Please add at least one item');
return;
}
const data = {
customer_id: parseInt(document.getElementById('quote-customer').value),
quote_date: document.getElementById('quote-date').value,
tax_exempt: document.getElementById('quote-tax-exempt').checked,
items: items
};
try {
const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
const method = currentQuoteId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeQuoteModal();
loadQuotes();
} else {
alert('Error saving quote');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving quote');
}
}
async function editQuote(id) {
await openQuoteModal(id);
}
async function deleteQuote(id) {
if (!confirm('Are you sure you want to delete this quote?')) return;
try {
const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' });
if (response.ok) {
loadQuotes();
} else {
alert('Error deleting quote');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting quote');
}
}
function viewQuotePDF(id) {
window.open(`/api/quotes/${id}/pdf`, '_blank');
}
async function convertQuoteToInvoice(quoteId) {
if (!confirm('Convert this quote to an invoice?')) return;
try {
const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, {
method: 'POST'
});
if (response.ok) {
const invoice = await response.json();
alert(`Invoice ${invoice.invoice_number} created successfully!`);
loadInvoices();
showTab('invoices');
} else {
const error = await response.json();
alert(error.error || 'Error converting quote to invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error converting quote to invoice');
}
}
// Invoice Management - Same accordion pattern
async function fetchNextInvoiceNumber() {
try {
const response = await fetch('/api/invoices/next-number');
const data = await response.json();
document.getElementById('invoice-number').value = data.next_number;
} catch (error) {
console.error('Error fetching next invoice number:', error);
}
}
async function loadInvoices() {
try {
const response = await fetch('/api/invoices');
invoices = await response.json();
renderInvoices();
} catch (error) {
console.error('Error loading invoices:', error);
alert('Error loading invoices');
}
}
function renderInvoices() {
const tbody = document.getElementById('invoices-list');
tbody.innerHTML = invoices.map(invoice => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
`).join('');
}
async function openInvoiceModal(invoiceId = null) {
currentInvoiceId = invoiceId;
const modal = document.getElementById('invoice-modal');
const title = document.getElementById('invoice-modal-title');
if (invoiceId) {
title.textContent = 'Edit Invoice';
const response = await fetch(`/api/invoices/${invoiceId}`);
const data = await response.json();
// 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-number').value = data.invoice.invoice_number;
document.getElementById('invoice-customer').value = data.invoice.customer_id;
const dateOnly = data.invoice.invoice_date.split('T')[0];
document.getElementById('invoice-date').value = dateOnly;
document.getElementById('invoice-terms').value = data.invoice.terms;
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
// Load items
document.getElementById('invoice-items').innerHTML = '';
itemCounter = 0;
data.items.forEach(item => {
addInvoiceItem(item);
});
updateInvoiceTotals();
} else {
title.textContent = 'New Invoice';
document.getElementById('invoice-form').reset();
document.getElementById('invoice-items').innerHTML = '';
document.getElementById('invoice-terms').value = 'Net 30';
itemCounter = 0;
setDefaultDate();
// Fetch next invoice number
fetchNextInvoiceNumber();
// Add one default item
addInvoiceItem();
}
modal.classList.add('active');
}
function closeInvoiceModal() {
document.getElementById('invoice-modal').classList.remove('active');
currentInvoiceId = null;
}
function addInvoiceItem(item = null) {
const itemId = itemCounter++;
const itemsDiv = document.getElementById('invoice-items');
const itemDiv = document.createElement('div');
itemDiv.className = '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 = `
<!-- Accordion Header -->
<div class="flex items-center p-4">
<!-- Move Buttons (Left) - Outside accordion click area -->
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
<button type="button" onclick="moveInvoiceItemUp(${itemId})"
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
title="Move Up">
</button>
<button type="button" onclick="moveInvoiceItemDown(${itemId})"
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
title="Move Down">
</button>
</div>
<!-- Accordion Toggle & Content (Center) -->
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" 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 mr-4" 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 mx-4 item-desc-preview">${previewDesc}</span>
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
</div>
<!-- Delete Button (Right) - Outside accordion click area -->
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();"
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
×
</button>
</div>
<!-- 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-6">
<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-3">
<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>
</div>
`;
itemsDiv.appendChild(itemDiv);
// Initialize Quill editor
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
const quill = new Quill(editorDiv, {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['clean']
]
}
});
if (item && item.description) {
quill.root.innerHTML = item.description;
}
quill.on('text-change', () => {
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
});
editorDiv.quillInstance = quill;
// 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"]');
const calculateAmount = () => {
if (qtyInput.value && rateInput.value) {
const qty = parseFloat(qtyInput.value) || 0;
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
amountInput.value = (qty * rateValue).toFixed(2);
}
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
};
qtyInput.addEventListener('input', calculateAmount);
rateInput.addEventListener('input', calculateAmount);
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();
}
function moveInvoiceItemUp(itemId) {
const item = document.getElementById(`invoice-item-${itemId}`);
const prevItem = item.previousElementSibling;
if (prevItem) {
item.parentNode.insertBefore(item, prevItem);
updateInvoiceTotals();
}
}
function moveInvoiceItemDown(itemId) {
const item = document.getElementById(`invoice-item-${itemId}`);
const nextItem = item.nextElementSibling;
if (nextItem) {
item.parentNode.insertBefore(nextItem, item);
updateInvoiceTotals();
}
}
function updateInvoiceTotals() {
const items = getInvoiceItems();
const taxExempt = document.getElementById('invoice-tax-exempt').checked;
let subtotal = 0;
items.forEach(item => {
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
subtotal += amount;
});
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
const total = subtotal + taxAmount;
document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
}
function getInvoiceItems() {
const items = [];
const itemDivs = document.querySelectorAll('#invoice-items > div');
itemDivs.forEach(div => {
const descEditor = div.querySelector('.invoice-item-description-editor');
const descriptionHTML = descEditor && descEditor.quillInstance
? descEditor.quillInstance.root.innerHTML
: '';
const item = {
quantity: div.querySelector('[data-field="quantity"]').value,
description: descriptionHTML,
rate: div.querySelector('[data-field="rate"]').value,
amount: div.querySelector('[data-field="amount"]').value
};
items.push(item);
});
return items;
}
async function handleInvoiceSubmit(e) {
e.preventDefault();
const items = getInvoiceItems();
if (items.length === 0) {
alert('Please add at least one item');
return;
}
const invoiceNumber = document.getElementById('invoice-number').value;
if (!invoiceNumber || !/^\d+$/.test(invoiceNumber)) {
alert('Invalid invoice number. Must be a numeric value.');
return;
}
const data = {
invoice_number: invoiceNumber,
customer_id: parseInt(document.getElementById('invoice-customer').value),
invoice_date: document.getElementById('invoice-date').value,
terms: document.getElementById('invoice-terms').value,
auth_code: document.getElementById('invoice-authorization').value,
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
items: items
};
try {
const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices';
const method = currentInvoiceId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
closeInvoiceModal();
loadInvoices();
} else {
const error = await response.json();
alert(error.error || 'Error saving invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error saving invoice');
}
}
async function editInvoice(id) {
await openInvoiceModal(id);
}
async function deleteInvoice(id) {
if (!confirm('Are you sure you want to delete this invoice?')) return;
try {
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
if (response.ok) {
loadInvoices();
} else {
alert('Error deleting invoice');
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting invoice');
}
}
function viewInvoicePDF(id) {
window.open(`/api/invoices/${id}/pdf`, '_blank');
}