Fix QA review findings: server-side validation, notes encoding, dashboard totals

Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
This commit is contained in:
Thanakorn
2026-09-19 10:58:42 +07:00
parent de760d02da
commit c89b28da4c
32 changed files with 924 additions and 483 deletions
+3 -2
View File
@@ -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 {
+22 -6
View File
@@ -26,7 +26,7 @@
</div>
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -39,7 +39,7 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
@@ -52,7 +52,7 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
@@ -65,7 +65,20 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
<i class="ti ti-ban fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Void</p>
<h3 class="fw-bold mb-0" id="stat_void">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
@@ -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));
}
+12 -1
View File
@@ -50,6 +50,13 @@
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Department</label>
<select id="department_id" class="form-select">
<option value="0">— No Department —</option>
</select>
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Notes</label>
<textarea id="notes" class="form-control" rows="2"
@@ -293,7 +300,7 @@
has_active_invoice = has_active_invoice || false;
active_invoice_id = parseInt(active_invoice_id) || 0;
var editable = status === -2;
$('#contact, #order_date, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
$('#contact, #order_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
.prop('disabled', !editable);
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
$('.remove_item_btn').toggleClass('d-none', !editable);
@@ -357,6 +364,7 @@
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || 0);
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
@@ -388,6 +396,7 @@
id: order_id,
contact_id: $('#contact_id').val() || 0,
order_date: to_iso_date($('#order_date').val()),
department_id: $('#department_id').val() || 0,
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
@@ -490,6 +499,8 @@
$(async function() {
try {
// Options must exist before retrieve_order() selects the saved department
await Promise.resolve(load_departments('department_id')).catch(function() {});
if (order_id) {
await retrieve_order();
} else {
+19 -5
View File
@@ -26,7 +26,7 @@
</div>
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -39,7 +39,7 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
@@ -52,7 +52,7 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
@@ -65,7 +65,7 @@
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
@@ -78,7 +78,20 @@
</div>
</div>
</div>
</div>
<div class="col-lg col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
<i class="ti ti-x fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Cancelled</p>
<h3 class="fw-bold mb-0" id="stat_cancelled">—</h3>
</div>
</div>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-12">
@@ -197,6 +210,7 @@
$('#stat_total').text(format_number(all.length));
$('#stat_pending').text(format_number(all.filter(o => String(o.status) === '-2').length));
$('#stat_draft').text(format_number(all.filter(o => String(o.status) === '0').length));
$('#stat_cancelled').text(format_number(all.filter(o => String(o.status) === '-1').length));
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) >= 1).length));
alasql('CREATE TABLE IF NOT EXISTS revenue_order_list');