// Invoice Form Logic
const getInvoiceDiscountInput = (tableBody) => {
const form = tableBody && typeof tableBody.closest === 'function' ? tableBody.closest('form') : null;
return form ? form.querySelector('[data-invoice-discount-input]') : null;
};
const getInvoiceDiscountValue = (tableBody) => {
const input = getInvoiceDiscountInput(tableBody);
if (!input) return 0;
let value = parseFloat(input.value);
if (!Number.isFinite(value) || value < 0) value = 0;
return value;
};
const getInvoiceManualDiscountPercent = () => {
let value = (typeof companySettings !== 'undefined' && companySettings && companySettings.manual_discount_profit_limit_percent !== undefined)
? parseFloat(companySettings.manual_discount_profit_limit_percent)
: 5;
if (!Number.isFinite(value) || value < 0) value = 5;
return Math.min(100, Math.max(0, value));
};
const getInvoiceManualDiscountMetrics = (tableBody) => {
const percent = getInvoiceManualDiscountPercent();
if (!tableBody || invoiceType !== 'sale') {
return { percent, profitAmount: 0, maxDiscount: 0 };
}
let profitAmount = 0;
tableBody.querySelectorAll('.item-row').forEach(row => {
const qty = normalizeQuantity(row.querySelector('.item-qty')?.value || 0);
const unitPrice = parseFloat(row.querySelector('.item-price')?.value) || 0;
const purchasePrice = parseFloat(row.querySelector('.item-purchase-price')?.value) || 0;
profitAmount += (unitPrice - purchasePrice) * qty;
});
profitAmount = Math.max(0, profitAmount);
return {
percent,
profitAmount,
maxDiscount: Math.max(0, profitAmount * (percent / 100))
};
};
const updateInvoiceDiscountHelp = (tableBody, limitAmount = 0, metrics = null) => {
const input = getInvoiceDiscountInput(tableBody);
if (!input) return;
const form = tableBody && typeof tableBody.closest === 'function' ? tableBody.closest('form') : null;
const help = form ? form.querySelector('[data-invoice-discount-help]') : null;
if (!help) return;
const activeMetrics = metrics || getInvoiceManualDiscountMetrics(tableBody);
const safeLimit = Math.max(0, Number(limitAmount) || 0);
const percentLabel = activeMetrics.percent.toFixed(3).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
const isArabic = (document.documentElement.lang || 'en') === 'ar';
help.textContent = isArabic
? `الحد الأقصى الآن: = __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% من ربح الفاتورة = __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`
: `Max allowed now: = __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% of invoice profit = __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`;
};
const initInvoiceForm = (searchInputId, suggestionsId, tableBodyId, grandTotalId, subtotalId, totalVatId) => {
const searchInput = document.getElementById(searchInputId);
const suggestions = document.getElementById(suggestionsId);
const tableBody = document.getElementById(tableBodyId);
const grandTotalEl = document.getElementById(grandTotalId);
const subtotalEl = document.getElementById(subtotalId);
const totalVatEl = document.getElementById(totalVatId);
if (!searchInput || !tableBody) return;
const discountInput = getInvoiceDiscountInput(tableBody);
if (discountInput && discountInput.dataset.discountBound !== '1') {
discountInput.dataset.discountBound = '1';
discountInput.addEventListener('input', function() {
const nextValue = parseFloat(this.value);
if (Number.isFinite(nextValue) && nextValue < 0) {
this.value = '0';
}
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
});
}
let timeout = null;
searchInput.addEventListener('input', function() {
clearTimeout(timeout);
const q = this.value.trim();
if (q.length < 2) {
suggestions.style.display = 'none';
return;
}
timeout = setTimeout(() => {
fetch(`index.php?action=search_items&q=${encodeURIComponent(q)}`)
.then(res => res.ok ? res.json() : [])
.then(data => {
suggestions.innerHTML = '';
if (data.length > 0) {
data.forEach(item => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'list-group-item list-group-item-action';
btn.innerHTML = `
${item.sku} - ${item.name_en} / ${item.name_ar}
Stock: ${formatQuantity(item.stock_quantity)}
`;
btn.onclick = () => addItemToTable(item, tableBody, searchInput, suggestions, grandTotalEl, subtotalEl, totalVatEl);
suggestions.appendChild(btn);
});
suggestions.style.display = 'block';
} else {
suggestions.style.display = 'none';
}
});
}, 300);
});
// Close suggestions when clicking outside
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !suggestions.contains(e.target)) {
suggestions.style.display = 'none';
}
});
};
const invoiceSyncSelect2Value = (select) => {
if (!select) return;
if (select.classList.contains('select2') && window.jQuery && window.jQuery.fn && window.jQuery.fn.select2) {
window.jQuery(select).trigger('change');
}
};
const invoiceEnsureSelectOption = (selectId, value, label = '') => {
const select = document.getElementById(selectId);
if (!select || value === null || value === undefined || String(value) === '') return;
const alreadyExists = Array.from(select.options || []).some(option => option.value == String(value));
if (!alreadyExists) {
const option = document.createElement('option');
option.value = String(value);
option.textContent = label || String(value);
select.appendChild(option);
}
};
const invoiceSetBlankSelectOptionLabel = (select, label = '---') => {
if (!select) return;
const emptyOption = Array.from(select.options || []).find(option => option.value === '');
if (emptyOption) {
emptyOption.textContent = label;
}
};
const invoiceShowConfirmDialog = async ({
title = 'Are you sure?',
text = '',
confirmButtonText = 'Yes, continue',
cancelButtonText = 'Cancel',
icon = 'warning'
} = {}) => {
if (window.Swal) {
const result = await Swal.fire({
title,
text,
icon,
showCancelButton: true,
confirmButtonText,
cancelButtonText,
reverseButtons: true,
focusCancel: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d'
});
return !!result.isConfirmed;
}
return window.confirm(text ? `${title}
${text}` : title);
};
const bindInvoiceSweetConfirmForms = () => {
document.querySelectorAll('.js-swal-confirm-form').forEach(form => {
if (form.dataset.confirmBound === '1') return;
form.dataset.confirmBound = '1';
form.addEventListener('submit', async function(event) {
if (form.dataset.skipConfirm === '1') {
delete form.dataset.skipConfirm;
return;
}
event.preventDefault();
const confirmed = await invoiceShowConfirmDialog({
title: form.dataset.confirmTitle || 'Are you sure?',
text: form.dataset.confirmText || '',
confirmButtonText: form.dataset.confirmButton || 'Yes, continue',
cancelButtonText: form.dataset.cancelButton || 'Cancel',
icon: form.dataset.confirmIcon || 'warning'
});
if (!confirmed) return;
const submitter = event.submitter || null;
if (submitter && typeof form.requestSubmit === 'function') {
form.dataset.skipConfirm = '1';
form.requestSubmit(submitter);
return;
}
HTMLFormElement.prototype.submit.call(form);
});
});
};
bindInvoiceSweetConfirmForms();
function addItemToTable(item, tableBody, searchInput, suggestions, grandTotalEl, subtotalEl, totalVatEl, customData = null) {
if (suggestions) suggestions.style.display = 'none';
if (searchInput) searchInput.value = '';
const allowZeroStock = (typeof companySettings !== 'undefined' && String(companySettings.allow_zero_stock_sell) === '1');
const currentStock = normalizeQuantity(item.stock_quantity);
if (invoiceType === 'sale' && !allowZeroStock && !customData) {
const existingInTable = Array.from(tableBody.querySelectorAll('.item-row')).find(row => row.querySelector('.item-id-input').value == item.id);
let currentQtyInTable = 0;
if (existingInTable) {
currentQtyInTable = normalizeQuantity(existingInTable.querySelector('.item-qty').value);
}
if (currentQtyInTable + 1 > currentStock) {
alert('Insufficient stock! Available: ' + formatQuantity(currentStock));
return;
}
}
const existingRow = Array.from(tableBody.querySelectorAll('.item-id-input')).find(input => input.value == item.id);
if (existingRow && !customData) {
const row = existingRow.closest('tr');
const qtyInput = row.querySelector('.item-qty');
qtyInput.value = formatQuantity((parseFloat(qtyInput.value) || 0) + 1);
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
return;
}
const row = document.createElement('tr');
row.className = 'item-row';
const price = customData ? customData.unit_price : (invoiceType === 'sale' ? item.sale_price : item.purchase_price);
const qty = normalizeQuantity(customData ? customData.quantity : 1);
const vatRate = item.vat_rate || 0;
const purchasePrice = customData && customData.purchase_price !== undefined && customData.purchase_price !== null
? customData.purchase_price
: (item.purchase_price || 0);
const lastSalePrice = parseFloat((customData && customData.last_sale_price !== undefined ? customData.last_sale_price : (item.last_sale_price ?? item.lastSalePrice ?? 0)) || 0) || 0;
const defaultSalePrice = parseFloat(item.sale_price || price || 0) || 0;
const rowUnitPrice = parseFloat(price || 0) || 0;
const priceMaxAttr = '';
const priceLocked = invoiceType === 'sale' && lastSalePrice <= 0;
const priceReadonlyAttr = priceLocked ? ' readonly' : '';
const priceReadonlyClass = priceLocked ? ' bg-light' : '';
const priceHelp = invoiceType === 'sale'
? (priceLocked
? `Price locked until Last Sale Price / minimum is set.
`
: `Minimum price: ${lastSalePrice.toFixed(3)}
`)
: '';
row.innerHTML = `
${item.name_en}
${item.name_ar} (${item.sku})
|
|
${priceHelp} |
|
|
|
`;
tableBody.appendChild(row);
if (typeof syncQuantityInputs === 'function') syncQuantityInputs(row);
attachRowListeners(row, tableBody, grandTotalEl, subtotalEl, totalVatEl);
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
}
function recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl) {
let subtotal = 0;
let totalVat = 0;
tableBody.querySelectorAll('.item-row').forEach(row => {
const qtyInput = row.querySelector('.item-qty');
const qty = normalizeQuantity(qtyInput ? qtyInput.value : 0);
const price = parseFloat(row.querySelector('.item-price').value) || 0;
const vatRate = parseFloat(row.querySelector('.item-vat-rate').value) || 0;
const total = qty * price;
const vatAmount = total * (vatRate / 100);
row.querySelector('.item-total').value = total.toFixed(3);
subtotal += total;
totalVat += vatAmount;
});
const rawGrandTotal = subtotal + totalVat;
const discountInput = getInvoiceDiscountInput(tableBody);
const discountMetrics = getInvoiceManualDiscountMetrics(tableBody);
const maxDiscountAmount = discountInput ? Math.min(rawGrandTotal, discountMetrics.maxDiscount) : rawGrandTotal;
let discountAmount = getInvoiceDiscountValue(tableBody);
if (discountAmount > maxDiscountAmount) {
discountAmount = maxDiscountAmount;
if (discountInput) discountInput.value = discountAmount.toFixed(3);
}
updateInvoiceDiscountHelp(tableBody, maxDiscountAmount, discountMetrics);
const grandTotal = Math.max(0, rawGrandTotal - discountAmount);
if (subtotalEl) subtotalEl.textContent = 'OMR ' + subtotal.toFixed(3);
if (totalVatEl) totalVatEl.textContent = 'OMR ' + totalVat.toFixed(2);
if (grandTotalEl) grandTotalEl.textContent = 'OMR ' + grandTotal.toFixed(3);
}
function showInvoicePriceLimitError(title, message) {
if (window.Swal) {
Swal.fire(title, message, 'error');
return;
}
alert(message);
}
function enforceInvoicePriceLimit(row, input) {
if (invoiceType !== 'sale' || !input) return true;
const lastSalePrice = parseFloat(input.dataset.lastSalePrice || row.querySelector('.item-last-sale-price')?.value || 0) || 0;
const nextPrice = parseFloat(input.value);
if (!Number.isFinite(nextPrice) || nextPrice < 0) return true;
const defaultPrice = parseFloat(input.dataset.defaultPrice || 0) || 0;
const lockedPrice = parseFloat(input.dataset.lockedPrice || input.dataset.previousPrice || defaultPrice || 0) || 0;
if (lastSalePrice <= 0) {
if (Math.abs(nextPrice - lockedPrice) <= 0.0005) {
input.dataset.previousPrice = nextPrice.toFixed(3);
return true;
}
showInvoicePriceLimitError(
'Price locked',
'Price changes are disabled because Last Sale Price / minimum sale price is 0.000. Set a minimum price limit first.'
);
input.value = lockedPrice.toFixed(3);
return false;
}
if (nextPrice >= (lastSalePrice - 0.0005)) {
input.dataset.previousPrice = nextPrice.toFixed(3);
return true;
}
showInvoicePriceLimitError(
'Price rejected',
`Price cannot be below Last Sale Price / minimum sale price (${lastSalePrice.toFixed(3)}).`
);
const previousPrice = parseFloat(input.dataset.previousPrice || defaultPrice || lastSalePrice || 0) || 0;
input.value = Math.max(previousPrice, lastSalePrice).toFixed(3);
return false;
}
function attachRowListeners(row, tableBody, grandTotalEl, subtotalEl, totalVatEl) {
row.querySelector('.item-qty').addEventListener('input', function() {
const allowZeroStock = (typeof companySettings !== 'undefined' && String(companySettings.allow_zero_stock_sell) === '1');
if (invoiceType === 'sale' && !allowZeroStock) {
const stock = normalizeQuantity(row.querySelector('.item-row-stock').value);
const qty = normalizeQuantity(this.value);
if (qty > stock) {
alert('Insufficient stock! Available: ' + formatQuantity(stock));
this.value = formatQuantity(stock);
}
}
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
});
const priceInput = row.querySelector('.item-price');
priceInput.addEventListener('input', () => recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl));
priceInput.addEventListener('change', function() {
enforceInvoicePriceLimit(row, this);
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
});
row.querySelector('.remove-row').addEventListener('click', function() {
row.remove();
recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl);
});
}
const invoiceType = '= in_array($page, ["sales", "quotations"]) ? "sale" : (in_array($page, ["purchases", "lpos"]) ? "purchase" : "") ?>';
initInvoiceForm('productSearchInput', 'searchSuggestions', 'invoiceItemsTableBody', 'grandTotal', 'subtotal', 'totalVat');
initInvoiceForm('editProductSearchInput', 'editSearchSuggestions', 'editInvoiceItemsTableBody', 'edit_grandTotal', 'edit_subtotal', 'edit_totalVat');