Files
invoice-system/public/js/modals/invoice-modal.js
2026-08-19 22:19:46 +02:00

534 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 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;