diff --git a/app/accounting/gl_entries.php b/app/accounting/gl_entries.php index 9e7d7ca..c970b77 100644 --- a/app/accounting/gl_entries.php +++ b/app/accounting/gl_entries.php @@ -262,7 +262,7 @@ '' + escape_html(r.doc_number) + '' + '' + escape_html(r.contact_name || '—') + '' + '' + format_number(r.grand_total, 2) + '' + - '' + escape_html(r.doc_date || '—') + '' + + '' + escape_html(format_date(r.doc_date)) + '' + '' + mapping_badge + '' + '' + status_badge + '' + ''; diff --git a/app/accounting/gl_movement.php b/app/accounting/gl_movement.php index b51be2d..ae8a269 100644 --- a/app/accounting/gl_movement.php +++ b/app/accounting/gl_movement.php @@ -198,7 +198,7 @@ export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running }); var bal_color = running >= 0 ? '' : 'text-danger'; html += '' + - '' + escape_html(r.entry_date || '—') + '' + + '' + escape_html(format_date(r.entry_date)) + '' + '' + escape_html(r.period) + '' + '' + (r.dept_code ? escape_html(r.dept_code) : '—') + '' + '' + escape_html(r.reference || '—') + '' + diff --git a/app/accounting/journal_listing.php b/app/accounting/journal_listing.php index 224a59b..efed99a 100644 --- a/app/accounting/journal_listing.php +++ b/app/accounting/journal_listing.php @@ -281,7 +281,7 @@ '' + escape_html(r.formula_name || '—') + '' + '' + format_number(r.total_debit, 2) + '' + '' + format_number(r.total_credit, 2) + '' + - '' + escape_html(r.posted_at) + '' + + '' + escape_html(format_date(r.posted_at)) + '' + '' + '' + '' + diff --git a/app/accounting/vat_report.php b/app/accounting/vat_report.php index c2f90b7..1aadad4 100644 --- a/app/accounting/vat_report.php +++ b/app/accounting/vat_report.php @@ -210,7 +210,7 @@ var html = ''; rows.forEach(function(r) { html += '' + - '' + escape_html(r.entry_date || '—') + '' + + '' + escape_html(format_date(r.entry_date)) + '' + '' + escape_html(r.period) + '' + '' + escape_html(src_labels[r.source_type] || r.source_type) + '' + '' + (r.dept_code ? escape_html(r.dept_code) : '—') + '' + diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 5b5ad57..fe9c47e 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -4,6 +4,36 @@ function escape_html(value) { }); } +// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right +// for anything written into markup but wrong inside a form field: a note saved +// as 5" pipe came back as 5" pipe <spare>. Field values +// are never parsed as HTML, so decoding them here is safe. +function decode_html(value) { + if (typeof value !== 'string' || value.indexOf('&') === -1) return value; + return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) { + name = name.toLowerCase(); + if (name === 'quot') return '"'; + if (name === 'lt') return '<'; + if (name === 'gt') return '>'; + if (name === 'amp') return '&'; + return "'"; + }); +} + +(function ($) { + if (!$ || !$.fn || $.fn.val.__decodes_html) return; + var original_val = $.fn.val; + $.fn.val = function (value) { + if (arguments.length && typeof value === 'string') { + // Only free-text fields; a - - - - - -
- - -
-
- - -
- - - -
-
-
-
-
-

Low Stock Items

- -
-
- - - - - - - - - - - - - - - - - - - -
ProductSKUWarehouseBalanceMin StockReorder PointStatusAction
-
Loading… -
-
-
-
-
-
- - - - - - - - - - + ${item.min_stock} + ${item.reorder_point} + ${badge} + + + Restock + + + `; + }); + + $('table#low_stock > tbody').html(body); + } + + + // ── Filter listeners ───────────────────────────────────────────────── + function debounce(fn, ms) { + var t; + return function() { + var args = arguments, + ctx = this; + clearTimeout(t); + t = setTimeout(function() { + fn.apply(ctx, args); + }, ms); + }; + } + + $('#filter_status, #filter_warehouse').on('change', function() { + change_page_low_stock(1); // reset to page 1 on filter change + }); + + $('#filter_search').on('input', debounce(function() { + change_page_low_stock(1); + }, 200)); + + + // ── Boot ───────────────────────────────────────────────────────────── + $(async function() { + try { + await retrieve_low_stock(); + } catch (e) { + console.error(e); + $('table#low_stock > tbody').html( + 'Failed to load data.' + ); + } + }); + + + + diff --git a/app/dbconn.php b/app/dbconn.php index 0a7a53b..ce571c9 100644 --- a/app/dbconn.php +++ b/app/dbconn.php @@ -31,6 +31,40 @@ class db_statement extends PDOStatement { $this->pdo = $pdo; } + // double_encode is off so text that is loaded and saved again is not escaped + // a second time (" becoming &quot;), and ENT_SUBSTITUTE keeps a value + // with a broken byte sequence instead of silently storing an empty string. + const ESCAPE_FLAGS = ENT_QUOTES | ENT_SUBSTITUTE; + + private static function escapeString(string $value): string { + return htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8', false); + } + + private static function escapeTree($node) { + if (is_string($node)) return self::escapeString($node); + if (!is_array($node)) return $node; + $out = []; + foreach ($node as $k => $v) { + $out[is_string($k) ? self::escapeString($k) : $k] = self::escapeTree($v); + } + return $out; + } + + public static function escapeValue(string $item): string { + $first = $item[0] ?? ''; + if ($first === '{' || $first === '[') { + $tree = json_decode($item, true); + if (is_array($tree)) { + $flags = JSON_PRESERVE_ZERO_FRACTION; + // An empty {} must not come back as []. + if ($tree === [] ) return $item; + $encoded = json_encode(self::escapeTree($tree), $flags); + if ($encoded !== false) return $encoded; + } + } + return self::escapeString($item); + } + // PDOStatement::execute() is declared ?array $params = null : bool. This // override deliberately accepts a looser signature so callers may pass // positional arguments (see func_get_args() below), so the tightened return @@ -49,46 +83,13 @@ class db_statement extends PDOStatement { // null is preserved as-is so PDO can bind NULL columns correctly. $args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args); - // escaping array - // prevent store XSS + // Escape on the way in, to prevent stored XSS. Values holding a JSON + // object/array are escaped string by string so they stay valid JSON. foreach($args as &$item){ - if (is_null($item)) continue; - - // decode the JSON data - // set second parameter boolean TRUE for associative array output. - $result = json_decode($item); - if (json_last_error() === JSON_ERROR_NONE) { - // encode html for json - $tmp = json_decode($item,true); - foreach((array)$tmp as &$ii){ - - // Inner values may be arrays (nested JSON objects) — cast to string - if (!is_string($ii)) { - $ii = json_encode($ii); - continue; - } - - $result = json_decode($ii); - if (json_last_error() === JSON_ERROR_NONE) { - // json inside json - $tmpp = json_decode($ii,true); - foreach ((array)$tmpp as &$iii) { - $iii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8'); - } - $ii = json_encode($tmpp); - }else{ - // string inside json - $ii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8'); - } - - } - $item = json_encode($tmp); - }else{ - // encode html for string - $item = htmlspecialchars($item, ENT_QUOTES, 'UTF-8'); - } + $item = self::escapeValue($item); } + unset($item); } return parent::execute($args); } @@ -96,11 +97,11 @@ class db_statement extends PDOStatement { } //..................... PDO1 .....................// -$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8', $db_user, $db_pass); +$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8mb4', $db_user, $db_pass); $pdo1->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 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8mb4', $db_user2, $db_pass2); $pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Pin both connections to the application timezone, so MySQL NOW() and PHP diff --git a/app/expense/api/engine/manage_purchase_request.php b/app/expense/api/engine/manage_purchase_request.php index 1499492..fe32206 100644 --- a/app/expense/api/engine/manage_purchase_request.php +++ b/app/expense/api/engine/manage_purchase_request.php @@ -13,13 +13,14 @@ $prm = new PurchaseRequestManager($pdo2, $company_id); try { if ($action === 'create') { - $new_id = $prm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging); + // One transaction: a failure while writing the lines must not leave the header behind. + $new_id = dbTransaction($pdo2, fn() => $prm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging)); $answer['success'] = 1; $answer['message'] = 'Purchase request created.'; $answer['new_id'] = $new_id; (new UsageGuard($pdo1, $company_id, $packages))->increment(); } elseif ($action === 'update') { - $prm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging); + dbTransaction($pdo2, fn() => $prm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging)); $answer['success'] = 1; $answer['message'] = 'Purchase request updated.'; } else { diff --git a/app/expense/purchase_invoice.php b/app/expense/purchase_invoice.php index 9bd2054..f0013ce 100644 --- a/app/expense/purchase_invoice.php +++ b/app/expense/purchase_invoice.php @@ -26,7 +26,7 @@
-
+
@@ -39,7 +39,7 @@
-
+
@@ -52,7 +52,7 @@
-
+
@@ -65,7 +65,20 @@
-
+
+
+
+
+ +
+
+

Void

+

—

+
+
+
+
+
@@ -255,8 +268,11 @@ function update_stats() { var pis = all_invoices.filter(i => i.doc_type === 'purchase_invoice'); $('#stat_invoice').text(format_number(pis.length)); - $('#stat_paid').text(format_number(pis.filter(i => String(i.status) === '2').length)); - $('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status))).length)); + // Same test as the row badge, so a tile never disagrees with the list below it + var is_paid = i => String(i.status) === '2' || i.payment_state === 'paid'; + $('#stat_paid').text(format_number(pis.filter(i => String(i.status) !== '4' && is_paid(i)).length)); + $('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status)) && !is_paid(i)).length)); + $('#stat_void').text(format_number(pis.filter(i => String(i.status) === '4').length)); $('#stat_scn').text(format_number(all_invoices.filter(i => i.doc_type === 'supplier_credit_note').length)); } diff --git a/app/journal/index.php b/app/journal/index.php index bb128c8..edd0b61 100644 --- a/app/journal/index.php +++ b/app/journal/index.php @@ -164,7 +164,7 @@ '' + escape_html(r.period) + '' + '' + format_number(r.total_debit, 2) + '' + '' + format_number(r.total_credit, 2) + '' + - '' + escape_html(r.posted_at) + '' + + '' + escape_html(format_date(r.posted_at)) + '' + '' + (r.source_type === 'manual' ? ' ' : '') + '' + diff --git a/app/order/invoice.php b/app/order/invoice.php index 3ec3390..1c711cf 100644 --- a/app/order/invoice.php +++ b/app/order/invoice.php @@ -23,7 +23,7 @@
-
+
@@ -36,7 +36,7 @@
-
+
@@ -49,7 +49,7 @@
-
+
@@ -62,7 +62,7 @@
-
+
@@ -75,7 +75,33 @@
-
+
+
+
+
+ +
+
+

Paid

+

—

+
+
+
+
+
+
+
+
+ +
+
+

Void

+

—

+
+
+
+
+
@@ -224,6 +250,8 @@ $('#stat_total').text(format_number(all.length)); $('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length)); $('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length)); + $('#stat_paid').text(format_number(all.filter(i => String(i.status) === '2').length)); + $('#stat_void').text(format_number(all.filter(i => String(i.status) === '4').length)); $('#stat_overdue').text(format_number(all.filter(i => is_overdue(i)).length)); alasql('CREATE TABLE IF NOT EXISTS invoice_list'); diff --git a/app/order/order.php b/app/order/order.php index 58f0b81..bf67b4c 100644 --- a/app/order/order.php +++ b/app/order/order.php @@ -30,7 +30,7 @@
-
+
@@ -43,7 +43,7 @@
-
+
@@ -56,7 +56,20 @@
-
+
+
+
+
+ +
+
+

Pending Warehouse

+

—

+
+
+
+
+
@@ -69,7 +82,7 @@
-
+
@@ -215,6 +228,7 @@ // Stat cards $('#stat_total').text(format_number(all.length)); + $('#stat_pending').text(format_number(all.filter(o => parseInt(o.status) === -2).length)); $('#stat_draft').text(format_number(all.filter(o => parseInt(o.status) === 0).length)); $('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) === 1).length)); $('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === -1).length)); diff --git a/app/po/invoice.php b/app/po/invoice.php index c4a981c..eb3d5c8 100644 --- a/app/po/invoice.php +++ b/app/po/invoice.php @@ -23,7 +23,7 @@
-
+
@@ -36,7 +36,7 @@
-
+
@@ -49,7 +49,7 @@
-
+
@@ -62,7 +62,7 @@
-
+
@@ -75,7 +75,33 @@
-
+
+
+
+
+ +
+
+

Paid

+

—

+
+
+
+
+
+
+
+
+ +
+
+

Void

+

—

+
+
+
+
+
@@ -212,6 +238,8 @@ $('#stat_total').text(format_number(all.length)); $('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length)); $('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length)); + $('#stat_paid').text(format_number(all.filter(i => String(i.status) === '2').length)); + $('#stat_void').text(format_number(all.filter(i => String(i.status) === '4').length)); $('#stat_overdue').text(format_number(all.filter(i => String(i.status) === '1' && i.due_date && i.due_date < today ).length)); diff --git a/app/reports/expired_stock.php b/app/reports/expired_stock.php index bc04fa4..7e49b42 100644 --- a/app/reports/expired_stock.php +++ b/app/reports/expired_stock.php @@ -306,7 +306,7 @@ ${item.product_sku} ${item.lot_number || '—'} ${format_location(item)} - ${item.expiry_date} + ${format_date(item.expiry_date)} ${Math.abs(item.days_remaining)} days ${item.quantity} ${status_badge(item.status)} @@ -330,7 +330,7 @@ ${item.product_sku} ${item.lot_number || '—'} ${format_location(item)} - ${item.expiry_date} + ${format_date(item.expiry_date)} ${item.days_remaining} days ${item.quantity} ${status_badge(item.status)} diff --git a/app/reports/stock_movement.php b/app/reports/stock_movement.php index 2c155b4..a2f4874 100644 --- a/app/reports/stock_movement.php +++ b/app/reports/stock_movement.php @@ -533,7 +533,7 @@ } body += ` - ${row.date} + ${format_date(row.date)} ${type_badge[row.type] || row.type} ${row.product_sku} ${row.lot_number || '—'} diff --git a/app/revenue/api/engine/manage_quotation.php b/app/revenue/api/engine/manage_quotation.php index c84fb8a..4c08d9e 100644 --- a/app/revenue/api/engine/manage_quotation.php +++ b/app/revenue/api/engine/manage_quotation.php @@ -13,14 +13,15 @@ $qm = new QuotationManager($pdo2, $company_id); try { if ($action === 'create') { require_role($user_role, ['owner', 'admin', 'staff']); - $new_id = $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging); + // One transaction: a failure while writing the lines must not leave the header behind. + $new_id = dbTransaction($pdo2, fn() => $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging)); $answer['success'] = 1; $answer['message'] = 'Quotation created.'; $answer['new_id'] = $new_id; (new UsageGuard($pdo1, $company_id, $packages))->increment(); } elseif ($action === 'update') { require_role($user_role, ['owner', 'admin', 'staff']); - $qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging); + dbTransaction($pdo2, fn() => $qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging)); $answer['success'] = 1; $answer['message'] = 'Quotation updated.'; } else { diff --git a/app/revenue/invoice.php b/app/revenue/invoice.php index 1666e7a..fc310db 100644 --- a/app/revenue/invoice.php +++ b/app/revenue/invoice.php @@ -26,7 +26,7 @@
-
+
@@ -39,7 +39,7 @@
-
+
@@ -52,7 +52,7 @@
-
+
@@ -65,7 +65,20 @@
-
+
+
+
+
+ +
+
+

Void

+

—

+
+
+
+
+
@@ -255,8 +268,11 @@ function update_stats() { $('#stat_invoice').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice').length)); - $('#stat_paid').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) === '2').length)); - $('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status))).length)); + // Same test as the row badge, so a tile never disagrees with the list below it + var is_paid = i => String(i.status) === '2' || i.payment_state === 'paid'; + $('#stat_paid').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) !== '4' && is_paid(i)).length)); + $('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status)) && !is_paid(i)).length)); + $('#stat_void').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) === '4').length)); $('#stat_cn').text(format_number(all_invoices.filter(i => i.doc_type === 'credit_note').length)); } diff --git a/app/revenue/manage_order.php b/app/revenue/manage_order.php index e022dd0..535430c 100644 --- a/app/revenue/manage_order.php +++ b/app/revenue/manage_order.php @@ -50,6 +50,13 @@ placeholder="DD/MM/YYYY" autocomplete="off">
+
+ + +
+