- sign-in pages load only what they use (include_login_header.php, ajax_core.js) - every library, font and data file served from assets/vendor with pinned versions (VERSIONS.json); no CDN at runtime - Content-Security-Policy on app, sign-in and landing pages - remove httpbin Dropzone target and source-map references
1112 lines
35 KiB
JavaScript
1112 lines
35 KiB
JavaScript
|
|
/** =========================
|
|
* 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 () {
|
|
|
|
|
|
// 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 += "<div class='product-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
|
|
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
|
|
temp += "SKU: " + (item.sku || 'N/A');
|
|
temp += "</div>";
|
|
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
|
|
temp += item.product_name;
|
|
temp += "</div>";
|
|
temp += "</div>";
|
|
|
|
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 $("<li>")
|
|
.append($("<div>").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 += "<div class='contact-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
|
|
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
|
|
temp += "Tax ID: " + (item.tax_id || 'N/A');
|
|
temp += "</div>";
|
|
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
|
|
temp += item.contact_name;
|
|
temp += "</div>";
|
|
temp += "</div>";
|
|
|
|
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 $("<li>")
|
|
.append($("<div>").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 $("<li>")
|
|
.append($("<div>").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 += "<div class='account-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
|
|
label += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
|
|
label += type + " · " + escape_html(account.account_code || '');
|
|
label += "</div>";
|
|
label += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
|
|
label += escape_html(account.account_name || '');
|
|
label += "</div>";
|
|
label += "</div>";
|
|
|
|
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 += "<div class='lot-result-item' style='font-family:\"Poppins\",sans-serif;padding:4px 0;'>";
|
|
temp += "<div style='font-size:10px;text-transform:uppercase;color:#E66239;font-weight:600;line-height:1.2;'>";
|
|
temp += "Lot: " + item.lot_number;
|
|
temp += "</div>";
|
|
temp += "<div style='font-size:13px;font-weight:500;color:#171717;margin-top:2px;'>";
|
|
temp += "Expiry: " + (item.expiry_date || 'N/A');
|
|
temp += "</div>";
|
|
temp += "</div>";
|
|
|
|
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 $("<li>")
|
|
.append($("<div>").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(`<option value="${value}">${label || value}</option>`);
|
|
}
|
|
$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_bin = s.location_label_bin || (advanced ? 'Bin' : '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, onLoaded) {
|
|
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();
|
|
$.each(res.output || [], function(i, f) {
|
|
var label = escape_html(f.formula_name) + (f.is_default == 1 ? ' (default)' : '');
|
|
$sel.append('<option value="' + f.id + '">' + label + '</option>');
|
|
});
|
|
if (selected_id) $sel.val(selected_id);
|
|
if (onLoaded) onLoaded(res.output || []);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Line tax rate; derived from tax_amount / total_price when only the amount was stored
|
|
function line_tax_rate(item) {
|
|
var rate = parseFloat(item.tax_rate) || 0;
|
|
var amount = parseFloat(item.tax_amount) || 0;
|
|
var total = parseFloat(item.total_price) || 0;
|
|
if (rate === 0 && amount > 0 && total > 0) rate = round_dp(amount / total * 100, 2);
|
|
return rate;
|
|
}
|
|
|
|
// Returns the request promise so callers can await the options before selecting a value
|
|
function load_departments(select_id, selected_id) {
|
|
var ctx = document.getElementById('session-context');
|
|
return 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('<option value="0">— No Department —</option>');
|
|
$.each(res.output || [], function(i, d) {
|
|
$sel.append('<option value="' + d.id + '">'
|
|
+ escape_html(d.dept_code) + ' — ' + escape_html(d.dept_name)
|
|
+ '</option>');
|
|
});
|
|
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('<option value="">All Departments</option>');
|
|
$.each(_dept_cache, function(id, label) {
|
|
$sel.append('<option value="' + id + '">' + label + '</option>');
|
|
});
|
|
}
|
|
|
|
|
|
|
|
/** =========================
|
|
* 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 <th> 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 = `
|
|
<tr>
|
|
<td colspan="${left_span}" class="border-bottom-0">
|
|
<span class="text-muted">Showing ${records_per_page} per page</span><br>
|
|
<strong>Total: ${total_records}</strong>
|
|
</td>
|
|
<td colspan="${right_span}" class="border-bottom-0" style="text-align:right;">
|
|
<nav aria-label="Page navigation" class="d-flex justify-content-end">
|
|
<ul class="pagination mb-0">`;
|
|
|
|
for (let i = 1; i <= total_pages; i++) {
|
|
// Check if 'i' matches the current_page passed to the function
|
|
let active_class = (i === current_page) ? 'active' : '';
|
|
|
|
pagination_html += `
|
|
<li class="page-item ${theme_class} ${active_class}" data-page="${i}">
|
|
<a class="page-link" href="javascript:;" onclick="handlePageClick('${table_id}', this, ${i})">${i}</a>
|
|
</li>`;
|
|
}
|
|
|
|
pagination_html += `</ul></nav></div></td></tr>`;
|
|
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 <li>
|
|
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"
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
* Helper function to format date strings (assuming input is in ISO format)
|
|
*/
|
|
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
|
|
function format_date(iso_string) {
|
|
if (!iso_string) return '—';
|
|
var split = String(iso_string).split(" ");
|
|
var datePart = split[0].split("-");
|
|
if (datePart.length !== 3) return iso_string;
|
|
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
|
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
|
}
|
|
|
|
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
|
|
function format_date_input(iso_string) {
|
|
if (!iso_string) return '';
|
|
var datePart = String(iso_string).split(" ")[0].split("-");
|
|
if (datePart.length !== 3) return iso_string;
|
|
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
|
}
|
|
|
|
|
|
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}`;
|
|
}
|
|
|
|
|
|
/**
|
|
* Clear every field inside a form or form-like container — the "Clear" buttons
|
|
* on the master-data pages. It was called by five pages but never defined, so
|
|
* each click threw a ReferenceError, and where the container is a <div> rather
|
|
* than a <form> (Chart of Accounts, Departments) the button did nothing at all.
|
|
* Disabled and hidden inputs are left alone: those carry the record id and
|
|
* locked values, not user input.
|
|
*/
|
|
function reset_input(selector) {
|
|
var $scope = $(selector);
|
|
if (!$scope.length) return;
|
|
|
|
$scope.find('input, textarea, select').each(function () {
|
|
if (this.disabled || this.type === 'hidden' || this.type === 'button' || this.type === 'submit') return;
|
|
|
|
if (this._flatpickr) {
|
|
this._flatpickr.clear();
|
|
} else if (this.type === 'checkbox' || this.type === 'radio') {
|
|
this.checked = this.defaultChecked;
|
|
} else if (this.tagName === 'SELECT') {
|
|
this.selectedIndex = 0;
|
|
} else {
|
|
this.value = '';
|
|
}
|
|
|
|
$(this).removeAttr('secondary').removeClass('is-invalid is-valid');
|
|
});
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Format a stock quantity without hiding real data.
|
|
*
|
|
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
|
|
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
|
|
* data" — the stock popups and list pages all showed an empty-looking QTY for
|
|
* a receipt that had in fact been made. Show 2 dp normally, and the stored
|
|
* 4 dp whenever rounding to 2 would lose something.
|
|
*/
|
|
function format_quantity(value) {
|
|
var n = Number(value);
|
|
if (isNaN(n)) return '--';
|
|
|
|
return (round_dp(n, 2) !== round_dp(n, 4))
|
|
? format_number(n, 4)
|
|
: format_number(n, 2);
|
|
}
|
|
|
|
|
|
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('<tr><td colspan="8" class="text-center py-4 text-muted">Loading...</td></tr>');
|
|
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('<tr><td colspan="8" class="text-center py-4 text-muted">No stock transactions found.</td></tr>');
|
|
return;
|
|
}
|
|
var html = '';
|
|
$.each(rows, function(i, r) {
|
|
var type_badge = r.type === 'in'
|
|
? '<span class="badge bg-success">In</span>'
|
|
: '<span class="badge bg-danger">Out</span>';
|
|
var status_badge = {'-1':'<span class="badge bg-secondary">Cancelled</span>',
|
|
'0':'<span class="badge bg-warning text-dark">Draft</span>',
|
|
'1':'<span class="badge bg-success">Approved</span>'}[String(r.status)]
|
|
|| '—';
|
|
var location = [r.zone, r.aisle, r.bin].filter(function(v) {
|
|
return v && v !== r.bin;
|
|
}).concat([r.bin]).filter(Boolean).join(' / ');
|
|
html += `<tr>
|
|
<td class="ps-4">${type_badge}</td>
|
|
<td>${escape_html(r.product_name || r.product_sku)}<br><small class="text-muted">${escape_html(r.product_sku)}</small></td>
|
|
<td>${escape_html(r.warehouse_name)}</td>
|
|
<td><small>${escape_html(location)}</small></td>
|
|
<td><small>${escape_html(r.lot_number || '—')}</small></td>
|
|
<td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
|
|
<td>${status_badge}</td>
|
|
<td><small>${format_date(r.date)}</small></td>
|
|
</tr>`;
|
|
});
|
|
$('#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);
|
|
}
|