Files
wms-app/app/assets/js/custom.js
T
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00

1383 lines
43 KiB
JavaScript

function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 += "<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>');
});
}
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;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- 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;
}
// Session displaced — another login took over this account
if (xhr?.status === 401 && xhr?.responseJSON?.code === 'signed_elsewhere') {
bootbox.alert('<strong>Signed in from another device.</strong><br>Your session has been ended because this account was signed in elsewhere.', function() {
window.location.href = server_url + 'index.php';
});
return;
}
// 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: <strong>${d.daily_count} / ${d.daily_limit}</strong>` : null;
const weekly = d.weekly_limit > 0 ? `Weekly: <strong>${d.weekly_count} / ${d.weekly_limit}</strong>` : null;
const detail = [daily, weekly].filter(Boolean).join('&nbsp;&nbsp;|&nbsp;&nbsp;');
bootbox.alert(
`<strong>Usage limit reached.</strong><br>${detail}<br><br>` +
`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 <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"
});
/** =========================
* 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)
*/
// 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}`;
}
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);
}