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 += ` ${format_date(item["date"])} ${item["stock_reference"] || "—"} + ${escape_html(item['warehouse_name'] || '—')} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'}
- ${format_number(item['quantity'], 2)} + ${format_quantity(item['quantity'])} ${item['uom'] || ''}
@@ -234,7 +238,7 @@ + onclick="delete_stock_in($(this),${item['id']},${warehouse})"> @@ -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 += ` ${format_date(item["date"])} ${item["stock_reference"] || "—"} + ${escape_html(item['warehouse_name'] || '—')} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'}
- ${format_number(item['quantity'], 2)} + ${format_quantity(item['quantity'])} ${item['uom'] || ''}
@@ -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 || ''}
- Ordered: ${format_number(item.ordered_qty, 0)} - Received: ${format_number(item.received_qty, 0)} - Remaining: ${format_number(item.remaining_qty, 0)} + Ordered: ${format_quantity(item.ordered_qty)} + Received: ${format_quantity(item.received_qty)} + Remaining: ${format_quantity(item.remaining_qty)}
@@ -546,7 +546,7 @@
+ value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
@@ -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 @@