diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js
index a86a945..12b9f39 100644
--- a/app/assets/js/custom.js
+++ b/app/assets/js/custom.js
@@ -772,6 +772,42 @@ function ajax_request(options) {
// 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 ---
@@ -1073,6 +1109,25 @@ function expand_exponential_number(value) {
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 '--';
@@ -1184,7 +1239,7 @@ function show_stock_rows(source, source_id, label) {
${escape_html(r.warehouse_name)}
${escape_html(location)}
${escape_html(r.lot_number || '—')}
-
${format_number(r.quantity, 2)}
+
${format_quantity(r.quantity)}
${status_badge}
${format_date(r.date)}
`;
diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php
index d8bcee7..6de171e 100644
--- a/app/assets/utils/classes/InvoiceManager.php
+++ b/app/assets/utils/classes/InvoiceManager.php
@@ -403,12 +403,47 @@ class InvoiceManager {
* @param array $logging Audit entry.
* @throws Exception If invoice not found or not in draft status.
*/
+ /**
+ * Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
+ * that is not a real calendar date.
+ *
+ * The date pickers display d/m/Y, and a page that forgets to convert before
+ * posting sends that text straight through to a MySQL DATE column, where it
+ * fails as a PDOException and surfaces to the user as the opaque
+ * "Database error, please try again." Accepting both spellings here keeps
+ * the failure mode a named, actionable message instead.
+ *
+ * @param string $value ISO or d/m/Y date; '' is treated as "not set".
+ * @param string $label Field name used in the error message.
+ * @return string|null ISO date, or null when nothing was supplied.
+ * @throws Exception When the value is not a valid date.
+ */
+ private function normaliseDate(string $value, string $label): ?string
+ {
+ $value = trim($value);
+ if ($value === '') return null;
+
+ // Strip a time part, if the caller passed a datetime.
+ $value = explode(' ', $value)[0];
+
+ foreach (['Y-m-d', 'd/m/Y'] as $format) {
+ $parsed = DateTime::createFromFormat('!' . $format, $value);
+ // createFromFormat() accepts overflowing values such as 32/01/2026
+ // and rolls them over, so compare the round-trip to reject those.
+ if ($parsed && $parsed->format($format) === $value) {
+ return $parsed->format('Y-m-d');
+ }
+ }
+
+ throw new Exception("{$label} is not a valid date.");
+ }
+
public function saveInvoice(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
- "SELECT status, doc_type, issued_date, `log` FROM td_invoice
+ "SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -428,8 +463,21 @@ class InvoiceManager {
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
+ // Absent key means "not being edited" — keep what is stored rather than
+ // clearing it, so a caller that posts only tax_adjustment cannot wipe
+ // the agreed payment term.
+ $due_date = array_key_exists('due_date', $data)
+ ? $this->normaliseDate((string)$data['due_date'], 'Due date')
+ : ($row['due_date'] ?: null);
+
+ $issued_date = $row['issued_date'] ?: null;
+
+ if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
+ throw new Exception("The due date cannot be earlier than the issue date.");
+ }
+
$params = [
- ':due_date' => $data['due_date'] ?: null,
+ ':due_date' => $due_date,
':notes' => $data['notes'] ?? '',
':formula_id' => $formula_id,
':log' => json_encode($log),
@@ -525,6 +573,11 @@ class InvoiceManager {
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
throw new Exception("Due date is required before issuing this document.");
}
+
+ $due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
+ if ($due_date !== null && $due_date < $issued_date) {
+ throw new Exception("The due date cannot be earlier than the issue date.");
+ }
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
$log = json_decode($row['log'] ?? '[]', true) ?: [];
diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php
index 43da110..159d822 100644
--- a/app/assets/utils/classes/PurchaseOrderManager.php
+++ b/app/assets/utils/classes/PurchaseOrderManager.php
@@ -1,6 +1,7 @@
0).
*
- * @param int $warehouse_id The md_warehouse.id to query.
+ * @param int $warehouse_id The md_warehouse.id to query, or 0 for every
+ * warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
- * @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
+ * @return array Stock rows ordered by date DESC, each with 'quantity',
+ * 'product_name', 'warehouse_id' and 'warehouse_name'.
*/
public function getStockList(int $warehouse_id, string $type): array
{
- $table = $this->stockTableNameFromWarehouseId($warehouse_id);
+ // warehouse_id 0 = every warehouse. Stock lives in one table per
+ // warehouse, so a single-warehouse list hides the rest of a receipt
+ // that was split across warehouses — a 4-line PO received into two of
+ // them looked like only 3 lines had been received.
+ $warehouses = $warehouse_id > 0
+ ? [$warehouse_id]
+ : $this->warehouseIdsWithStockTable();
+
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
- $column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
+ // Quantity is NOT rounded for display here: rounding to 2 dp reports a
+ // small-but-real quantity as "0.00", which reads as missing data.
+ $column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
+ $rows = [];
+
+ foreach ($warehouses as $wh_id) {
+ $table = $this->stockTableNameFromWarehouseId($wh_id);
+
+ $sth = $this->pdo->prepare(
+ "SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
+ b.product_name, b.uom,
+ w.warehouse_name
+ FROM `{$table}` a
+ LEFT JOIN md_product b
+ ON a.company_id = b.company_id
+ AND a.product_sku = b.sku
+ LEFT JOIN md_warehouse w
+ ON w.company_id = a.company_id
+ AND w.id = :warehouse_id
+ WHERE a.company_id = :company_id
+ AND a.type = :type
+ {$extra_cond}
+ ORDER BY a.date DESC"
+ );
+ $sth->execute([
+ ':company_id' => $this->company_id,
+ ':warehouse_id' => $wh_id,
+ ':type' => $type,
+ ]);
+
+ foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ // The row's own warehouse, so the list can link each Action
+ // back to the right td_stock_ table when showing them all.
+ $row['warehouse_id'] = $wh_id;
+ $rows[] = $row;
+ }
+ }
+
+ // Re-sort across warehouses — each table was only ordered internally.
+ usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
+
+ return $rows;
+ }
+
+ /**
+ * Warehouse ids of this company that actually have a stock table.
+ *
+ * td_stock_ tables are created lazily on first use, so a warehouse with
+ * no movements yet has none and must be skipped rather than queried.
+ *
+ * @return int[]
+ */
+ private function warehouseIdsWithStockTable(): array
+ {
$sth = $this->pdo->prepare(
- "SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
- FROM `{$table}` a
- LEFT JOIN md_product b
- ON a.company_id = b.company_id
- AND a.product_sku = b.sku
- WHERE a.company_id = :company_id
- AND a.type = :type
- {$extra_cond}
- ORDER BY a.date DESC"
+ "SELECT w.id
+ FROM md_warehouse w
+ JOIN information_schema.tables t
+ ON t.table_schema = DATABASE()
+ AND t.table_name = CONCAT('td_stock_', w.id)
+ WHERE w.company_id = :company_id
+ ORDER BY w.id"
);
- $sth->execute([
- ':company_id' => $this->company_id,
- ':type' => $type,
- ]);
- return $sth->fetchAll(PDO::FETCH_ASSOC);
+ $sth->execute([':company_id' => $this->company_id]);
+ return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
}
/**
@@ -258,8 +360,8 @@ class StockManager {
$warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0);
- if ($id === 0 && $quantity <= 0) {
- throw new Exception("Quantity must be greater than zero.");
+ if ($id === 0) {
+ $quantity = self::normaliseQuantity($quantity);
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
diff --git a/app/assets/utils/timezone.php b/app/assets/utils/timezone.php
new file mode 100644
index 0000000..5c18fad
--- /dev/null
+++ b/app/assets/utils/timezone.php
@@ -0,0 +1,40 @@
+getOffset(new DateTime('now', $tz));
+ define('APP_TIMEZONE_OFFSET', sprintf(
+ '%s%02d:%02d',
+ $offset_seconds < 0 ? '-' : '+',
+ intdiv(abs($offset_seconds), 3600),
+ intdiv(abs($offset_seconds) % 3600, 60)
+ ));
+}
diff --git a/app/dbconn.php b/app/dbconn.php
index e698975..0a7a53b 100644
--- a/app/dbconn.php
+++ b/app/dbconn.php
@@ -1,5 +1,9 @@
setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//..................... PDO2 .....................//
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2);
-$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
\ No newline at end of file
+$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+// Pin both connections to the application timezone, so MySQL NOW() and PHP
+// date() agree no matter how the database server itself is configured. Queries
+// mix the two freely (rows written with NOW(), others with date()), and a
+// mismatch shows up as timestamps hours away from the real clock.
+foreach ([$pdo1, $pdo2] as $pdo_tz) {
+ try {
+ $pdo_tz->exec("SET time_zone = '" . APP_TIMEZONE_OFFSET . "'");
+ } catch (PDOException $e) {
+ // A server that refuses the offset keeps its own zone — no worse than
+ // before this call existed, and not a reason to fail the request.
+ }
+}
\ No newline at end of file
diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php
index c6043af..184d97d 100644
--- a/app/ics/manage_stock_in.php
+++ b/app/ics/manage_stock_in.php
@@ -257,6 +257,31 @@
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val();
+ // Quantities are stored as decimal(18,4). Anything finer is rounded away
+ // on insert, so 0.0000001 used to become a movement of 0.0000 that still
+ // looked like a successful stock-in. Reject it here with a message that
+ // names the limit; StockManager enforces the same rule server-side.
+
+ var quantity_num = parseFloat(quantity_val);
+
+ if (!(quantity_num > 0)) {
+ bootbox.alert('Please enter a quantity greater than zero.');
+ return Promise.resolve();
+ }
+ if (round_dp(quantity_num, 4) < 0.0001) {
+ bootbox.alert('Quantity ' + quantity_num + ' is smaller than the minimum the system records (0.0001).');
+ return Promise.resolve();
+ }
+ quantity_val = round_dp(quantity_num, 4);
+
+ // Expiry dates are stored on the lot, so one entered without a lot number
+ // has nowhere to go and would be dropped without warning.
+ if ($('#expiry_date').val() && !$('#lot_number').val().trim()) {
+ bootbox.alert('Enter a lot number — expiry dates are recorded against a lot.');
+ return Promise.resolve();
+ }
+
+
// Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) {
$('#zone').val(bin_val);
@@ -462,7 +487,18 @@
.val(data.expiry_date);
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
} else {
+ // No lot number, so there is nothing for an expiry date to hang
+ // off: expiry lives on md_lot, keyed by lot. Leaving the field
+ // empty but editable invited entering one that would be silently
+ // discarded on update, so say why it is unavailable instead.
if (expiryPicker) { expiryPicker.clear(); }
+ $('#expiry_date').prop('disabled', true)
+ .attr('title', 'Expiry dates are recorded against a lot — this movement has no lot number')
+ .attr('placeholder', 'Not tracked — no lot number');
+ if (expiryPicker && expiryPicker.altInput) {
+ expiryPicker.altInput.disabled = true;
+ expiryPicker.altInput.placeholder = 'Not tracked — no lot number';
+ }
}
$('#product_sku').attr('secondary', data.product_sku);
$('#product_sku, #quantity, #price').prop('disabled', true);
diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php
index df0648d..ec55a47 100644
--- a/app/ics/stock_in.php
+++ b/app/ics/stock_in.php
@@ -32,7 +32,7 @@
-
+
@@ -56,6 +56,7 @@
Date
Reference
+
Warehouse
Product
Lot Number
Serial Number
@@ -210,18 +211,21 @@
var body = ``;
- var warehouse = $(`select#warehouse`).val();
-
$.each(page_data, function(key, item) {
+ // The row's own warehouse, not the filter — with "All Warehouses" the
+ // filter has no value, and each row lives in its own td_stock_ table.
+ var warehouse = item['warehouse_id'];
+
body += `
@@ -255,9 +259,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
- var option = ``;
+ // Default to every warehouse. Stock is stored one table per warehouse,
+ // so defaulting to a single one made a receipt that was split across
+ // warehouses look incomplete — a 4-line PO showed only the 3 lines that
+ // landed in the selected warehouse.
+ var option = ``;
$.each(res.output, function(key, item) {
- option += ``;
+ option += ``;
})
$(`select#warehouse`).html(option);
}
@@ -265,7 +273,7 @@
}
- function delete_stock_in(element, id) {
+ function delete_stock_in(element, id, warehouse_id) {
return ajax_request({
url: "ics/api/engine/delete_stock_in.php",
@@ -274,7 +282,7 @@
action: 'delete',
data : {
id: id,
- wh: $(`select#warehouse`).val()
+ wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
diff --git a/app/ics/stock_out.php b/app/ics/stock_out.php
index 2371329..dd0af98 100644
--- a/app/ics/stock_out.php
+++ b/app/ics/stock_out.php
@@ -32,7 +32,7 @@
-
+
@@ -56,6 +56,7 @@
Date
Reference
+
Warehouse
Product
Lot Number
Serial Number
@@ -151,7 +152,7 @@
}
var delete_btn = (!source)
- ? `
+ ? ``
: '';
@@ -237,18 +238,21 @@
var body = ``;
- var warehouse = $(`select#warehouse`).val();
-
$.each(page_data, function(key, item) {
+ // The row's own warehouse, not the filter — with "All Warehouses" the
+ // filter has no value, and each row lives in its own td_stock_ table.
+ var warehouse = item['warehouse_id'];
+
body += `
@@ -275,9 +279,12 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
- var option = ``;
+ // Default to every warehouse — stock is stored one table per
+ // warehouse, so a single-warehouse default hides movements that went
+ // elsewhere and makes a document look only partly processed.
+ var option = ``;
$.each(res.output, function(key, item) {
- option += ``;
+ option += ``;
})
$(`select#warehouse`).html(option);
}
@@ -285,7 +292,7 @@
}
- function delete_stock_out(element, id) {
+ function delete_stock_out(element, id, warehouse_id) {
return ajax_request({
url: "ics/api/engine/delete_stock_out.php",
@@ -294,7 +301,7 @@
action: 'delete',
data : {
id: id,
- wh: $(`select#warehouse`).val()
+ wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
diff --git a/app/include_header.php b/app/include_header.php
index 65ed825..b20c91d 100644
--- a/app/include_header.php
+++ b/app/include_header.php
@@ -18,6 +18,11 @@ if (ob_get_level() === 0) {
ini_set('display_errors', '0');
ini_set('log_errors', '1');
+// Apply the configured application timezone. Pages that only require config.php
+// (no dbconn.php) still call date() for default values such as "today", so they
+// need this too or they render a UTC date.
+require_once __DIR__ . '/assets/utils/timezone.php';
+
// Security headers — emitted before any HTML output.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php
index e9c07fa..0166c76 100644
--- a/app/login/api/engine/login_confirm.php
+++ b/app/login/api/engine/login_confirm.php
@@ -134,15 +134,17 @@ if (empty($_SESSION['skip_otp'])) {
// An explicit logout clears session_token to NULL, so back.php bypasses this.
//
// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's
-// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and
-// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is
-// UTC (config.php's $time_zone is never applied via date_default_timezone_set()).
-// Pulling the timestamp into PHP and comparing with strtotime()/time() silently
-// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which
-// made idle_seconds permanently negative and this check block every login,
-// regardless of window size. Comparing inside MySQL sidesteps the mismatch
-// without touching PHP's global timezone (which would ripple into every other
-// date()/time() call in the app).
+// own NOW()), not in PHP, because session_last_seen is written with MySQL's
+// NOW() and so is best compared against it.
+//
+// This originally worked around a timezone mismatch: config.php's $time_zone was
+// never applied via date_default_timezone_set(), so PHP ran on UTC while the
+// MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing
+// with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours
+// in the future — which made idle_seconds permanently negative and blocked every
+// login. assets/utils/timezone.php now applies $time_zone to PHP and pins both
+// PDO connections to the same offset, so the mismatch is gone; comparing in SQL
+// is kept because it is still the most direct way to read a NOW()-written column.
define('SESSION_ACTIVE_GRACE_SECONDS', 120);
$sth_active = $pdo1->prepare(
diff --git a/app/po/manage_po.php b/app/po/manage_po.php
index f241786..aa75577 100644
--- a/app/po/manage_po.php
+++ b/app/po/manage_po.php
@@ -527,9 +527,9 @@
${item.product_name || ''}
@@ -770,6 +770,15 @@
var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0;
if (qty <= 0) return;
+ // Received quantities are stored as decimal(18,4). A value finer than
+ // that is rounded away on insert, so 0.0000001 was accepted, created a
+ // stock movement of 0.0000, and still left the PO showing "Partial".
+ if (round_dp(qty, 4) < 0.0001) {
+ errors.push(`${$card.data('sku')}: receiving quantity ${qty} is smaller than the minimum the system records (0.0001).`);
+ return;
+ }
+ qty = round_dp(qty, 4);
+
var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0;
var bin = $(`#recv_bin_${rowId}`).val() || '';
@@ -791,13 +800,22 @@
// flatpickr with altInput: the real (hidden) input holds the ISO value
var expiry_val = $(`#recv_expiry_${rowId}`).val() || '';
+ var lot_val = $(`#recv_lot_${rowId}`).val().trim();
+
+ // Expiry dates are stored on md_lot, keyed by lot number — one entered
+ // without a lot has nowhere to go and was dropped without warning, so
+ // the received stock then showed no expiry at all.
+ if (expiry_val && !lot_val) {
+ errors.push(`${$card.data('sku')}: enter a lot number — expiry dates are recorded against a lot.`);
+ return;
+ }
receive_items.push({
item_id: parseInt($card.data('item-id')),
product_sku: $card.data('sku'),
warehouse_id: wh_id,
quantity: qty,
- lot_number: $(`#recv_lot_${rowId}`).val().trim(),
+ lot_number: lot_val,
expiry_date: expiry_val,
serial_number: $(`#recv_serial_${rowId}`).val().trim(),
zone: zone,
diff --git a/app/po/manage_purchase_invoice.php b/app/po/manage_purchase_invoice.php
index c33747f..548809c 100644
--- a/app/po/manage_purchase_invoice.php
+++ b/app/po/manage_purchase_invoice.php
@@ -160,6 +160,8 @@
var invoice_id = ;
var invoice_data = null;
+ var issued_date = ''; // ISO issue date — lower bound for the due date
+ var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
@@ -226,6 +228,11 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
+
+ // A due date before the issue date is not a valid payment term, so
+ // stop the picker from offering one.
+ issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
+ if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
@@ -311,6 +318,12 @@
}
function save_invoice() {
+ var due_val = $('#due_date').val().trim();
+ if (due_val && issued_date && to_iso_date(due_val) < issued_date) {
+ bootbox.alert('The due date cannot be earlier than the issue date.');
+ return;
+ }
+
ajax_request({
url: 'order/api/engine/manage_invoice.php',
autoPrepare: true,
@@ -346,6 +359,10 @@
bootbox.alert('Please enter a due date before issuing this purchase invoice.');
return;
}
+ if (!is_dn && due_date && issued_date && to_iso_date(due_date) < issued_date) {
+ bootbox.alert('The due date cannot be earlier than the issue date.');
+ return;
+ }
var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice';
bootbox.confirm({
message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?',
@@ -419,8 +436,8 @@
$(function() {
load_dept_cache();
+ due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
- flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
diff --git a/app/revenue/manage_invoice.php b/app/revenue/manage_invoice.php
index 113298d..40f3406 100644
--- a/app/revenue/manage_invoice.php
+++ b/app/revenue/manage_invoice.php
@@ -146,7 +146,9 @@