534 lines
22 KiB
JavaScript
534 lines
22 KiB
JavaScript
/**
|
||
* invoice-modal.js — Invoice create/edit modal
|
||
* Uses shared item-editor for accordion items
|
||
*
|
||
* Features:
|
||
* - Auto-sets tax-exempt based on customer's taxable flag
|
||
* - Recurring invoice support (monthly/yearly)
|
||
*/
|
||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||
import '../utils/api.js';
|
||
|
||
let currentInvoiceId = null;
|
||
let qboLaborRate = null;
|
||
let workerList = [];
|
||
|
||
export async function loadWorkers() {
|
||
try {
|
||
const result = await window.API.settings.get('invoice_workers');
|
||
if (result && result.value) {
|
||
workerList = result.value.split(',').map(w => w.trim()).filter(Boolean);
|
||
} else {
|
||
workerList = [];
|
||
}
|
||
populateWorkerDropdown();
|
||
console.log(`👷 ${workerList.length} Bearbeiter geladen`);
|
||
} catch (e) {
|
||
console.log('Worker-Liste konnte nicht geladen werden.');
|
||
}
|
||
}
|
||
|
||
function populateWorkerDropdown() {
|
||
const sel = document.getElementById('invoice-worker');
|
||
if (!sel) return;
|
||
const current = sel.value;
|
||
sel.innerHTML = `<option value="">— None —</option>` +
|
||
workerList.map(w => `<option value="${w}">${w}</option>`).join('');
|
||
if (current) sel.value = current;
|
||
}
|
||
|
||
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.');
|
||
}
|
||
}
|
||
|
||
export function getLaborRate() { return qboLaborRate; }
|
||
|
||
/**
|
||
* Auto-set tax exempt based on customer's taxable flag
|
||
*/
|
||
function applyCustomerTaxStatus(customerId) {
|
||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||
if (customer) {
|
||
const cb = document.getElementById('invoice-tax-exempt');
|
||
if (cb) {
|
||
cb.checked = (customer.taxable === false);
|
||
updateInvoiceTotals();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Customer Credit
|
||
//
|
||
// Das Guthaben selbst wird in QBO gefuehrt (negativer A/R-Saldo). Die App
|
||
// merkt sich nur, welcher Betrag auf DIESER Rechnung ausgewiesen wird, damit
|
||
// der Kunde den Endbetrag nachvollziehen kann. Subtotal, Tax und Total bleiben
|
||
// unberuehrt — ein Guthaben ist eine Zahlungsverrechnung, kein Rabatt.
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Guthaben des aktuell gewaehlten Kunden laut QBO (null = noch nicht geprueft). */
|
||
let availableCredit = null;
|
||
|
||
function creditEls() {
|
||
return {
|
||
banner: document.getElementById('invoice-credit-banner'),
|
||
text: document.getElementById('invoice-credit-available-text'),
|
||
fields: document.getElementById('invoice-credit-fields'),
|
||
amount: document.getElementById('invoice-credit-applied'),
|
||
memo: document.getElementById('invoice-credit-memo'),
|
||
balance: document.getElementById('invoice-balance-due')
|
||
};
|
||
}
|
||
|
||
/** Betrag im Guthabenfeld — 0, wenn leer oder ungueltig. */
|
||
function appliedCredit() {
|
||
const el = document.getElementById('invoice-credit-applied');
|
||
const n = parseFloat(el?.value);
|
||
return isFinite(n) && n > 0 ? n : 0;
|
||
}
|
||
|
||
function resetCreditUi() {
|
||
availableCredit = null;
|
||
const e = creditEls();
|
||
if (e.banner) e.banner.classList.add('hidden');
|
||
if (e.fields) e.fields.style.display = 'none';
|
||
if (e.amount) e.amount.value = '0';
|
||
if (e.memo) e.memo.value = '';
|
||
}
|
||
|
||
/**
|
||
* Fragt QBO nach Guthaben des gewaehlten Kunden. Fehler bleiben stumm: ein
|
||
* nicht erreichbares QBO darf das Anlegen einer Rechnung nicht blockieren.
|
||
*/
|
||
async function checkCustomerCredit(customerId) {
|
||
availableCredit = null;
|
||
const e = creditEls();
|
||
if (e.banner) e.banner.classList.add('hidden');
|
||
if (!customerId) return;
|
||
|
||
try {
|
||
const credits = await window.API.accounting.getCustomerCredits();
|
||
if (!Array.isArray(credits)) return;
|
||
const match = credits.find(c => c.customerId === parseInt(customerId));
|
||
if (!match || !(match.credit > 0)) return;
|
||
|
||
availableCredit = match.credit;
|
||
if (e.text) e.text.textContent =
|
||
`${match.name} has a credit of $${match.credit.toFixed(2)} on account.`;
|
||
if (e.banner) e.banner.classList.remove('hidden');
|
||
} catch (err) {
|
||
console.log('Customer credit check skipped:', err.message);
|
||
}
|
||
}
|
||
|
||
/** Uebernimmt das Guthaben in die Rechnung — gedeckelt auf das Rechnungstotal. */
|
||
export function applyCustomerCredit() {
|
||
if (!(availableCredit > 0)) return;
|
||
const e = creditEls();
|
||
const total = currentInvoiceTotal();
|
||
const value = Math.min(availableCredit, total);
|
||
|
||
if (value <= 0) {
|
||
alert('Please add items first — a credit can only be applied to an invoice amount.');
|
||
return;
|
||
}
|
||
if (e.fields) e.fields.style.display = 'block';
|
||
if (e.amount) e.amount.value = value.toFixed(2);
|
||
if (e.memo && !e.memo.value) e.memo.value = 'Credit on account';
|
||
if (value < availableCredit) {
|
||
alert(`Only $${value.toFixed(2)} of the $${availableCredit.toFixed(2)} credit fits on this invoice. `
|
||
+ 'The remainder stays on account in QBO.');
|
||
}
|
||
updateInvoiceTotals();
|
||
}
|
||
|
||
export function clearCustomerCredit() {
|
||
const e = creditEls();
|
||
if (e.amount) e.amount.value = '0';
|
||
if (e.memo) e.memo.value = '';
|
||
if (e.fields) e.fields.style.display = 'none';
|
||
updateInvoiceTotals();
|
||
}
|
||
|
||
function currentInvoiceTotal() {
|
||
const items = getItems('invoice-items');
|
||
const taxExempt = document.getElementById('invoice-tax-exempt')?.checked;
|
||
let subtotal = 0;
|
||
items.forEach(item => {
|
||
subtotal += parseFloat(String(item.amount).replace(/[$,]/g, '')) || 0;
|
||
});
|
||
return subtotal + (taxExempt ? 0 : subtotal * 8.25 / 100);
|
||
}
|
||
|
||
function updateRecurringChildUi(invoice = null) {
|
||
const recurringCb = document.getElementById('invoice-recurring');
|
||
if (!recurringCb) return;
|
||
|
||
const recurringWrapper = recurringCb.closest('label') || recurringCb.parentElement;
|
||
if (!recurringWrapper) return;
|
||
|
||
let labelText = document.getElementById('invoice-recurring-label-text');
|
||
let childBadge = document.getElementById('invoice-recurring-child-badge');
|
||
let childNote = document.getElementById('invoice-recurring-child-note');
|
||
|
||
// Find or create a stable label span
|
||
if (!labelText) {
|
||
// Prefer an existing span/text element containing "Recurring"
|
||
const existingTextElement = Array.from(recurringWrapper.children).find(el =>
|
||
el !== recurringCb &&
|
||
el.id !== 'invoice-recurring-child-badge' &&
|
||
el.textContent &&
|
||
el.textContent.trim() === 'Recurring'
|
||
);
|
||
|
||
if (existingTextElement) {
|
||
existingTextElement.id = 'invoice-recurring-label-text';
|
||
labelText = existingTextElement;
|
||
} else {
|
||
// Fallback: try to replace a raw text node
|
||
const textNode = Array.from(recurringWrapper.childNodes).find(node =>
|
||
node.nodeType === Node.TEXT_NODE &&
|
||
node.textContent.trim().includes('Recurring')
|
||
);
|
||
|
||
if (textNode) {
|
||
const span = document.createElement('span');
|
||
span.id = 'invoice-recurring-label-text';
|
||
span.textContent = 'Recurring';
|
||
recurringWrapper.replaceChild(span, textNode);
|
||
labelText = span;
|
||
} else {
|
||
// Final fallback: create label text after checkbox
|
||
labelText = document.createElement('span');
|
||
labelText.id = 'invoice-recurring-label-text';
|
||
labelText.textContent = 'Recurring';
|
||
labelText.className = 'ml-2';
|
||
|
||
if (recurringCb.nextSibling) {
|
||
recurringWrapper.insertBefore(labelText, recurringCb.nextSibling);
|
||
} else {
|
||
recurringWrapper.appendChild(labelText);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!childBadge) {
|
||
childBadge = document.createElement('span');
|
||
childBadge.id = 'invoice-recurring-child-badge';
|
||
childBadge.className = 'ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800 hidden';
|
||
childBadge.textContent = 'Child invoice';
|
||
recurringWrapper.appendChild(childBadge);
|
||
}
|
||
|
||
if (!childNote) {
|
||
childNote = document.createElement('div');
|
||
childNote.id = 'invoice-recurring-child-note';
|
||
childNote.className = 'mt-1 ml-8 text-xs text-gray-500 hidden';
|
||
|
||
const parent = recurringWrapper.parentElement || recurringWrapper;
|
||
parent.appendChild(childNote);
|
||
}
|
||
|
||
const isChild = !!invoice?.recurring_source_id;
|
||
|
||
if (isChild) {
|
||
labelText.textContent = 'Recurring child invoice';
|
||
childBadge.classList.remove('hidden');
|
||
|
||
const sourceNumber =
|
||
invoice.recurring_source_invoice_number ||
|
||
invoice.source_invoice_number ||
|
||
invoice.recurring_source_id;
|
||
|
||
childNote.textContent =
|
||
`This invoice was generated from recurring invoice #${sourceNumber} and will not create further recurring invoices.`;
|
||
|
||
childNote.classList.remove('hidden');
|
||
} else {
|
||
labelText.textContent = 'Recurring';
|
||
childBadge.classList.add('hidden');
|
||
childNote.classList.add('hidden');
|
||
childNote.textContent = '';
|
||
}
|
||
}
|
||
|
||
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 || '';
|
||
|
||
// Guthaben einer bestehenden Rechnung wiederherstellen
|
||
resetCreditUi();
|
||
const savedCredit = parseFloat(data.invoice.credit_applied) || 0;
|
||
if (savedCredit > 0) {
|
||
const ce = creditEls();
|
||
if (ce.fields) ce.fields.style.display = 'block';
|
||
if (ce.amount) ce.amount.value = savedCredit.toFixed(2);
|
||
if (ce.memo) ce.memo.value = data.invoice.credit_memo || '';
|
||
}
|
||
checkCustomerCredit(data.invoice.customer_id);
|
||
|
||
// Worker
|
||
populateWorkerDropdown();
|
||
const workerEl = document.getElementById('invoice-worker');
|
||
if (workerEl) workerEl.value = data.invoice.worker || '';
|
||
|
||
const sendDateEl = document.getElementById('invoice-send-date');
|
||
if (sendDateEl) {
|
||
sendDateEl.value = data.invoice.scheduled_send_date
|
||
? data.invoice.scheduled_send_date.split('T')[0] : '';
|
||
}
|
||
|
||
// Recurring fields
|
||
const recurringCb = document.getElementById('invoice-recurring');
|
||
const recurringInterval = document.getElementById('invoice-recurring-interval');
|
||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||
if (recurringCb) {
|
||
const isGeneratedRecurringChild = !!data.invoice.recurring_source_id;
|
||
const canBeRecurringMaster = !isGeneratedRecurringChild;
|
||
|
||
recurringCb.checked = canBeRecurringMaster && (data.invoice.is_recurring || false);
|
||
recurringCb.disabled = !canBeRecurringMaster;
|
||
|
||
if (recurringInterval) {
|
||
recurringInterval.value = data.invoice.recurring_interval || 'monthly';
|
||
recurringInterval.disabled = !canBeRecurringMaster;
|
||
}
|
||
|
||
if (recurringGroup) {
|
||
recurringGroup.style.display = recurringCb.checked ? 'block' : 'none';
|
||
}
|
||
|
||
// Make recurring child invoices obvious in the UI
|
||
updateRecurringChildUi(data.invoice);
|
||
}
|
||
|
||
// Load items
|
||
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 = '';
|
||
resetCreditUi();
|
||
document.getElementById('invoice-terms').value = 'Net 14';
|
||
document.getElementById('invoice-number').value = '';
|
||
document.getElementById('invoice-send-date').value = '';
|
||
|
||
// Worker zurücksetzen
|
||
populateWorkerDropdown();
|
||
const workerEl = document.getElementById('invoice-worker');
|
||
if (workerEl) workerEl.value = '';
|
||
|
||
// Reset recurring
|
||
const recurringCb = document.getElementById('invoice-recurring');
|
||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||
if (recurringCb) recurringCb.checked = false;
|
||
if (recurringGroup) recurringGroup.style.display = 'none';
|
||
|
||
const recurringInterval = document.getElementById('invoice-recurring-interval');
|
||
if (recurringCb) recurringCb.disabled = false;
|
||
if (recurringInterval) recurringInterval.disabled = false;
|
||
updateRecurringChildUi(null);
|
||
|
||
resetItemCounter();
|
||
setDefaultDate();
|
||
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';
|
||
|
||
// Guthabenzeile: Betrag nie ueber dem Total, sonst waere die Balance Due negativ.
|
||
const credit = appliedCredit();
|
||
const e = creditEls();
|
||
if (credit > 0) {
|
||
if (credit > total && e.amount) e.amount.value = total.toFixed(2);
|
||
const applied = Math.min(credit, total);
|
||
if (e.fields) e.fields.style.display = 'block';
|
||
if (e.balance) e.balance.textContent = `$${(total - applied).toFixed(2)}`;
|
||
} else if (e.fields) {
|
||
e.fields.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
export async function handleInvoiceSubmit(e) {
|
||
e.preventDefault();
|
||
const isRecurring = document.getElementById('invoice-recurring')?.checked || false;
|
||
const recurringInterval = isRecurring
|
||
? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null;
|
||
|
||
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,
|
||
worker: document.getElementById('invoice-worker')?.value || null,
|
||
is_recurring: isRecurring,
|
||
recurring_interval: recurringInterval,
|
||
credit_applied: appliedCredit(),
|
||
credit_memo: document.getElementById('invoice-credit-memo')?.value?.trim() || 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)');
|
||
|
||
// Ein fehlgeschlagener QBO-Sync wurde frueher nur in die Konsole
|
||
// geschrieben. Folge: Die Rechnung stand lokal korrekt da, in QBO aber
|
||
// unvollstaendig — der Kunde zahlte den vollen Betrag, und die Differenz
|
||
// blieb als Guthaben haengen. Deshalb jetzt sichtbar melden.
|
||
if (result.qbo_error) {
|
||
alert('⚠️ Invoice saved locally, but QuickBooks was NOT updated:\n\n'
|
||
+ result.qbo_error
|
||
+ '\n\nThe QBO invoice may be missing or incomplete. Please check it in QBO.');
|
||
} else if (result.qbo_skipped) {
|
||
console.log(`ℹ️ QBO skipped: ${result.qbo_skipped}`);
|
||
}
|
||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||
} else {
|
||
alert(`Error: ${result.error}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
alert('Error saving invoice');
|
||
} finally {
|
||
hideSpinner();
|
||
}
|
||
}
|
||
|
||
export function initInvoiceModal() {
|
||
const form = document.getElementById('invoice-form');
|
||
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||
|
||
loadWorkers(); // Bearbeiterliste laden
|
||
|
||
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||
|
||
const creditAmount = document.getElementById('invoice-credit-applied');
|
||
if (creditAmount) creditAmount.addEventListener('input', updateInvoiceTotals);
|
||
|
||
// Recurring toggle
|
||
const recurringCb = document.getElementById('invoice-recurring');
|
||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||
if (recurringCb && recurringGroup) {
|
||
recurringCb.addEventListener('change', () => {
|
||
recurringGroup.style.display = recurringCb.checked ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
// Watch for customer selection → auto-set tax exempt (only for new invoices)
|
||
const customerHidden = document.getElementById('invoice-customer');
|
||
if (customerHidden) {
|
||
const observer = new MutationObserver(() => {
|
||
// Only auto-apply when creating new (not editing existing)
|
||
if (!currentInvoiceId && customerHidden.value) {
|
||
applyCustomerTaxStatus(customerHidden.value);
|
||
checkCustomerCredit(customerHidden.value);
|
||
}
|
||
});
|
||
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||
}
|
||
}
|
||
|
||
window.invoiceModal = {
|
||
applyCustomerCredit,
|
||
clearCustomerCredit
|
||
};
|
||
window.openInvoiceModal = openInvoiceModal;
|
||
window.closeInvoiceModal = closeInvoiceModal;
|
||
window.addInvoiceItem = addInvoiceItem;
|
||
window.reloadInvoiceWorkers = loadWorkers; |