refactoring
This commit is contained in:
221
public/js/modals/invoice-modal.js
Normal file
221
public/js/modals/invoice-modal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* invoice-modal.js — Invoice create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoiceId = null;
|
||||
let qboLaborRate = null;
|
||||
|
||||
/**
|
||||
* Load labor rate from QBO (called once at startup)
|
||||
*/
|
||||
export async function loadLaborRate() {
|
||||
try {
|
||||
const response = await fetch('/api/qbo/labor-rate');
|
||||
const data = await response.json();
|
||||
if (data.rate) {
|
||||
qboLaborRate = data.rate;
|
||||
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getLaborRate() {
|
||||
return qboLaborRate;
|
||||
}
|
||||
|
||||
export async function openInvoiceModal(invoiceId = null) {
|
||||
currentInvoiceId = invoiceId;
|
||||
|
||||
if (invoiceId) {
|
||||
await loadInvoiceForEdit(invoiceId);
|
||||
} else {
|
||||
prepareNewInvoice();
|
||||
}
|
||||
|
||||
document.getElementById('invoice-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeInvoiceModal() {
|
||||
document.getElementById('invoice-modal').classList.remove('active');
|
||||
currentInvoiceId = null;
|
||||
}
|
||||
|
||||
async function loadInvoiceForEdit(invoiceId) {
|
||||
document.getElementById('invoice-modal-title').textContent = 'Edit Invoice';
|
||||
|
||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.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;
|
||||
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||
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;
|
||||
document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
|
||||
|
||||
const sendDateEl = document.getElementById('invoice-send-date');
|
||||
if (sendDateEl) {
|
||||
sendDateEl.value = data.invoice.scheduled_send_date
|
||||
? data.invoice.scheduled_send_date.split('T')[0]
|
||||
: '';
|
||||
}
|
||||
|
||||
// Load items using shared editor
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('invoice-items', {
|
||||
item,
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
});
|
||||
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function prepareNewInvoice() {
|
||||
document.getElementById('invoice-modal-title').textContent = 'New Invoice';
|
||||
document.getElementById('invoice-form').reset();
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
document.getElementById('invoice-terms').value = 'Net 30';
|
||||
document.getElementById('invoice-number').value = '';
|
||||
document.getElementById('invoice-send-date').value = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('invoice-items', {
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
}
|
||||
|
||||
export function addInvoiceItem(item = null) {
|
||||
addItem('invoice-items', {
|
||||
item,
|
||||
type: 'invoice',
|
||||
laborRate: qboLaborRate,
|
||||
onUpdate: updateInvoiceTotals
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInvoiceTotals() {
|
||||
const items = getItems('invoice-items');
|
||||
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';
|
||||
}
|
||||
|
||||
export async function handleInvoiceSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
invoice_number: document.getElementById('invoice-number').value || null,
|
||||
customer_id: 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,
|
||||
scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
|
||||
bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
|
||||
items: getItems('invoice-items')
|
||||
};
|
||||
|
||||
if (!data.customer_id) {
|
||||
alert('Please select a customer.');
|
||||
return;
|
||||
}
|
||||
if (!data.items || data.items.length === 0) {
|
||||
alert('Please add at least one item.');
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceId = currentInvoiceId;
|
||||
const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
|
||||
const method = invoiceId ? 'PUT' : 'POST';
|
||||
|
||||
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
|
||||
if (result.qbo_doc_number) {
|
||||
console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
|
||||
} else if (result.qbo_synced) {
|
||||
console.log('✅ Invoice saved & synced to QBO');
|
||||
} else {
|
||||
console.log('✅ Invoice saved locally (QBO sync pending)');
|
||||
}
|
||||
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving invoice');
|
||||
} finally {
|
||||
hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initInvoiceModal() {
|
||||
const form = document.getElementById('invoice-form');
|
||||
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openInvoiceModal = openInvoiceModal;
|
||||
window.closeInvoiceModal = closeInvoiceModal;
|
||||
window.addInvoiceItem = addInvoiceItem;
|
||||
364
public/js/modals/payment-modal.js
Normal file
364
public/js/modals/payment-modal.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// payment-modal.js — ES Module v3 (clean)
|
||||
// Invoice payments: multi-invoice, partial, overpay
|
||||
// No downpayment functionality
|
||||
|
||||
let bankAccounts = [];
|
||||
let paymentMethods = [];
|
||||
let selectedInvoices = []; // { invoice, payAmount }
|
||||
let dataLoaded = false;
|
||||
|
||||
// ============================================================
|
||||
// Load QBO Data
|
||||
// ============================================================
|
||||
|
||||
async function loadQboData() {
|
||||
if (dataLoaded) return;
|
||||
try {
|
||||
const [accRes, pmRes] = await Promise.all([
|
||||
fetch('/api/qbo/accounts'),
|
||||
fetch('/api/qbo/payment-methods')
|
||||
]);
|
||||
if (accRes.ok) bankAccounts = await accRes.json();
|
||||
if (pmRes.ok) paymentMethods = await pmRes.json();
|
||||
dataLoaded = true;
|
||||
} catch (e) { console.error('Error loading QBO data:', e); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Open / Close
|
||||
// ============================================================
|
||||
|
||||
export async function openPaymentModal(invoiceIds = []) {
|
||||
await loadQboData();
|
||||
selectedInvoices = [];
|
||||
|
||||
for (const id of invoiceIds) {
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${id}`);
|
||||
const data = await res.json();
|
||||
if (data.invoice) {
|
||||
const total = parseFloat(data.invoice.total);
|
||||
const amountPaid = parseFloat(data.invoice.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: data.invoice,
|
||||
payAmount: balance > 0 ? balance : total
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||
}
|
||||
|
||||
ensureModalElement();
|
||||
renderModalContent();
|
||||
document.getElementById('payment-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closePaymentModal() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
selectedInvoices = [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Add / Remove Invoices
|
||||
// ============================================================
|
||||
|
||||
async function addInvoiceById() {
|
||||
const input = document.getElementById('payment-add-invoice-id');
|
||||
const searchVal = input.value.trim();
|
||||
if (!searchVal) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/invoices');
|
||||
const allInvoices = await res.json();
|
||||
const match = allInvoices.find(inv =>
|
||||
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
|
||||
);
|
||||
|
||||
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
|
||||
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
|
||||
if (match.paid_date) { alert('This invoice is already paid.'); return; }
|
||||
if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; }
|
||||
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
|
||||
alert('All invoices must belong to the same customer.'); return;
|
||||
}
|
||||
|
||||
const detailRes = await fetch(`/api/invoices/${match.id}`);
|
||||
const detailData = await detailRes.json();
|
||||
const detailInv = detailData.invoice;
|
||||
const detailTotal = parseFloat(detailInv.total);
|
||||
const detailPaid = parseFloat(detailInv.amount_paid) || 0;
|
||||
const detailBalance = detailTotal - detailPaid;
|
||||
selectedInvoices.push({
|
||||
invoice: detailInv,
|
||||
payAmount: detailBalance > 0 ? detailBalance : detailTotal
|
||||
});
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
input.value = '';
|
||||
} catch (e) {
|
||||
console.error('Error adding invoice:', e);
|
||||
alert('Error searching for invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeInvoice(invoiceId) {
|
||||
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function updatePayAmount(invoiceId, newAmount) {
|
||||
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
|
||||
if (si) {
|
||||
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
|
||||
}
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('payment-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'payment-modal';
|
||||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
const modal = document.getElementById('payment-modal');
|
||||
if (!modal) return;
|
||||
|
||||
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||||
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
||||
const methods = filtered.length > 0 ? filtered : paymentMethods;
|
||||
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
|
||||
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invoice List -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||||
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
|
||||
<button onclick="window.paymentModal.addById()"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
|
||||
<input type="date" id="payment-date" value="${today}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
|
||||
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
|
||||
<select id="payment-method"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${methodOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
|
||||
<select id="payment-deposit-to"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
${accountOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
|
||||
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="window.paymentModal.close()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
|
||||
💰 Record Payment in QBO
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
renderInvoiceList();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function renderInvoiceList() {
|
||||
const container = document.getElementById('payment-invoice-list');
|
||||
if (!container) return;
|
||||
|
||||
if (selectedInvoices.length === 0) {
|
||||
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = selectedInvoices.map(si => {
|
||||
const inv = si.invoice;
|
||||
const total = parseFloat(inv.total);
|
||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||
const balance = total - amountPaid;
|
||||
const isPartial = si.payAmount < balance;
|
||||
const isOver = si.payAmount > balance;
|
||||
|
||||
const paidInfo = amountPaid > 0
|
||||
? `<span class="text-green-600 text-xs ml-1">Paid: $${amountPaid.toFixed(2)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
|
||||
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
|
||||
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
|
||||
${paidInfo}
|
||||
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
|
||||
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="text-gray-500 text-sm">$</span>
|
||||
<input type="number" step="0.01" min="0.01"
|
||||
value="${si.payAmount.toFixed(2)}"
|
||||
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
|
||||
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
|
||||
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
|
||||
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
|
||||
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
const totalEl = document.getElementById('payment-total');
|
||||
const noteEl = document.getElementById('payment-overpay-note');
|
||||
if (!totalEl) return;
|
||||
|
||||
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
||||
|
||||
if (noteEl) {
|
||||
if (payTotal > invTotal && invTotal > 0) {
|
||||
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
|
||||
noteEl.classList.remove('hidden');
|
||||
} else {
|
||||
noteEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Submit
|
||||
// ============================================================
|
||||
|
||||
async function submitPayment() {
|
||||
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
|
||||
|
||||
const paymentDate = document.getElementById('payment-date').value;
|
||||
const reference = document.getElementById('payment-reference').value;
|
||||
const methodSelect = document.getElementById('payment-method');
|
||||
const depositSelect = document.getElementById('payment-deposit-to');
|
||||
|
||||
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
|
||||
alert('Please fill in all fields.'); return;
|
||||
}
|
||||
|
||||
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
|
||||
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
|
||||
const hasOverpay = total > invTotal;
|
||||
|
||||
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
|
||||
if (hasPartial) msg += '\n⚠️ Contains partial payment(s).';
|
||||
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
const submitBtn = document.getElementById('payment-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Processing...';
|
||||
submitBtn.disabled = true;
|
||||
if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/record-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
invoice_payments: selectedInvoices.map(si => ({
|
||||
invoice_id: si.invoice.id,
|
||||
amount: si.payAmount
|
||||
})),
|
||||
payment_date: paymentDate,
|
||||
reference_number: reference,
|
||||
payment_method_id: methodSelect.value,
|
||||
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
|
||||
deposit_to_account_id: depositSelect.value,
|
||||
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
alert(`✅ ${result.message}`);
|
||||
closePaymentModal();
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Payment error:', e);
|
||||
alert('Network error.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
|
||||
window.paymentModal = {
|
||||
open: openPaymentModal,
|
||||
close: closePaymentModal,
|
||||
submit: submitPayment,
|
||||
addById: addInvoiceById,
|
||||
removeInvoice: removeInvoice,
|
||||
updateAmount: updatePayAmount,
|
||||
updateTotal: updateTotal
|
||||
};
|
||||
154
public/js/modals/quote-modal.js
Normal file
154
public/js/modals/quote-modal.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* quote-modal.js — Quote create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentQuoteId = null;
|
||||
|
||||
export function openQuoteModal(quoteId = null) {
|
||||
currentQuoteId = quoteId;
|
||||
|
||||
if (quoteId) {
|
||||
loadQuoteForEdit(quoteId);
|
||||
} else {
|
||||
prepareNewQuote();
|
||||
}
|
||||
|
||||
document.getElementById('quote-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeQuoteModal() {
|
||||
document.getElementById('quote-modal').classList.remove('active');
|
||||
currentQuoteId = null;
|
||||
}
|
||||
|
||||
async function loadQuoteForEdit(quoteId) {
|
||||
document.getElementById('quote-modal-title').textContent = 'Edit Quote';
|
||||
|
||||
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#quote-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('quote-customer').value = data.quote.customer_id;
|
||||
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||
|
||||
// Load items using shared editor
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
});
|
||||
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function prepareNewQuote() {
|
||||
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function addQuoteItem(item = null) {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function updateQuoteTotals() {
|
||||
const items = getItems('quote-items');
|
||||
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';
|
||||
}
|
||||
|
||||
export async function handleQuoteSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const items = getItems('quote-items');
|
||||
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();
|
||||
if (window.quoteView) window.quoteView.loadQuotes();
|
||||
} else {
|
||||
alert('Error saving quote');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving quote');
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initQuoteModal() {
|
||||
const form = document.getElementById('quote-form');
|
||||
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openQuoteModal = openQuoteModal;
|
||||
window.closeQuoteModal = closeQuoteModal;
|
||||
window.addQuoteItem = addQuoteItem;
|
||||
Reference in New Issue
Block a user