function escape_html(value) { return String(value ?? '').replace(/[&<>"']/g, function(c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } /** ========================= * SIDEBAR ACTIVE STATE * Override: activate the parent listing page for manage_* sub-pages. * main.js (theme) handles exact-match pages; this covers second-depth pages. * ========================= */ $(function () { var $links = $('#sidebar').find('a.nav-link'); var path = window.location.pathname; // Theme already handled it — an active link whose href matches the current path var alreadyActive = $links.filter('.active').toArray().some(function (a) { return a.pathname === path; }); if (alreadyActive) return; // For manage_* pages: strip "manage_" to derive the parent listing filename var filename = path.split('/').pop(); if (!/^manage_/.test(filename)) return; var dir = path.substring(0, path.lastIndexOf('/')); var parentPath = dir + '/' + filename.replace(/^manage_/, ''); var $parent = $links.filter(function () { return this.pathname === parentPath; }); if ($parent.length) { $links.removeClass('active'); $parent.addClass('active'); } }); /** ========================= * UTILITIES * ========================= */ /** * Returns a debounced version of fn that delays invoking until * after `wait` ms have elapsed since the last call. */ function debounce(fn, wait) { wait = wait || 300; var timer; return function (event) { var ctx = this; var args = arguments; // Persist the jQuery event so it's accessible after the timeout if (event && event.persist) event.persist(); clearTimeout(timer); timer = setTimeout(function () { fn.apply(ctx, args); }, wait); }; } /** ========================= * DEFAULT ON LOAD FUNCTION * ========================= */ $(function () { // Prevent all forms from refreshing the page $("form").on("submit", function (e) { e.preventDefault(); }); // Initialize autocomplete for product search inputs init_product_search_inputs(); window.init_product_search_inputs = init_product_search_inputs; function init_product_search_inputs(scope) { var $scope = scope ? $(scope) : $(document); $scope.find('input.product_search').addBack('input.product_search').each(function (i, el) { var $el = $(el); if ($el.data('product-search-ready')) return; $el.data('product-search-ready', true); $el.autocomplete({ // Fetch data from server source: function (request, response) { // Abort previous request (prevent multiple firing) if ($el.data('xhr')) { $el.data('xhr').abort(); } // Prepare payload let q = prepare_form_data(0, true); q['keyword'] = request.term; let json = JSON.stringify(q); // Send AJAX request var xhr = $.ajax({ url: server_url + 'ics/api/engine/product_search.php', type: 'POST', dataType: 'json', data: { json: json }, // for CSRF validation headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, success: function (res) { var aca = []; // Map result to autocomplete format $.each(res.result, function (i, item) { var temp = ""; temp += "
"; temp += "
"; temp += "SKU: " + (item.sku || 'N/A'); temp += "
"; temp += "
"; temp += item.product_name; temp += "
"; temp += "
"; aca.push({ label: temp, // HTML dropdown value: item.product_name, // Default value data: item // Full object }); }); response(aca); } }); // Save request reference $el.data('xhr', xhr); }, minLength: 0, delay: 300, // When selecting item select: function (e, ui) { if (ui.item && ui.item.data) { var product = ui.item.data; // Replace input value with SKU $(this) .val(product.sku) .attr("secondary", product.sku); var target = $(this).data('product-name-target'); var $productName = target ? $(this).closest('tr').find(target) : $('input#product_name'); $productName .val(product.product_name) .attr("secondary", product.product_name); $(this).trigger('product:selected', [product]); console.log("Input updated with SKU:", product.sku); return false; // Prevent overwrite by default value } } }) // Allow HTML rendering in dropdown .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") .append($("
    ").html(item.label)) .appendTo(ul); }; // Add this ↓ $el.on('blur', function () { var secondary = $(this).attr('secondary'); if (secondary) { $(this).val(secondary); var target = $(this).data('product-name-target'); var $productName = target ? $(this).closest('tr').find(target) : $('input#product_name'); $productName.val($productName.attr('secondary')); } }); }); } init_contact_search_inputs(); window.init_contact_search_inputs = init_contact_search_inputs; function init_contact_search_inputs(scope) { var $scope = scope ? $(scope) : $(document); $scope.find('input.contact_search').addBack('input.contact_search').each(function (i, el) { var $el = $(el); if ($el.data('contact-search-ready')) return; $el.data('contact-search-ready', true); $el.autocomplete({ source: function (request, response) { if ($el.data('xhr')) { $el.data('xhr').abort(); } let q = prepare_form_data(0, true); q['keyword'] = request.term; let json = JSON.stringify(q); var xhr = $.ajax({ url: server_url + 'ics/api/engine/contact_search.php', type: 'POST', dataType: 'json', data: { json: json }, headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, success: function (res) { var aca = []; $.each(res.result, function (i, item) { var temp = ""; temp += "
    "; temp += "
    "; temp += "Tax ID: " + (item.tax_id || 'N/A'); temp += "
    "; temp += "
    "; temp += item.contact_name; temp += "
    "; temp += "
    "; aca.push({ label: temp, value: item.contact_name, data: item }); }); response(aca); } }); $el.data('xhr', xhr); }, minLength: 0, delay: 300, select: function (e, ui) { if (ui.item && ui.item.data) { var contact = ui.item.data; $(this) .val(contact.contact_name) .attr("data-id", contact.id) .attr("secondary", contact.contact_name); $(this).trigger('contact:selected', [contact]); return false; } } }) .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") .append($("
    ").html(item.label)) .appendTo(ul); }; $el.on('blur', function () { var secondary = $(this).attr('secondary'); if (secondary) { $(this).val(secondary); } }); }); } /** ========================= * ACCOUNT SEARCH AUTOCOMPLETE * Reusable for accounting pages that already loaded md_account rows. * ========================= */ window.init_account_search_inputs = init_account_search_inputs; function init_account_search_inputs(scope, accounts) { var $scope = scope ? $(scope) : $(document); var accountRows = accounts || window.account_search_accounts || []; $scope.find('input.account_search').addBack('input.account_search').each(function (i, el) { var $el = $(el); if ($el.data('account-search-ready')) { $el.autocomplete('option', 'source', build_account_source(accountRows)); return; } $el.data('account-search-ready', true); $el.autocomplete({ source: build_account_source(accountRows), minLength: 0, delay: 100, select: function (e, ui) { if (ui.item && ui.item.data) { var account = ui.item.data; var display = account.account_code + ' - ' + account.account_name; $(this) .val(display) .attr('secondary', display) .attr('data-account-code', account.account_code); var target = $(this).data('account-code-target'); if (target) { $(this).closest('tr').find(target).val(account.account_code); } $(this).trigger('account:selected', [account]); return false; } } }) .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") .append($("
    ").html(item.label)) .appendTo(ul); }; $el.on('focus', function () { $(this).autocomplete('search', $(this).val()); }); $el.on('blur', function () { var secondary = $(this).attr('secondary'); if (secondary) { $(this).val(secondary); } }); $el.on('input', function () { $(this).removeAttr('data-account-code secondary'); var target = $(this).data('account-code-target'); if (target) { $(this).closest('tr').find(target).val(''); } $(this).trigger('account:cleared'); }); }); } function build_account_source(accounts) { return function(request, response) { var term = String(request.term || '').toLowerCase(); var rows = (accounts || []).filter(function(account) { if (String(account.is_posting) !== '1' || String(account.status) !== '1') return false; var code = String(account.account_code || '').toLowerCase(); var name = String(account.account_name || '').toLowerCase(); var type = String(account.account_type || '').toLowerCase(); return !term || code.includes(term) || name.includes(term) || type.includes(term); }).slice(0, 20); response(rows.map(function(account) { var type = account.account_type ? String(account.account_type).toUpperCase() : 'ACCOUNT'; var label = ""; label += ""; return { label: label, value: account.account_code + ' - ' + account.account_name, data: account }; })); }; } /** ========================= * LOT SEARCH AUTOCOMPLETE * ========================= */ init_lot_search_inputs(); window.init_lot_search_inputs = init_lot_search_inputs; function init_lot_search_inputs(scope) { var $scope = scope ? $(scope) : $(document); $scope.find('input.lot_search').addBack('input.lot_search').each(function (i, el) { var $el = $(el); if ($el.data('lot-search-ready')) return; $el.data('lot-search-ready', true); $el.autocomplete({ source: function (request, response) { if ($el.data('xhr')) { $el.data('xhr').abort(); } var sku = $el.closest('[data-sku]').data('sku') || $('input#product_sku').attr('secondary') || $('input#product_sku').val() || ''; let q = prepare_form_data(0, true); q['keyword'] = request.term; q['product_sku'] = sku; let json = JSON.stringify(q); var xhr = $.ajax({ url: server_url + 'ics/api/engine/retrieve_lot.php', type: 'POST', dataType: 'json', data: { json: json }, headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, success: function (res) { var aca = []; $.each(res.result, function (i, item) { var temp = ""; temp += "
    "; temp += "
    "; temp += "Lot: " + item.lot_number; temp += "
    "; temp += "
    "; temp += "Expiry: " + (item.expiry_date || 'N/A'); temp += "
    "; temp += "
    "; aca.push({ label: temp, value: item.lot_number, data: item }); }); response(aca); } }); $el.data('xhr', xhr); }, minLength: 0, delay: 300, select: function (e, ui) { if (ui.item && ui.item.data) { var lot = ui.item.data; $(this) .val(lot.lot_number) .attr('secondary', lot.lot_number) .attr('data-existing', '1'); $(this).trigger('lot:selected', [lot]); return false; } } }) .data("ui-autocomplete")._renderItem = function (ul, item) { return $("
  • ") .append($("
    ").html(item.label)) .appendTo(ul); }; $el.on('focus', function () { $(this).autocomplete('search', $(this).val()); }); $el.on('blur', function () { var secondary = $(this).attr('secondary'); if (secondary) { $(this).val(secondary); } }); $el.on('input', function () { $(this).removeAttr('data-existing secondary'); $(this).trigger('lot:cleared'); }); }); } }); /** ========================= * SHARED PAGE UTILITIES * ========================= */ function remove_item(idx) { $(`#item_row_${idx}`).remove(); recalc_totals(); } function set_select_value(selector, value, label) { if (!value) return; var $select = $(selector); if (!$select.find(`option[value="${value}"]`).length) { $select.append(``); } $select.val(value).prop('disabled', false); } function load_location_config(callback) { return ajax_request({ url: server_url + 'setting/api/engine/company_setting.php', autoPrepare: true, checkRequired: 0, action: 'read', onSuccess: function(res) { var s = res.output; advanced = s.advanced_location == 1; label_rack = s.location_label_rack || (advanced ? 'Rack' : 'Location'); label_zone = s.location_label_zone || 'Zone'; label_aisle = s.location_label_aisle || 'Aisle'; if (callback) callback(); } }); } /** ========================= * PREPARE FORM DATA * ========================= */ function load_formula_options(select_id, document_type, selected_id) { var ctx = document.getElementById('session-context'); ajax_request({ url: server_url + 'accounting/api/engine/account_formula.php', action: 'get_by_type', queueLock: false, noLoading: true, data: { otp: ctx ? ctx.dataset.otp : '', company_id: ctx ? ctx.dataset.companyId : '', action: 'get_by_type', document_type: document_type }, onSuccess: function(res) { var $sel = $('#' + select_id); $sel.empty().append(''); $.each(res.output || [], function(i, f) { var label = escape_html(f.formula_name) + (f.is_default == 1 ? ' (default)' : ''); $sel.append(''); }); if (selected_id) $sel.val(selected_id); } }); } function load_departments(select_id, selected_id) { var ctx = document.getElementById('session-context'); ajax_request({ url: server_url + 'accounting/api/engine/department.php', action: 'get', queueLock: false, noLoading: true, data: { otp: ctx ? ctx.dataset.otp : '', company_id: ctx ? ctx.dataset.companyId : '', action: 'get' }, onSuccess: function(res) { var $sel = $('#' + select_id); $sel.empty().append(''); $.each(res.output || [], function(i, d) { $sel.append(''); }); if (selected_id) $sel.val(selected_id); } }); } var _dept_cache = {}; function load_dept_cache() { return ajax_request({ url: server_url + 'accounting/api/engine/department.php', action: 'get', queueLock: false, noLoading: true, autoPrepare: true, checkRequired: 0, onSuccess: function(res) { _dept_cache = {}; $.each(res.output || [], function(i, d) { _dept_cache[String(d.id)] = escape_html(d.dept_code) + ' — ' + escape_html(d.dept_name); }); } }); } function get_dept_label(dept_id) { if (!dept_id || String(dept_id) === '0') return '—'; return _dept_cache[String(dept_id)] || ('Dept #' + dept_id); } function populate_dept_filter(select_id) { var $sel = $('#' + select_id); $sel.empty().append(''); $.each(_dept_cache, function(id, label) { $sel.append(''); }); } function prepare_form_data(check_required, raw_data) { var q = {}; // Get session context const session_element = document.getElementById('session-context'); if (session_element) { q['company_id'] = session_element.dataset.companyId; q['otp'] = session_element.dataset.otp; } // Include GET parameters const url_params = new URLSearchParams(window.location.search); url_params.forEach((value, key) => { q[key] = value; }); // Collect form inputs (exclude search inputs — UI-only filters, not API data) $(".form-control:not([type=search]), .form-select").each(function () { if (!$(this).attr("id")) return true; var el = $(this).get(0); if (!el || !el.nodeName) return true; q[$(this).attr("id")] = $(this).val(); }); // Validate required fields if (check_required === 1) { const required_inputs = document.querySelectorAll('[required]'); let is_valid = true; required_inputs.forEach(input => { if (!input.value.trim()) { input.classList.add('is-invalid'); is_valid = false; } else { input.classList.remove('is-invalid'); input.classList.add('is-valid'); } }); if (!is_valid) { alert("Please fill in all mandatory fields."); isAjaxProcessing = false; return false; } } return (raw_data) ? q : JSON.stringify(q); } /** ========================= * AJAX WRAPPER * ========================= */ // Prevent double firing let isAjaxProcessing = false; function ajax_request(options) { if (isAjaxProcessing && options.queueLock !== false) { // Instead of rejecting, we just return a "never-ending" promise // or a resolved promise that does nothing. console.warn("Request is busy... ignoring click."); return new Promise(() => { }); // This stays pending and won't trigger .then or .catch } if (options.queueLock !== false) { isAjaxProcessing = true; } // Auto prepare form data if (options.autoPrepare === true) { let payloadJson = prepare_form_data(options.checkRequired ?? 0, true); if (payloadJson === false) { isAjaxProcessing = false; return Promise.reject("validation_failed"); } if (options.data) { Object.entries(options.data).forEach(([key, value]) => { payloadJson[key] = value; }); } if (options.action) { // modify action if (options.action === 'manage') { options.action = (payloadJson['id']) ? 'update' : 'create'; } // add action to JSON payloadJson['action'] = options.action; } else { isAjaxProcessing = false; return Promise.reject("please_define_action"); } options.data = { json: JSON.stringify(payloadJson) }; if (options.debugMode) { isAjaxProcessing = false; // Show FormData contents if applicable if (options.formData instanceof FormData) { // Log original formData before merging for (let [key, value] of options.formData.entries()) { console.log("FORMDATA: " + key, value); } } // Show stringified JSON payload console.log("REQUEST DATA:", options.data); } // IF formData exist, we pass as $_POST [not json] if (options.formData instanceof FormData) { // THE BYPASS: If formData exists, move all text data into it Object.entries(payloadJson).forEach(([key, value]) => { options.formData.append(key, value); }); // Override options.data with the full FormData object options.data = options.formData; } } // --- START MODIFIED $.AJAX BLOCK --- let isSendingFiles = (options.data instanceof FormData); // Show loading overlay if (options.noLoading !== true) { $.LoadingOverlay("show", { imageColor: "#525252", imageAnimation: "2s rotate_right", background: "rgba(255,255,255,0.8)" }); } return $.ajax({ async: true, type: options.type || "POST", url: options.url, data: options.data, dataType: "json", // These two settings are only triggered when sending files processData: isSendingFiles ? false : true, contentType: isSendingFiles ? false : "application/x-www-form-urlencoded; charset=UTF-8", // for CSRF validation headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } }) .then(function (res) { isAjaxProcessing = false; if (options.noLoading !== true) $.LoadingOverlay("hide"); if (options.debugMode) { console.log("RESPONSE:", res); return res; } if (!res || res.success != 1) { if (options.noAlert !== true) bootbox.alert(res?.message || "Unexpected error"); options.onError?.(null, res?.message || 'api_failed'); throw new Error(res?.message || "api_failed"); } options.onSuccess?.(res); return res; }) .catch(function (xhr) { isAjaxProcessing = false; $.LoadingOverlay("hide"); // Errors re-thrown from .then() — pass through if (xhr instanceof Error) { throw xhr; } // File / payload too large (nginx 413) if (xhr?.status === 413) { bootbox.alert('The file you selected is too large. Please use an image under 2 MB and try again.'); options.onError?.(xhr, 'payload_too_large'); throw xhr; } // Usage limit reached — show upgrade notice instead of generic error if (xhr?.status === 402) { const d = xhr?.responseJSON ?? {}; const daily = d.daily_limit > 0 ? `Daily: ${d.daily_count} / ${d.daily_limit}` : null; const weekly = d.weekly_limit > 0 ? `Weekly: ${d.weekly_count} / ${d.weekly_limit}` : null; const detail = [daily, weekly].filter(Boolean).join('  |  '); bootbox.alert( `Usage limit reached.
    ${detail}

    ` + `Reports are locked until the quota resets. Upgrade your package for higher limits.` ); options.onError?.(xhr, 'limit_reached'); throw xhr; } // Extract server's error message from JSON response let serverMessage = xhr?.responseJSON?.message; // Fallback: parse responseText if responseJSON isn't set if (!serverMessage && xhr?.responseText) { try { serverMessage = JSON.parse(xhr.responseText)?.message; } catch (e) { // Response wasn't JSON — real server crash or HTML error page } } if (serverMessage) { if (options.noAlert !== true) bootbox.alert(serverMessage); options.onError?.(xhr, serverMessage); } else { console.error("AJAX Error:", xhr?.status, xhr?.responseText); if (options.noAlert !== true) bootbox.alert("Server error occurred."); options.onError?.(xhr, null); } throw xhr; }); } /** ========================= * GENERATE PAGINATION * ========================= */ function generate_pagination(table_id, total_records, records_per_page, current_page = 1) { records_per_page = records_per_page ?? prop_limit; const total_pages = Math.ceil(total_records / records_per_page); // 1. Find the table and thead const table = document.getElementById(table_id); let theme_class = ''; let col_count = 1; if (table) { const thead = table.querySelector('thead'); if (thead) { // 2. Look through classes for one that starts with "table-" const bootstrap_theme = Array.from(thead.classList).find(c => c.startsWith('table-')); if (bootstrap_theme) { // 3. Convert "table-secondary" to "page-item-secondary" theme_class = bootstrap_theme.replace('table-', 'page-item-'); } // 4. Count in the first row of thead const header_row = thead.querySelector('tr'); if (header_row) { col_count = header_row.querySelectorAll('th').length || 1; } } } const left_span = Math.floor(col_count / 2); const right_span = Math.ceil(col_count / 2); let pagination_html = ` Showing ${records_per_page} per page
    Total: ${total_records}
    `; return pagination_html; } function handlePageClick(table_id, element, pageNumber) { // 1. Remove 'active' class from all siblings within this specific pagination bar const paginationUl = element.closest('.pagination'); paginationUl.querySelectorAll('.page-item').forEach(li => { li.classList.remove('active'); }); // 2. Add 'active' class to the clicked parent
  • element.parentElement.classList.add('active'); // 3. Define the dynamic function name const dynamicFunctionName = `change_page_${table_id}`; // 4. Check if the specific dynamic function exists and call it if (typeof window[dynamicFunctionName] === "function") { window[dynamicFunctionName](pageNumber); } else { console.error(`Function ${dynamicFunctionName} not found!`); } } /** ========================= * FLATPICKR INITIALIZE * ========================= */ flatpickr(".flatpickr", { enableTime: true, time_24hr: true, defaultDate: new Date(), altInput: true, altFormat: "d/m/Y H:i:s", dateFormat: "Y-m-d H:i:s" }); /** ========================= * REAL-TIME REQUIRED VALIDATION * ========================= */ document.addEventListener('DOMContentLoaded', () => { const required_inputs = document.querySelectorAll('[required]'); required_inputs.forEach(input => { input.addEventListener('input', function () { if (this.value.trim() !== "") { this.classList.remove('is-invalid'); this.classList.add('is-valid'); } else { this.classList.remove('is-valid'); this.classList.add('is-invalid'); } }); }); }); /** * Helper function to format date strings (assuming input is in ISO format) */ function format_date(iso_string) { if (!iso_string) return '—'; var split = iso_string.split(" "); var datePart = split[0].split("-"); if (datePart.length !== 3) return iso_string; var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`; return split.length === 2 ? `${formatted} ${split[1]}` : formatted; } function to_iso_date(dateStr) { if (!dateStr) return null; const parts = dateStr.split('/'); if (parts.length !== 3) return null; let [day, month, year] = parts; day = day.padStart(2, '0'); month = month.padStart(2, '0'); return `${year}-${month}-${day}`; } function round_dp(value, places) { var factor = Math.pow(10, places); return Math.round((Number(value) + Number.EPSILON) * factor) / factor; } function expand_exponential_number(value) { var str = String(value); if (!/[eE]/.test(str)) return str; var sign = ''; if (str[0] === '-') { sign = '-'; str = str.slice(1); } var parts = str.toLowerCase().split('e'); var mantissa = parts[0]; var exponent = parseInt(parts[1], 10); if (isNaN(exponent)) return sign + str; var mantissa_parts = mantissa.split('.'); var integer = mantissa_parts[0]; var fraction = mantissa_parts[1] || ''; var digits = integer + fraction; var point = integer.length + exponent; if (point <= 0) { return sign + '0.' + '0'.repeat(Math.abs(point)) + digits; } if (point >= digits.length) { return sign + digits + '0'.repeat(point - digits.length); } return sign + digits.slice(0, point) + '.' + digits.slice(point); } function format_number(value, decimal) { var n = Number(value); if (isNaN(n)) return '--'; if (Math.abs(n) < 0.000005) n = 0; if (decimal !== undefined) { n = round_dp(n, decimal); if (Math.abs(n) < 0.000005) n = 0; n = n.toFixed(decimal); } else { n = expand_exponential_number(n); } var parts = String(n).split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return parts.join('.'); } /** * set_btn_state(selector, enabled, hint) * * Enable or disable a button with consistent visual feedback: * enabled → restores original btn-* classes, removes disabled, clears title * disabled → swaps to btn-secondary, adds disabled attr, sets title hint * * Stores the original class on first call via data-original-class so * repeated calls always restore the correct variant. * * @param {string} selector jQuery selector e.g. '#btn_confirm' * @param {boolean} enabled true = enable, false = disable * @param {string} hint Tooltip text shown when disabled */ function set_btn_state(selector, enabled, hint) { var $btn = $(selector); if (!$btn.length) return; // Store original classes once if (!$btn.data('original-class')) { $btn.data('original-class', $btn.attr('class')); } if (enabled) { $btn.attr('class', $btn.data('original-class')) .prop('disabled', false) .attr('title', ''); } else { $btn.attr('class', $btn.data('original-class') .replace(/btn-(outline-)?[a-z]+/g, '') + ' btn-light') .prop('disabled', true) .attr('title', hint || ''); } } var ROLE_RANK = { viewer: 0, staff: 1, admin: 2, owner: 3 }; function apply_role_ui() { var role = document.getElementById('session-context')?.dataset.role || 'viewer'; var rank = ROLE_RANK[role] ?? 0; $('[data-min-role]').each(function() { var required = ROLE_RANK[$(this).data('min-role')] ?? 99; if (rank < required) $(this).hide(); }); } $(function() { apply_role_ui(); if (window.MutationObserver) { var roleObserver = new MutationObserver(function() { apply_role_ui(); }); roleObserver.observe(document.body, { childList: true, subtree: true }); } }); function show_stock_rows(source, source_id, label) { $('#stock_rows_modal_title').text('Stock — ' + label); $('#stock_rows_tbody').html('Loading...'); var modal = new bootstrap.Modal(document.getElementById('stock_rows_modal')); modal.show(); ajax_request({ url: server_url + 'ics/api/engine/retrieve_stock_by_source.php', autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false, action: 'read', data: { source: source, source_id: source_id }, onSuccess: function(res) { var rows = res.output || []; if (!rows.length) { $('#stock_rows_tbody').html('No stock transactions found.'); return; } var html = ''; $.each(rows, function(i, r) { var type_badge = r.type === 'in' ? 'In' : 'Out'; var status_badge = {'-1':'Cancelled', '0':'Draft', '1':'Approved'}[String(r.status)] || '—'; var location = [r.zone, r.aisle, r.rack].filter(function(v) { return v && v !== r.rack; }).concat([r.rack]).filter(Boolean).join(' / '); html += ` ${type_badge} ${escape_html(r.product_name || r.product_sku)}
    ${escape_html(r.product_sku)} ${escape_html(r.warehouse_name)} ${escape_html(location)} ${escape_html(r.lot_number || '—')} ${format_number(r.quantity, 2)} ${status_badge} ${format_date(r.date)} `; }); $('#stock_rows_tbody').html(html); } }); } // ─── Generic batch processor ───────────────────────────────────────────────── // Submits items one at a time, waiting for each response before proceeding. // One lock per key prevents two chains running concurrently for the same job. // ── DB operation locks ──────────────────────────────────────────────────── // acquire_op_lock / release_op_lock use md_lock_operation for cross-tab, // cross-user locking. operation_type is the lock key (e.g. 'gl_post'). var _active_op_locks = []; // tracks locks held by this tab function acquire_op_lock(operation_type, onSuccess, onFail, ttl_minutes) { var ctx = document.getElementById('session-context'); ajax_request({ url: server_url + 'accounting/api/engine/acquire_op_lock.php', action: 'post', queueLock: false, noLoading: true, data: { otp: ctx ? ctx.dataset.otp : '', company_id: ctx ? ctx.dataset.companyId : '', action: 'post', operation_type: operation_type, ttl_minutes: ttl_minutes || 120 }, onSuccess: function(res) { if (res.success) { _active_op_locks.push(operation_type); onSuccess(); } else { if (onFail) onFail(res.message, res); } } }); } function release_op_lock(operation_type) { _active_op_locks = _active_op_locks.filter(function(t) { return t !== operation_type; }); var ctx = document.getElementById('session-context'); ajax_request({ url: server_url + 'accounting/api/engine/release_op_lock.php', action: 'post', queueLock: false, noLoading: true, data: { otp: ctx ? ctx.dataset.otp : '', company_id: ctx ? ctx.dataset.companyId : '', action: 'post', operation_type: operation_type }, onSuccess: function() {} }); } // Release all held locks on tab/browser close via keepalive fetch // (async XHR is killed on unload; keepalive fetch survives) window.addEventListener('beforeunload', function() { if (!_active_op_locks.length) return; var ctx = document.getElementById('session-context'); if (!ctx) return; var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; _active_op_locks.forEach(function(operation_type) { fetch(server_url + 'accounting/api/engine/release_op_lock.php', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, body: JSON.stringify({ otp: ctx.dataset.otp, company_id: ctx.dataset.companyId, action: 'post', operation_type: operation_type }) }); }); }); // ── Batch processing ────────────────────────────────────────────────────── // Each item is retried up to 3 times before being marked failed (covers // transient errors such as MySQL lock-wait timeouts). var _batch_locks = {}; // delay_ms: throttle interval between items (default 80ms) — back-pressure on MySQL function batch_process(key, items, url, make_payload, onProgress, onDone, delay_ms) { if (_batch_locks[key]) { console.warn('batch_process: already running for key=' + key); return; } _batch_locks[key] = true; var total = items.length; var failed = []; var interval = (typeof delay_ms === 'number' && delay_ms >= 0) ? delay_ms : 200; function release() { _batch_locks[key] = false; } function run_next(index, attempt) { if (index >= total) { release(); onDone(failed); return; } var item = items[index]; ajax_request({ url: url, action: 'post', noLoading: true, queueLock: false, noAlert: true, data: make_payload(item), onSuccess: function(res) { onProgress(index + 1, total, item, res); setTimeout(function() { run_next(index + 1, 0); }, interval); }, onError: function(xhr, msg) { if (attempt < 2) { // Retry — covers success:0 (e.g. lock-wait timeout) and HTTP 500 setTimeout(function() { run_next(index, attempt + 1); }, interval); } else { // 3 attempts exhausted — mark failed, continue to next item failed.push({ item: item, error: msg || 'unknown error' }); onProgress(index + 1, total, item, null); setTimeout(function() { run_next(index + 1, 0); }, interval); } } }); } run_next(0, 0); }