/**
* 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 = `` +
workerList.map(w => ``).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();
}
}
}
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 || '';
// 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 = '';
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';
}
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,
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();
}
}
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);
// 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);
}
});
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
}
}
window.openInvoiceModal = openInvoiceModal;
window.closeInvoiceModal = closeInvoiceModal;
window.addInvoiceItem = addInvoiceItem;
window.reloadInvoiceWorkers = loadWorkers;