").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(`
${label || value} `);
}
$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('
' + label + ' ');
});
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('
— No Department — ');
$.each(res.output || [], function(i, d) {
$sel.append('
'
+ escape_html(d.dept_code) + ' — ' + escape_html(d.dept_name)
+ ' ');
});
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('
All Departments ');
$.each(_dept_cache, function(id, label) {
$sel.append('
' + label + ' ');
});
}
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('
Signed in from another device. 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:
${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)
*/
// 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 rather
* than a