accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
+5 -4
View File
@@ -15,15 +15,16 @@
exit(json_encode($answer));
}
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_cn = (int)$csm->get('auto_invoice_and_credit_note') === 1;
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_cn = (int)$csm->get('auto_invoice_and_credit_note') === 1;
$auto_approve = (int)$csm->get('default_stock_status') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_cn) {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_cn, $auto_approve) {
$ret = new ReturnManager($pdo, $company_id);
$whMgmt = new WarehouseManager($pdo, $company_id);
$invMgmt = new InvoiceManager($pdo, $company_id);
$ret->confirmReturn($id, $uuid, $logging, $whMgmt, $invMgmt, $auto_cn);
$ret->confirmReturn($id, $uuid, $logging, $whMgmt, $invMgmt, $auto_cn, $auto_approve);
});
$answer['success'] = 1;
+5 -4
View File
@@ -4,7 +4,8 @@
require_role($user_role, ['owner', 'admin', 'staff']);
require '../../../assets/utils/classes/InvoiceManager.php';
$id = (int)($data['id'] ?? 0);
$id = (int)($data['id'] ?? ($data['invoice_id'] ?? 0));
$due_date = trim((string)($data['due_date'] ?? ''));
if (!$id) {
$answer['message'] = 'Invalid ID.';
@@ -13,9 +14,9 @@
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
dbTransaction($pdo2, function($pdo) use ($id, $due_date, $company_id, $logging) {
$inv = new InvoiceManager($pdo, $company_id);
$inv->issueInvoice($id, $logging);
$inv->issueInvoice($id, $logging, $due_date ?: null);
});
$answer['success'] = 1;
@@ -31,4 +32,4 @@
}
exit(json_encode($answer));
?>
?>
+12
View File
@@ -5,6 +5,18 @@
require '../../../assets/utils/classes/InvoiceManager.php';
try {
$action = $data['action'] ?? 'update';
if ($action === 'save_formula') {
$id = (int)($data['id'] ?? 0);
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
$inv = new InvoiceManager($pdo2, $company_id);
$inv->saveFormula($id, $formula_id);
$answer['success'] = 1;
exit(json_encode($answer));
}
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) {
$inv = new InvoiceManager($pdo, $company_id);
$inv->saveInvoice($data, $logging);
+7 -9
View File
@@ -8,19 +8,17 @@
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
try {
$new_id = null;
$quotation_id = (int)($data['quotation_id'] ?? 0);
$new_id = null;
$source = trim((string)($data['source'] ?? ''));
$source_id = (int)($data['source_id'] ?? 0);
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $quotation_id, &$new_id) {
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, $source, $source_id, &$new_id) {
$order = new OrderManager($pdo, $company_id);
$new_id = $order->saveOrder($data, $logging);
// On new order created from a quotation: link both records
if ($new_id && $quotation_id > 0) {
$pdo->prepare(
"UPDATE ac_quotation SET order_id = :order_id, status = 5
WHERE id = :id AND company_id = :cid AND status = 2"
)->execute([':order_id' => $new_id, ':id' => $quotation_id, ':cid' => $company_id]);
if ($new_id && $source === 'quotation' && $source_id > 0) {
$order->linkQuotationToOrder($source_id, $new_id);
}
});
@@ -39,4 +37,4 @@
}
exit(json_encode($answer));
?>
?>
+12
View File
@@ -4,9 +4,21 @@
require_role($user_role, ['owner', 'admin', 'staff']);
require '../../../assets/utils/classes/ReturnManager.php';
$action = $data['action'] ?? 'save';
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
try {
if ($action === 'save_formula') {
$id = (int)($data['id'] ?? 0);
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
if (!$id) throw new Exception('Missing return id.');
$ret = new ReturnManager($pdo2, $company_id);
$ret->saveFormula($id, $formula_id);
$answer['success'] = 1;
exit(json_encode($answer));
}
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
@@ -0,0 +1,36 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
require '../../../assets/utils/classes/InvoiceManager.php';
$return_id = (int)($data['return_id'] ?? 0);
if (!$return_id) {
$answer['message'] = 'Invalid return ID.';
http_response_code(400);
exit(json_encode($answer));
}
try {
$new_id = null;
dbTransaction($pdo2, function($pdo) use ($return_id, $company_id, $logging, &$new_id) {
$inv = new InvoiceManager($pdo, $company_id);
$new_id = $inv->createFromReturn($return_id, $logging);
});
$answer['success'] = 1;
$answer['new_id'] = $new_id;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+9 -27
View File
@@ -17,41 +17,23 @@
// Fetch linked credit notes for invoice detail page
if ($result['doc_type'] === 'invoice') {
$sth = $pdo2->prepare(
"SELECT * FROM td_invoice
WHERE company_id = :company_id
AND ref_invoice_id = :ref_id
AND doc_type = 'credit_note'
ORDER BY id DESC"
);
$sth->execute([':company_id' => $company_id, ':ref_id' => $id]);
$answer['credit_notes'] = $sth->fetchAll(PDO::FETCH_ASSOC);
$answer['credit_notes'] = $inv->getLinkedCreditNotes($id, 'credit_note');
}
if ($result['doc_type'] === 'purchase_invoice') {
$answer['supplier_credit_notes'] = $inv->getLinkedCreditNotes($id, 'supplier_credit_note');
}
} else {
$order_id = (int)($data['order_id'] ?? 0);
$doc_type = $data['doc_type'] ?? '';
$answer['output'] = $inv->getInvoiceList($order_id, $doc_type);
// For credit note tab — join ref invoice number
if ($doc_type === 'credit_note') {
$rows = $answer['output'];
foreach ($rows as &$row) {
if ((int)$row['ref_invoice_id'] > 0) {
$sth = $pdo2->prepare(
"SELECT invoice_number FROM td_invoice
WHERE id = :id AND company_id = :company_id"
);
$sth->execute([':id' => $row['ref_invoice_id'], ':company_id' => $company_id]);
$row['ref_invoice_number'] = $sth->fetchColumn() ?: '—';
} else {
$row['ref_invoice_number'] = '—';
}
}
unset($row);
$answer['output'] = $rows;
// For credit note / debit note tabs — join ref invoice number
if ($doc_type === 'credit_note' || $doc_type === 'supplier_credit_note') {
$answer['output'] = $inv->addReferenceInvoiceNumbers($answer['output']);
}
}
$answer['success'] = 1;
exit(json_encode($answer));
?>
?>
+4 -79
View File
@@ -16,85 +16,10 @@
}
$answer['output'] = $result;
// Check if an active invoice already exists for this order
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND doc_type = 'invoice'
AND status != 4"
);
$sth->execute([':company_id' => $company_id, ':order_id' => $id]);
$answer['has_active_invoice'] = (int)$sth->fetchColumn() > 0;
// Calculate returnable items — order items minus already confirmed returns
$order_items = $result['items'] ?? '[]';
// Aggregate returned qty per item_id from confirmed returns
$sth = $pdo2->prepare(
"SELECT items FROM td_return
WHERE company_id = :company_id
AND order_id = :order_id
AND status = 1"
);
$sth->execute([':company_id' => $company_id, ':order_id' => $id]);
$returned_qty = [];
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $items_json) {
$items = json_decode($items_json, true) ?: [];
foreach ($items as $item) {
$item_id = (int)($item['item_id'] ?? -1);
if ($item_id < 0) continue;
$returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity'];
}
}
$returnable = [];
foreach ($order_items as $i => $item) {
$already_returned = $returned_qty[$i] ?? 0;
$remaining = (float)$item['quantity'] - $already_returned;
if ($remaining > 0) {
$stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0);
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
$table = 'td_stock_' . $stock_out_warehouse_id;
$sth = $pdo2->prepare(
"SELECT id, status, zone, aisle, rack, lot_number, serial_number
FROM `{$table}`
WHERE company_id = :company_id
AND id = :id
AND type = 'out'
LIMIT 1"
);
$sth->execute([
':company_id' => $company_id,
':id' => $stock_out_id,
]);
$stock_out = $sth->fetch(PDO::FETCH_ASSOC);
if ($stock_out) {
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
$item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? '');
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
$item['stock_out_status'] = (int)$stock_out['status'];
}
}
$returnable[] = array_merge($item, [
'item_id' => $i,
'original_qty' => (float)$item['quantity'],
'returned_qty' => $already_returned,
'returnable_qty' => $remaining,
'stock_out_warehouse_id' => $stock_out_warehouse_id,
'stock_out_status' => (int)($item['stock_out_status'] ?? 0),
]);
}
}
$answer['returnable_items'] = $returnable;
$active_invoice_id = $order->getActiveInvoiceId($id);
$answer['has_active_invoice'] = $active_invoice_id > 0;
$answer['active_invoice_id'] = $active_invoice_id;
$answer['returnable_items'] = $order->getReturnableItems($result);
} else {
$answer['output'] = $order->getOrderList();
}
+6 -1
View File
@@ -13,6 +13,11 @@
$answer['message'] = 'Return not found.';
exit(json_encode($answer));
}
$active_cn_id = $ret->getActiveCreditNoteId($id);
$result['has_active_cn'] = $active_cn_id > 0 ? 1 : 0;
$result['active_cn_id'] = $active_cn_id;
$answer['output'] = $result;
} else {
$order_id = (int)($data['order_id'] ?? 0);
@@ -21,4 +26,4 @@
$answer['success'] = 1;
exit(json_encode($answer));
?>
?>
+6 -35
View File
@@ -13,8 +13,9 @@
exit(json_encode($answer));
}
// 2=paid 3=overdue 4=void — issue (0→1) handled by issue_invoice.php
if (!in_array($status, [2, 3, 4], true)) {
// Manual status changes are limited to void. Paid is handled by payments;
// overdue is calculated from due_date and is not stored as a status.
if ($status !== 4) {
$answer['message'] = 'Invalid status value.';
http_response_code(400);
exit(json_encode($answer));
@@ -22,38 +23,8 @@
try {
dbTransaction($pdo2, function($pdo) use ($id, $status, $company_id, $logging) {
$sth = $pdo->prepare(
"SELECT status, doc_type, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception("Invoice not found.");
if ($row['doc_type'] !== 'invoice') throw new Exception("Only invoices can be updated this way.");
$current = (int)$row['status'];
if ($current === 4) throw new Exception("Invoice is already void.");
if ($current === 0) throw new Exception("Issue the invoice before changing payment status.");
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, [
'action' => 'update_status',
'status' => $status,
'previous_status' => $current,
]);
$pdo->prepare(
"UPDATE td_invoice SET status = :status, `log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':status' => $status,
':log' => json_encode($log),
':id' => $id,
':company_id' => $company_id,
]);
$inv = new InvoiceManager($pdo, $company_id);
$inv->voidInvoice($id, array_merge($logging, ['action' => 'update_status', 'status' => $status]));
});
$answer['success'] = 1;
@@ -69,4 +40,4 @@
}
exit(json_encode($answer));
?>
?>
+2 -3
View File
@@ -6,7 +6,6 @@
require '../../../assets/utils/classes/CompanySettingManager.php';
$id = (int)($data['id'] ?? 0);
$fulfillment_status = (int)($data['fulfillment_status'] ?? 0);
$tracking_number = trim((string)($data['shipping_tracking_number'] ?? ''));
if (!$id) {
@@ -19,9 +18,9 @@
$auto_complete = (int)$csm->get('auto_complete_on_ship') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $fulfillment_status, $tracking_number, $company_id, $logging, $auto_complete) {
dbTransaction($pdo2, function($pdo) use ($id, $tracking_number, $company_id, $logging, $auto_complete) {
$order = new OrderManager($pdo, $company_id);
$order->updateFulfillmentStatus($id, $fulfillment_status, $tracking_number, $logging, $auto_complete);
$order->updateShippingTracking($id, $tracking_number, $logging, $auto_complete);
});
$answer['success'] = 1;
@@ -1,69 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require_role($user_role, ['owner', 'admin', 'staff']);
$id = (int)($data['id'] ?? 0);
$payment_status = (int)($data['payment_status'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid order ID.';
http_response_code(400);
exit(json_encode($answer));
}
// Validate payment_status range: 0=unpaid 1=paid 2=partial
if (!in_array($payment_status, [0, 1, 2], true)) {
$answer['message'] = 'Invalid payment status.';
http_response_code(400);
exit(json_encode($answer));
}
try {
dbTransaction($pdo2, function($pdo) use ($id, $payment_status, $company_id, $logging) {
// Load order — verify ownership and that order is not cancelled
$sth = $pdo->prepare(
"SELECT status, payment_status, `log` FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $company_id, ':id' => $id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception("Order not found.");
if ((int)$row['status'] === -1) throw new Exception("Cannot update payment status of a cancelled order.");
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = array_merge($logging, [
'action' => 'update_payment_status',
'payment_status' => $payment_status,
'previous_status' => (int)$row['payment_status'],
]);
$pdo->prepare(
"UPDATE td_order SET
payment_status = :payment_status,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':payment_status' => $payment_status,
':log' => json_encode($log),
':id' => $id,
':company_id' => $company_id,
]);
});
$answer['success'] = 1;
$answer['message'] = 'Payment status updated.';
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+150 -134
View File
@@ -11,61 +11,108 @@
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="row mb-6">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Invoices</h1>
<p class="mb-0">Invoices and credit notes</p>
<p class="mb-0 text-muted">Invoices and credit notes</p>
</div>
</div>
</div>
</div>
<!-- Tabs -->
<div class="row mb-5">
<div class="col-12">
<ul class="nav nav-tabs" id="invoice_tabs">
<li class="nav-item">
<a class="nav-link active" href="#" onclick="switch_tab('invoice'); return false;">
<i class="ti ti-file-invoice me-1"></i>Invoices
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="switch_tab('credit_note'); return false;">
<i class="ti ti-file-minus me-1"></i>Credit Notes
</a>
</li>
</ul>
<div class="row g-5 mb-5">
<div class="col-lg-3 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">
<i class="ti ti-file-invoice fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Total</p>
<h3 class="fw-bold mb-0" id="stat_total">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 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">
<i class="ti ti-pencil fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Draft</p>
<h3 class="fw-bold mb-0" id="stat_draft">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 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-info bg-opacity-10 text-info rounded-2">
<i class="ti ti-send fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Issued</p>
<h3 class="fw-bold mb-0" id="stat_issued">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 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">
<i class="ti ti-clock fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Overdue</p>
<h3 class="fw-bold mb-0" id="stat_overdue">—</h3>
</div>
</div>
</div>
</div>
</div>
<!-- Filter bar -->
<div class="row g-5 mb-5">
<div class="row g-5">
<div class="col-12">
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<div class="col-lg-4">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search number or contact...">
placeholder="Search invoice number or contact...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="col-lg-3" id="status_filter_wrap">
<div class="col-lg-2">
<select id="filter_type" class="form-select">
<option value="">All Types</option>
<option value="invoice">Invoice</option>
<option value="credit_note">Credit Note</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Issued</option>
<option value="2">Paid</option>
<option value="3">Overdue</option>
<option value="overdue">Overdue</option>
<option value="4">Void</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_invoices()">
<i class="ti ti-search me-1"></i>Search
@@ -75,22 +122,19 @@
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="table-responsive">
<!-- Invoice table -->
<table class="table table-hover mb-0 table-centered" id="invoice_list">
<thead class="table-primary border-light">
<tr>
<th class="py-3">Invoice #</th>
<th class="py-3">Type</th>
<th class="py-3">Order #</th>
<th class="py-3">Contact</th>
<th class="py-3">Department</th>
<th class="py-3">Issued Date</th>
<th class="py-3">Due Date</th>
<th class="py-3">Grand Total</th>
@@ -101,24 +145,6 @@
<tbody></tbody>
<tfoot></tfoot>
</table>
<!-- Credit Note table -->
<table class="table table-hover mb-0 table-centered d-none" id="cn_list">
<thead class="table-warning border-light">
<tr>
<th class="py-3">CN #</th>
<th class="py-3">Order #</th>
<th class="py-3">Ref Invoice #</th>
<th class="py-3">Contact</th>
<th class="py-3">Date</th>
<th class="py-3">Amount</th>
<th class="py-3">Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
@@ -132,121 +158,108 @@
<script>
var current_tab = 'invoice';
function doc_type_badge(doc_type) {
const map = {
'invoice': '<span class="badge bg-primary">Invoice</span>',
'credit_note': '<span class="badge bg-warning text-dark">Credit Note</span>',
};
return map[doc_type] ?? '—';
}
function is_overdue(inv) {
if (String(inv.status) !== '1' || !inv.due_date) return false;
var today = new Date().toISOString().slice(0, 10);
return inv.due_date < today;
}
// ── Badges ────────────────────────────────────────────────────────────────
function invoice_status_badge(status) {
function invoice_status_badge(inv) {
if (inv.payment_state === 'partial') return '<span class="badge bg-warning text-dark">Partial</span>';
if (inv.payment_state === 'paid') {
return inv.doc_type === 'credit_note'
? '<span class="badge bg-success">Settled</span>'
: '<span class="badge bg-success">Paid</span>';
}
if (is_overdue(inv)) return '<span class="badge bg-danger">Overdue</span>';
const map = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-info text-white">Issued</span>',
'2': '<span class="badge bg-success">Paid</span>',
'3': '<span class="badge bg-danger">Overdue</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
return map[String(status)] ?? '—';
return map[String(inv.status)] ?? '—';
}
// ── Tab switch ────────────────────────────────────────────────────────────
function switch_tab(tab) {
current_tab = tab;
// Update nav tabs
$('#invoice_tabs .nav-link').each(function() {
var is_active = $(this).attr('onclick').includes("'" + tab + "'");
$(this).toggleClass('active', is_active);
});
// Show/hide tables
$('#invoice_list').toggleClass('d-none', tab !== 'invoice');
$('#cn_list').toggleClass('d-none', tab !== 'credit_note');
// Status filter — CN has no draft/paid/overdue, simpler
if (tab === 'credit_note') {
$('#filter_status').html(`
<option value="">All Status</option>
<option value="1">Issued</option>
<option value="4">Void</option>
`);
} else {
$('#filter_status').html(`
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Issued</option>
<option value="2">Paid</option>
<option value="3">Overdue</option>
<option value="4">Void</option>
`);
}
retrieve_invoices();
}
// ── Retrieve ──────────────────────────────────────────────────────────────
function retrieve_invoices() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { doc_type: current_tab },
data: { doc_type_group: 'sales' },
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var fil_st = $('#filter_status').val();
var data = all.filter(function(i) {
var match_kw = !keyword ||
(i.invoice_number || '').toLowerCase().includes(keyword) ||
(i.contact_name || '').toLowerCase().includes(keyword);
var match_st = fil_st === '' || String(i.status) === fil_st;
return match_kw && match_st;
var all = (res.output || []).filter(function(inv) {
return inv.doc_type === 'invoice' || inv.doc_type === 'credit_note';
});
if (current_tab === 'invoice') {
render_invoice_table(data);
} else {
render_cn_table(data);
}
var keyword = $('#search_input').val().toLowerCase();
var fil_type = $('#filter_type').val();
var fil_status = $('#filter_status').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(inv) {
var match_kw = !keyword ||
(inv.invoice_number || '').toLowerCase().includes(keyword) ||
(inv.contact_name || '').toLowerCase().includes(keyword);
var match_type = !fil_type || inv.doc_type === fil_type;
var match_st = !fil_status
|| String(inv.status) === fil_status
|| (fil_status === 'overdue' && is_overdue(inv));
var match_dept = !fil_dept || String(inv.department_id) === fil_dept;
return match_kw && match_type && match_st && match_dept;
});
var today = new Date().toISOString().slice(0, 10);
$('#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_overdue').text(format_number(all.filter(i => is_overdue(i)).length));
alasql('CREATE TABLE IF NOT EXISTS invoice_list');
alasql.tables.invoice_list.data = data;
$('table#invoice_list tfoot').html(generate_pagination('invoice_list', data.length));
change_page_invoice_list(1);
}
});
}
// ── Render invoice table ──────────────────────────────────────────────────
function render_invoice_table(data) {
alasql('DROP TABLE IF EXISTS invoice_list');
alasql('CREATE TABLE invoice_list');
alasql.tables.invoice_list.data = data;
var page = generate_pagination('invoice_list', data.length);
$('table#invoice_list tfoot').html(page);
if (typeof change_page_invoice_list === 'function') {
change_page_invoice_list(1);
}
}
function change_page_invoice_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var page_data = alasql(`SELECT * FROM invoice_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
var offset = (page_num - 1) * prop_limit;
var rows = alasql(`SELECT * FROM invoice_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
$.each(page_data, function(i, inv) {
var status = parseInt(inv.status);
var can_void = status >= 1 && status <= 3;
if (!rows.length) {
$('table#invoice_list tbody').html(
'<tr><td colspan="10" class="text-center py-5 text-muted">No invoices found.</td></tr>'
);
return;
}
$.each(rows, function(i, inv) {
var status = parseInt(inv.status);
var can_void = inv.doc_type === 'invoice' && status >= 1 && status !== 4;
body += `<tr>
<td class="py-3 fw-semibold">${inv.invoice_number}</td>
<td class="py-3">${inv.order_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${inv.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3 fw-semibold">${escape_html(inv.invoice_number || '')}</td>
<td class="py-3">${doc_type_badge(inv.doc_type)}</td>
<td class="py-3">${inv.order_number
? `<a href="<?php echo $server_url?>order/manage_order.php?id=${inv.order_id}">${escape_html(inv.order_number)}</a>`
: '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(inv.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(inv.department_id)}</td>
<td class="py-3">${inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3 fw-semibold">${format_number(inv.grand_total, 2)}</td>
<td class="py-3">${invoice_status_badge(inv.status)}</td>
<td class="py-3">${invoice_status_badge(inv)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>order/manage_invoice.php?id=${inv.id}" title="View">
<i class="ti ti-eye fs-5"></i>
@@ -329,11 +342,14 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(function() {
$(async function() {
await load_dept_cache();
populate_dept_filter('filter_dept');
retrieve_invoices();
$('#search_input').on('keyup', function(e) {
if (e.key === 'Enter') retrieve_invoices();
});
$('#filter_type, #filter_status, #filter_dept').on('change', retrieve_invoices);
});
</script>
+125 -97
View File
@@ -42,6 +42,8 @@
<th>Product</th>
<th class="text-end">Qty</th>
<th class="text-end">Unit Price</th>
<th class="text-end" style="width:60px;">Tax%</th>
<th class="text-end">Tax Amt</th>
<th class="text-end">Total</th>
</tr>
</thead>
@@ -93,15 +95,31 @@
<span class="text-muted">Contact:</span>
<span class="ms-1 fw-semibold" id="display_contact"></span>
</div>
<div class="mb-2 small">
<span class="text-muted">Department:</span>
<span class="ms-1" id="display_department">—</span>
</div>
<div class="mb-2 small">
<span class="text-muted">Issued:</span>
<span class="ms-1" id="display_issued"></span>
</div>
<div class="mb-2 small">
<div class="mb-2 small" id="due_date_row">
<span class="text-muted">Due:</span>
<input type="text" id="due_date" class="form-control form-control-sm ms-1 d-inline-block"
style="width:140px;" placeholder="DD/MM/YYYY">
</div>
<div class="mt-3 small">
<span class="text-muted">GL Formula:</span>
<div class="d-flex gap-1 mt-1">
<select id="formula_id" class="form-select form-select-sm">
<option value="">— None —</option>
</select>
<button class="btn btn-outline-secondary btn-sm px-2" id="btn_save_formula"
onclick="save_formula()" title="Save formula">
<i class="ti ti-device-floppy"></i>
</button>
</div>
</div>
<div class="mt-3 small">
<span class="text-muted">Notes:</span>
<textarea id="notes" class="form-control form-control-sm mt-1" rows="2"></textarea>
@@ -118,32 +136,15 @@
<i class="ti ti-device-floppy me-1"></i>Save
</button>
<button class="btn btn-success w-100" id="btn_issue"
onclick="issue_invoice()">
<i class="ti ti-send me-1"></i>Issue Invoice
</button>
<button class="btn btn-outline-secondary w-100" id="btn_print"
onclick="print_invoice()">
<i class="ti ti-printer me-1"></i>Print / Save PDF
</button>
<!-- Status selector (issued and above) -->
<div class="" id="invoice_status_group">
<label class="form-label small text-muted mb-1">Invoice Status</label>
<div class="d-flex gap-2">
<select id="invoice_status_select" class="form-select form-select-sm">
<option value="1">Issued</option>
<option value="2">Paid</option>
<option value="3">Overdue</option>
<option value="4">Void</option>
</select>
<button class="btn btn-sm btn-outline-primary text-nowrap"
onclick="update_invoice_status($('#invoice_status_select').val())">
<i class="ti ti-check"></i>
</button>
</div>
</div>
<button class="btn btn-outline-danger w-100" id="btn_void"
onclick="void_invoice()">
<i class="ti ti-ban me-1"></i>Void Invoice
</button>
</div>
@@ -157,6 +158,7 @@
<?php require '../include_ending.php'; ?>
<script>
// custom.js globals: format_number()
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
@@ -166,17 +168,34 @@
const map = {
'invoice': '<span class="badge bg-primary">Invoice</span>',
'credit_note': '<span class="badge bg-warning text-dark">Credit Note</span>',
'debit_note': '<span class="badge bg-info text-white">Debit Note</span>',
'supplier_credit_note': '<span class="badge bg-info text-white">Supplier Credit Note</span>',
};
return map[doc_type] ?? '—';
}
function invoice_status_badge(status) {
function is_invoice_overdue(status, due_date) {
if (String(status) !== '1' || !due_date) return false;
var d = new Date();
var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
return due_date < today;
}
function invoice_status_badge(status, due_date, payment_state, doc_type) {
if (payment_state === 'partial') {
return '<span class="badge bg-warning text-dark">Partial</span>';
}
if (payment_state === 'paid') {
return doc_type === 'credit_note'
? '<span class="badge bg-success">Settled</span>'
: '<span class="badge bg-success">Paid</span>';
}
if (is_invoice_overdue(status, due_date)) {
return '<span class="badge bg-danger">Overdue</span>';
}
const map = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-info text-white">Issued</span>',
'2': '<span class="badge bg-success">Paid</span>',
'3': '<span class="badge bg-danger">Overdue</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
return map[String(status)] ?? '—';
@@ -184,31 +203,26 @@
function set_mode(inv) {
var status = parseInt(inv.status);
var doc_type = inv.doc_type;
var is_invoice = doc_type === 'invoice';
var is_draft = status === 0;
var is_active = status >= 1 && status <= 3;
var status = parseInt(inv.status);
var doc_type = inv.doc_type;
var is_invoice = doc_type === 'invoice';
var is_cn = doc_type === 'credit_note';
var is_draft = status === 0;
var is_void = status === 4;
var is_editable = is_draft && (is_invoice || is_cn);
// Fields editable only on draft invoice
$('#due_date, #notes').prop('disabled', !(is_draft && is_invoice));
// Due date only relevant for invoices
$('#due_date_row').toggleClass('d-none', is_cn);
$('#due_date, #notes').prop('disabled', !is_editable);
// Save + Issue
var is_draft_invoice = is_draft && is_invoice;
var not_invoice_hint = doc_type !== 'invoice' ? 'Only original invoices can be edited' : '';
set_btn_state('#btn_save', is_draft_invoice,
not_invoice_hint || 'Only draft invoices can be edited');
set_btn_state('#btn_issue', is_draft_invoice,
not_invoice_hint || (status > 0 ? 'Invoice has already been issued' : 'Only draft invoices can be issued'));
// Status selector
var can_change_status = is_active && is_invoice;
var status_hint = !is_invoice ? 'Status cannot be changed on credit/debit notes'
: status === 0 ? 'Issue the invoice first before changing status'
: status === 4 ? 'Invoice is void' : '';
$('#invoice_status_select').prop('disabled', !can_change_status);
var not_editable_hint = (!is_invoice && !is_cn) ? 'Only invoices and credit notes can be edited' : '';
set_btn_state('#btn_save', is_editable,
not_editable_hint || 'Only draft documents can be edited');
set_btn_state('#btn_void', is_invoice && status >= 1 && status !== 4,
!is_invoice ? 'Only original invoices can be voided'
: status === 0 ? 'Invoice has not been issued yet'
: status === 4 ? 'Invoice is already void' : '');
}
@@ -225,12 +239,32 @@
$('#page_title').text(inv.invoice_number);
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
$('#badge_status').html(invoice_status_badge(inv.status));
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date, inv.payment_state, inv.doc_type));
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#notes').val(inv.notes || '');
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
var selected_formula = inv.formula_id || '';
ajax_request({
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
autoPrepare: true,
checkRequired: 0,
action: 'get_by_type',
data: { document_type: gl_type },
onSuccess: function(fres) {
var opts = '<option value="">— None —</option>';
$.each(fres.output || [], function(i, f) {
var sel = String(f.id) === String(selected_formula) ? ' selected' : '';
opts += '<option value="' + f.id + '"' + sel + '>' + escape_html(f.formula_name) + '</option>';
});
$('#formula_id').html(opts);
}
});
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
if (inv.order_id) {
$('#link_order')
.attr('href', '<?php echo $server_url?>order/manage_order.php?id=' + inv.order_id)
@@ -244,19 +278,29 @@
tbody += `<tr>
<td>${item.product_name || item.product_sku}</td>
<td class="text-end">${format_number(item.quantity, 2)}</td>
<td class="text-end">${format_number(item.price, 2)}</td>
<td class="text-end">${format_number(item.unit_price, 2)}</td>
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
<td class="text-end">${format_number(item.tax_amount, 4)}</td>
<td class="text-end">${format_number(item.total_price, 2)}</td>
</tr>`;
});
$('#item_tbody').html(tbody);
// Totals
var is_draft = parseInt(inv.status) === 0;
var is_editable_doc = inv.doc_type === 'invoice' || inv.doc_type === 'credit_note';
var tax_adj_val = parseFloat(inv.tax_adjustment) || 0;
var tax_adj_cell = (is_draft && is_editable_doc)
? `<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end d-inline-block" style="width:90px;" min="-0.3" max="0.3" step="0.01" value="${tax_adj_val.toFixed(2)}">`
: `${format_number(tax_adj_val, 2)}`;
var tfoot = `
<tr><td colspan="3" class="text-muted border-bottom-0">Subtotal</td><td class="text-end border-bottom-0">${format_number(inv.subtotal, 2)}</td></tr>
<tr><td colspan="3" class="text-muted border-bottom-0">Discount</td><td class="text-end border-bottom-0">${format_number(inv.discount, 2)}</td></tr>
<tr><td colspan="3" class="text-muted border-bottom-0">Tax</td><td class="text-end border-bottom-0">${format_number(inv.tax, 2)}</td></tr>
<tr><td colspan="3" class="text-muted">Shipping</td><td class="text-end">${format_number(inv.shipping_fee, 2)}</td></tr>
<tr><td colspan="3" class="fw-bold">Grand Total</td><td class="text-end fw-bold">${format_number(inv.grand_total, 2)}</td></tr>`;
<tr><td colspan="5" class="text-muted border-bottom-0">Subtotal</td><td class="text-end border-bottom-0">${format_number(inv.subtotal, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Discount</td><td class="text-end border-bottom-0">${format_number(inv.discount, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax</td><td class="text-end border-bottom-0">${format_number(inv.tax, 2)}</td></tr>
<tr><td colspan="5" class="text-muted border-bottom-0">Tax Adj.</td><td class="text-end border-bottom-0">${tax_adj_cell}</td></tr>
<tr><td colspan="5" class="text-muted">Shipping</td><td class="text-end">${format_number(inv.shipping_fee, 2)}</td></tr>
<tr><td colspan="5" class="fw-bold">Grand Total</td><td class="text-end fw-bold">${format_number(inv.grand_total, 2)}</td></tr>`;
$('#item_tfoot').html(tfoot);
// Linked credit notes
@@ -267,7 +311,7 @@
<td>${cn.invoice_number}</td>
<td>${format_date(cn.issued_date)}</td>
<td class="text-end text-danger">${format_number(cn.grand_total, 2)}</td>
<td>${invoice_status_badge(cn.status)}</td>
<td>${invoice_status_badge(cn.status, cn.due_date, cn.payment_state, cn.doc_type)}</td>
<td><a href="<?php echo $server_url?>order/manage_invoice.php?id=${cn.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
@@ -276,7 +320,6 @@
}
set_mode(inv);
$('#invoice_status_select').val(inv.status);
}
});
}
@@ -289,50 +332,23 @@
checkRequired: 0,
action: 'update',
data: {
id: invoice_id,
due_date: $('#due_date').val().split('/').reverse().join('-'),
notes: $('#notes').val(),
id: invoice_id,
due_date: $('#due_date').val().split('/').reverse().join('-'),
notes: $('#notes').val(),
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
});
}
function issue_invoice() {
function void_invoice() {
bootbox.confirm({
message: 'Issue this invoice? It will be sent to the customer.',
message: '<strong>Void this invoice?</strong><br>This action cannot be undone.',
buttons: {
confirm: { label: 'Issue', className: 'btn-success' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/issue_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { invoice_id: invoice_id },
onSuccess: function() { retrieve_invoice(); }
});
}
});
}
function update_invoice_status(status) {
const labels = { 2: 'Mark as Paid', 3: 'Mark as Overdue', 4: 'Void Invoice' };
const classes = { 2: 'btn-success', 3: 'btn-warning', 4: 'btn-danger' };
const messages = {
2: 'Mark this invoice as paid?',
3: 'Mark this invoice as overdue?',
4: '<strong>Void this invoice?</strong><br>This action cannot be undone.',
};
bootbox.confirm({
message: messages[status],
buttons: {
confirm: { label: labels[status], className: classes[status] },
confirm: { label: 'Void Invoice', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
@@ -342,7 +358,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { invoice_id: invoice_id, status: status },
data: { invoice_id: invoice_id, status: 4 },
onSuccess: function() { retrieve_invoice(); }
});
}
@@ -350,6 +366,19 @@
}
function save_formula() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'save_formula',
data: { id: invoice_id, formula_id: $('#formula_id').val() || null },
onSuccess: function() {
bootbox.alert({ message: 'GL formula saved.', size: 'small' });
}
});
}
function print_invoice() {
if (!invoice_id) return;
window.open('<?php echo $server_url?>order/print_invoice.php?id=' + invoice_id, '_blank');
@@ -357,13 +386,12 @@
$(function() {
if (invoice_id) {
retrieve_invoice();
}
load_dept_cache();
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
</body>
</html>
</html>
+173 -203
View File
@@ -5,6 +5,8 @@
$order_id = (int)($_GET['id'] ?? 0);
$is_edit = isset($_GET['edit']) || $order_id === 0;
$source = $order_id ? '' : preg_replace('/[^a-z0-9_-]/i', '', $_GET['source'] ?? '');
$source_id = $order_id ? 0 : (int)($_GET['source_id'] ?? 0);
?>
<body>
@@ -61,9 +63,10 @@
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Shipping Tracking Number</label>
<input type="text" id="shipping_tracking_number" class="form-control"
placeholder="Carrier tracking number..." maxlength="100" autocomplete="off">
<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">
@@ -93,9 +96,10 @@
<tr>
<th>Product</th>
<th>Warehouse</th>
<th style="width:120px;">Qty</th>
<th style="width:130px;">Unit Price</th>
<th style="width:130px;">Total</th>
<th style="width:90px;">Qty</th>
<th style="width:120px;">Unit Price</th>
<th style="width:80px;">Tax%</th>
<th style="width:120px;">Total</th>
<th style="width:40px;"></th>
</tr>
</thead>
@@ -117,14 +121,26 @@
<h2 class="fs-5 mb-3">Status</h2>
<div class="d-flex gap-2 mb-2">
<span id="badge_status"></span>
<span id="badge_payment"></span>
<span id="badge_fulfillment"></span>
</div>
<p class="mb-0 small text-muted" id="order_number_display"></p>
<p class="mb-0 small text-muted d-none" id="source_display"></p>
<p class="mb-0 small text-muted d-none" id="tracking_number_display"></p>
</div>
</div>
<div class="alert alert-warning d-none mb-5" id="pending_workflow_hint">
<div class="d-flex gap-2">
<i class="ti ti-building-warehouse mt-1"></i>
<div>
<div class="fw-semibold">Warehouse assignment required</div>
<div class="small">
Select a warehouse for every item, then save it. The order will move to Draft and can be confirmed.
</div>
</div>
</div>
</div>
<!-- Totals card -->
<div class="card mb-5">
<div class="card-body p-4">
@@ -140,8 +156,12 @@
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Tax</span>
<input type="number" id="tax" class="form-control form-control-sm text-end"
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
<span id="summary_tax" class="fw-semibold">0.00</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Tax Adj.</span>
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()">
</div>
<div class="d-flex justify-content-between mb-3">
<span class="text-muted">Shipping</span>
@@ -180,36 +200,10 @@
<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice
</button>
<!-- Update fulfillment status (confirmed and above) -->
<div id="fulfillment_status_group">
<label class="form-label small text-muted mb-1">Fulfillment Status</label>
<div class="d-flex gap-2">
<select id="fulfillment_status_select" class="form-select form-select-sm">
<option value="1">Picking</option>
<option value="2">Packed</option>
<option value="3">Shipped</option>
</select>
<button class="btn btn-sm btn-outline-primary text-nowrap"
onclick="update_fulfillment_status()">
<i class="ti ti-check"></i>
</button>
</div>
</div>
<div id="payment_status_group">
<label class="form-label small text-muted mb-1">Payment Status</label>
<div class="d-flex gap-2">
<select id="payment_status_select" class="form-select form-select-sm">
<option value="0">Unpaid</option>
<option value="1">Paid</option>
<option value="2">Partial</option>
</select>
<button class="btn btn-sm btn-outline-primary text-nowrap"
onclick="update_payment_status()">
<i class="ti ti-check"></i>
</button>
</div>
</div>
<button class="btn btn-outline-primary w-100" id="btn_tracking"
onclick="update_tracking_number()">
<i class="ti ti-truck-delivery me-1"></i>Save Tracking
</button>
<!-- Create Return (confirmed and above) -->
<button class="btn btn-outline-warning w-100" id="btn_return"
onclick="create_return()">
@@ -234,8 +228,11 @@
<?php require '../include_ending.php'; ?>
<script>
// custom.js globals: remove_item(), init_product_search_inputs(), format_number()
var order_id = <?php echo $order_id; ?>;
var order_source = <?php echo json_encode($source); ?>;
var order_source_id = <?php echo (int)$source_id; ?>;
var order_data = null;
var warehouses = [];
var item_index = 0;
@@ -244,21 +241,10 @@
// ── Status helpers ────────────────────────────────────────────────────────
function order_status_badge(status) {
const map = {
'-2': '<span class="badge bg-danger">Pending — Awaiting Warehouse</span>',
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Confirmed</span>',
'2': '<span class="badge bg-primary">Processing</span>',
'3': '<span class="badge bg-success">Completed</span>',
};
return map[String(status)] ?? '—';
}
function payment_status_badge(status) {
const map = {
'0': '<span class="badge bg-warning text-dark">Unpaid</span>',
'1': '<span class="badge bg-success">Paid</span>',
'2': '<span class="badge bg-info text-white">Partial</span>',
};
return map[String(status)] ?? '—';
}
@@ -272,24 +258,45 @@
return map[String(status)] ?? '';
}
function source_display_label(source, source_id) {
source = String(source || '').trim();
source_id = parseInt(source_id) || 0;
if (!source) return '';
const labels = {
quotation: 'Quotation',
api: 'API',
marketplace: 'Marketplace',
revenue: 'Revenue',
};
return (labels[source] || source) + (source_id > 0 ? ' #' + source_id : '');
}
// ── Recalculate totals ────────────────────────────────────────────────────
function recalc_totals() {
var subtotal = 0;
var tax_sum = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var total = qty * price;
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var total = qty * price;
var tax_amt = round_dp(total * tax_rate / 100, 4);
$(this).find('.item_total').text(format_number(total, 2));
$(this).find('.item_tax_amount').val(tax_amt);
subtotal += total;
tax_sum += tax_amt;
});
var discount = parseFloat($('#discount').val()) || 0;
var tax = parseFloat($('#tax').val()) || 0;
var shipping_fee = parseFloat($('#shipping_fee').val()) || 0;
var grand_total = subtotal - discount + tax + shipping_fee;
var discount = parseFloat($('#discount').val()) || 0;
var tax_adjustment = parseFloat($('#tax_adjustment').val()) || 0;
var shipping_fee = parseFloat($('#shipping_fee').val()) || 0;
var derived_tax = round_dp(tax_sum, 2);
var grand_total = subtotal - discount + derived_tax + tax_adjustment + shipping_fee;
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(derived_tax, 2));
$('#summary_grand_total').text(format_number(grand_total, 2));
}
@@ -299,7 +306,7 @@
item = item || {};
var idx = item_index++;
var wh_options = warehouses.map(w =>
var wh_options = '<option value="">Select warehouse</option>' + warehouses.map(w =>
`<option value="${w.id}" ${item.warehouse_id == w.id ? 'selected' : ''}>${w.warehouse_name}</option>`
).join('');
@@ -307,7 +314,7 @@
<td>
<input type="text" class="form-control form-control-sm item_sku product_search"
placeholder="SKU..." value="${item.product_sku || ''}" autocomplete="off"
data-idx="${idx}">
data-idx="${idx}" data-product-name-target=".item_name">
<input type="hidden" class="item_name" value="${item.product_name || ''}">
<input type="hidden" class="item_id" value="${item.item_id ?? idx}">
<small class="text-muted item_name_display">${item.product_name || ''}</small>
@@ -323,9 +330,14 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_price"
value="${item.price || 0}" min="0" step="any" oninput="recalc_totals()">
value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.price || 0), 2)}</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
<td>
<a href="javascript:void(0);" class="link-danger" onclick="remove_item(${idx})">
<i class="ti ti-trash fs-5"></i>
@@ -334,13 +346,12 @@
</tr>`;
$('#item_tbody').append(row);
if (window.init_product_search_inputs) {
window.init_product_search_inputs(`#item_row_${idx}`);
}
recalc_totals();
}
function remove_item(idx) {
$(`#item_row_${idx}`).remove();
recalc_totals();
}
// ── Collect items from DOM ────────────────────────────────────────────────
@@ -348,12 +359,15 @@
var items = [];
var idx = 0;
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var name = $(this).find('.item_name').val().trim();
var wh = parseInt($(this).find('.item_warehouse').val()) || 0;
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
if (!sku || !wh || qty <= 0) { idx++; return; }
var sku = $(this).find('.item_sku').val().trim();
var name = $(this).find('.item_name').val().trim();
var wh = parseInt($(this).find('.item_warehouse').val()) || 0;
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
if (!sku || qty <= 0) { idx++; return; }
if (!wh && !(!order_id && order_source)) { idx++; return; }
items.push({
item_id: idx,
product_sku: sku,
@@ -365,8 +379,10 @@
lot_number: '',
serial_number: '',
quantity: qty,
price: price,
total_price: qty * price,
unit_price: price,
total_price: round_dp(qty * price, 4),
tax_rate: tax_rate,
tax_amount: tax_amt,
stock_out_id: 0,
});
idx++;
@@ -387,42 +403,33 @@
var can_confirm = status === 0; // only Draft can be confirmed (not Pending)
var can_cancel = status === -2 || status === 0 || status === 1;
var is_cancelled = status === -1;
var is_completed = status === 3;
var can_fulfill = status >= 1 && !is_cancelled && !is_completed;
var can_update_tracking = status === 1 && !is_cancelled;
// Edit controls
$('#btn_add_item, #contact, #order_date, #notes, #discount, #tax, #shipping_fee')
$('#btn_add_item, #contact, #order_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee')
.prop('disabled', !is_editable);
$('#shipping_tracking_number').prop('disabled', !(is_editable || can_fulfill));
$('.item_sku, .item_qty, .item_price, .item_warehouse').prop('disabled', !is_editable);
$('.item_sku, .item_qty, .item_price, .item_tax_rate, .item_warehouse').prop('disabled', !is_editable);
$('a[onclick^="remove_item"]').toggleClass('d-none', !is_editable);
// Pending hint
if (is_pending) {
$('#page_subtitle').text('Assign a warehouse for each item, then save to move this order to Draft.');
$('#page_subtitle').text('Pending warehouse assignment');
}
$('#pending_workflow_hint').toggleClass('d-none', !is_pending);
// Action buttons
set_btn_state('#btn_save', is_draft, 'Only draft orders can be edited');
set_btn_state('#btn_confirm', can_confirm, 'Order must be in draft to confirm');
set_btn_state('#btn_save', is_editable, 'Only pending or draft orders can be edited');
$('#btn_save').html(is_pending
? '<i class="ti ti-device-floppy me-1"></i>Save Warehouse'
: '<i class="ti ti-device-floppy me-1"></i>Save Draft');
set_btn_state('#btn_confirm', can_confirm,
is_pending ? 'Assign warehouses and save first. The order will move to Draft.' : 'Order must be in draft to confirm');
set_btn_state('#btn_cancel', can_cancel && !is_cancelled,
is_cancelled ? 'Order is already cancelled' : 'Only draft or confirmed orders can be cancelled');
// Fulfillment status — confirmed and above, not cancelled, not completed
$('#fulfillment_status_group').toggleClass('d-none', !can_fulfill);
if (can_fulfill) {
// Only show options ahead of current fulfillment
$('#fulfillment_status_select option').each(function() {
$(this).prop('disabled', parseInt($(this).val()) <= fulfillment_status);
});
// Default to next step
var next = fulfillment_status + 1;
if (next <= 3) $('#fulfillment_status_select').val(next);
else $('#fulfillment_status_select').val(fulfillment_status);
}
// Payment status selector
$('#payment_status_select').prop('disabled', status < 1 || is_cancelled);
set_btn_state('#btn_tracking', can_update_tracking,
is_cancelled ? 'Order is cancelled'
: status < 1 ? 'Confirm the order before adding a tracking number' : '');
// Create Return
var can_return = status >= 1 && !is_cancelled && returnable_count > 0;
@@ -464,21 +471,25 @@
$('#page_subtitle').text('');
$('#status_card').removeClass('d-none');
$('#badge_status').html(order_status_badge(o.status));
$('#badge_payment').html(payment_status_badge(o.payment_status));
$('#badge_fulfillment').html(fulfillment_status_badge(o.fulfillment_status));
$('#order_number_display').text(o.order_number);
var source_label = source_display_label(o.source || '', o.source_id || 0);
$('#source_display')
.toggleClass('d-none', !source_label)
.text(source_label ? 'Source: ' + source_label : '');
var tracking = o.shipping_tracking_number || '';
$('#tracking_number_display')
.toggleClass('d-none', !o.shipping_tracking_number)
.text(o.shipping_tracking_number ? 'Tracking: ' + o.shipping_tracking_number : '');
.toggleClass('d-none', !tracking)
.html(tracking ? '<i class="ti ti-truck-delivery me-1"></i>' + escape_html(tracking) : '');
// Fields
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id);
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
$('#shipping_tracking_number').val(o.shipping_tracking_number || '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax').val(o.tax || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
$('#shipping_fee').val(o.shipping_fee || 0);
// Items
@@ -487,7 +498,6 @@
recalc_totals();
set_mode(o.status, res.has_active_invoice, (res.returnable_items || []).length, o.fulfillment_status);
$('#payment_status_select').val(o.payment_status ?? 0);
}
});
}
@@ -495,6 +505,17 @@
// ── Save draft ────────────────────────────────────────────────────────────
function save_order() {
var missing_warehouse = false;
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var wh = parseInt($(this).find('.item_warehouse').val()) || 0;
if (sku && !wh) missing_warehouse = true;
});
if (missing_warehouse && !(!order_id && order_source)) {
bootbox.alert('Select a warehouse for every item before saving.');
return;
}
var items = collect_items();
if (!items.length) {
bootbox.alert('Please add at least one item.');
@@ -511,15 +532,17 @@
checkRequired: 0,
action: order_id ? 'update' : 'create',
data: {
id: order_id,
contact_id: $('#contact_id').val() || 0,
order_date: to_iso_date($('#order_date').val()),
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax: $('#tax').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
shipping_tracking_number: $('#shipping_tracking_number').val().trim(),
notes: $('#notes').val(),
id: order_id,
contact_id: $('#contact_id').val() || 0,
department_id: $('#department_id').val() || 0,
order_date: to_iso_date($('#order_date').val()),
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
source: order_id ? '' : order_source,
source_id: order_id ? 0 : order_source_id,
notes: $('#notes').val(),
},
onSuccess: function(res) {
if (!order_id && res.new_id) {
@@ -557,43 +580,33 @@
}
// ── Update fulfillment status ─────────────────────────────────────────────
function update_fulfillment_status() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_order_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: order_id,
fulfillment_status: $('#fulfillment_status_select').val(),
shipping_tracking_number: $('#shipping_tracking_number').val().trim(),
},
onSuccess: function() {
retrieve_order();
// ── Update tracking number ────────────────────────────────────────────────
function update_tracking_number() {
var current = (order_data && order_data.shipping_tracking_number) ? order_data.shipping_tracking_number : '';
bootbox.prompt({
title: '<i class="ti ti-truck-delivery me-2"></i>Shipping Tracking Number',
value: current,
inputType: 'text',
placeholder: 'Enter carrier tracking number...',
callback: function(result) {
if (result === null) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_order_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: order_id,
shipping_tracking_number: result.trim(),
},
onSuccess: function() {
retrieve_order();
}
});
}
});
}
// ── Update payment status ─────────────────────────────────────────────────
function update_payment_status() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_payment_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: order_id,
payment_status: $('#payment_status_select').val(),
},
onSuccess: function() {
retrieve_order();
}
});
}
// ── Create return ─────────────────────────────────────────────────────────
function create_return() {
window.location.href = '<?php echo $server_url?>order/manage_return.php?order_id=' + order_id;
@@ -656,67 +669,23 @@
}
// ── Contact autocomplete ──────────────────────────────────────────────────
function init_contact_search() {
$('#contact').autocomplete({
source: function(req, res) {
ajax_request({
url: '<?php echo $server_url?>contact/api/engine/contact.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { keyword: req.term },
onSuccess: function(data) {
res(data.output.map(c => ({
label: c.contact_name,
value: c.contact_name,
id: c.id,
})));
}
});
},
select: function(e, ui) {
$('#contact_id').val(ui.item.id);
}
});
}
$(document).on('contact:selected', '#contact', function(e, contact) {
$('#contact_id').val(contact.id);
});
// ── Product autocomplete (per row) ────────────────────────────────────────
function init_product_search() {
$(document).on('input', '.item_sku', function() {
var $input = $(this);
var $row = $input.closest('tr');
$input.autocomplete({
source: function(req, res) {
ajax_request({
url: '<?php echo $server_url?>inventory/api/engine/product.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { keyword: req.term },
onSuccess: function(data) {
res((data.output || []).map(p => ({
label: p.sku + ' — ' + p.product_name,
value: p.sku,
name: p.product_name,
})));
}
});
},
select: function(e, ui) {
$row.find('.item_name').val(ui.item.name);
$row.find('.item_name_display').text(ui.item.name);
recalc_totals();
}
});
});
}
$(document).on('product:selected', '.item_sku', function(e, product) {
var $row = $(this).closest('tr');
$row.find('.item_name').val(product.product_name || '');
$row.find('.item_name_display').text(product.product_name || '');
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
recalc_totals();
});
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await retrieve_warehouses();
if (order_id) {
@@ -727,14 +696,15 @@
$('#order_date').val(today);
add_item_row();
set_mode(0);
if (order_source) {
$('#page_title').text('New Revenue Sales Order');
$('#page_subtitle').text('Create a pending sales order for WMS warehouse assignment');
}
}
// Init flatpickr on order date
flatpickr('#order_date', { dateFormat: 'd/m/Y', allowInput: true });
init_contact_search();
init_product_search();
} catch(e) {
console.log(e);
}
+121 -31
View File
@@ -70,6 +70,11 @@
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Department</label>
<p class="form-control-plaintext" id="display_department">—</p>
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Reason</label>
<textarea id="reason" class="form-control" rows="2"
@@ -99,13 +104,14 @@
<th class="th_return_aisle" style="width:120px;">Aisle</th>
<th class="th_return_rack" style="width:120px;">Location</th>
<th class="text-end" style="width:110px;">Return Qty</th>
<th class="text-end" style="width:120px;">Unit Price</th>
<th class="text-end" style="width:120px;">Total</th>
<th class="text-end" style="width:100px;">Unit Price</th>
<th style="width:70px;">Tax%</th>
<th class="text-end" style="width:110px;">Total</th>
</tr>
</thead>
<tbody id="item_tbody">
<tr id="loading_row">
<td colspan="11" class="text-center text-muted py-4">
<td colspan="12" class="text-center text-muted py-4">
<i class="ti ti-loader me-1"></i>Loading items...
</td>
</tr>
@@ -134,6 +140,20 @@
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Refund Summary</h2>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="summary_subtotal">0.00</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Tax</span>
<span id="summary_tax">0.00</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Tax Adj.</span>
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
style="width:90px;" min="-0.3" max="0.3" step="0.01" value="0" oninput="recalc_totals()">
</div>
<hr>
<div class="d-flex justify-content-between fw-bold fs-5">
<span>Total Refund</span>
<span id="summary_refund">0.00</span>
@@ -155,6 +175,15 @@
<i class="ti ti-check me-1"></i>Confirm Return
</button>
<button class="btn btn-warning w-100" id="btn_create_cn"
onclick="proceed_to_credit_note()">
<i class="ti ti-file-plus me-1"></i>Create Credit Note
</button>
<a href="#" class="btn btn-outline-warning w-100 d-none" id="btn_view_cn">
<i class="ti ti-file-invoice me-1"></i>View Credit Note
</a>
<button class="btn btn-outline-danger w-100" id="btn_cancel"
onclick="cancel_return()">
<i class="ti ti-x me-1"></i>Cancel Return
@@ -171,6 +200,7 @@
<?php require '../include_ending.php'; ?>
<script>
// custom.js globals: escape_html(), format_number()
var return_id = <?php echo $return_id; ?>;
var return_data = null;
@@ -196,18 +226,23 @@
// ── Recalculate totals ────────────────────────────────────────────────────
function recalc_totals() {
var total = 0;
var subtotal = 0, tax_sum = 0;
$('#item_tbody tr.return-item-row').each(function() {
var $row = $(this);
var checked = $row.find('.item_check').is(':checked');
if (!checked) return;
var qty = parseFloat($row.find('.item_qty').val()) || 0;
var price = parseFloat($row.find('.item_price').val()) || 0;
var t = qty * price;
var $row = $(this);
if (!$row.find('.item_check').is(':checked')) return;
var qty = parseFloat($row.find('.item_qty').val()) || 0;
var price = parseFloat($row.find('.item_price').val()) || 0;
var tax_rate = parseFloat($row.find('.item_tax_rate').val()) || 0;
var t = qty * price;
$row.find('.item_total').text(format_number(t, 2));
total += t;
subtotal += t;
tax_sum += round_dp(t * tax_rate / 100, 4);
});
$('#summary_refund').text(format_number(total, 2));
var tax_adj = parseFloat($('#tax_adjustment').val()) || 0;
var tax = round_dp(tax_sum, 2) + tax_adj;
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(tax, 2));
$('#summary_refund').text(format_number(subtotal + tax, 2));
}
function escape_html(value) {
@@ -235,7 +270,7 @@
$('.th_return_zone').text(label_zone).toggleClass('d-none', !advanced);
$('.th_return_aisle').text(label_aisle).toggleClass('d-none', !advanced);
$('.th_return_rack').text(label_rack);
$('#loading_row td').attr('colspan', 11);
$('#loading_row td').attr('colspan', 12);
}
function retrieve_return_zones(item_id, selected_zone, selected_aisle, selected_rack) {
@@ -449,8 +484,9 @@
var si = selected_map[item.item_id] || null;
var checked = si ? 'checked' : '';
var received = return_data && parseInt(return_data.status) === 1 && si;
var qty = si ? si.quantity : item.returnable_qty;
var price = item.price || 0;
var qty = si ? si.quantity : item.returnable_qty;
var price = item.price || 0;
var tax_rate = si ? (parseFloat(si.tax_rate) || 0) : (parseFloat(item.tax_rate) || 0);
var destination = {
warehouse_id: si ? (si.warehouse_id || item.warehouse_id || '') : (item.warehouse_id || ''),
zone: si ? (si.zone || '') : (item.zone || ''),
@@ -499,6 +535,13 @@
${!checked ? 'disabled' : ''}
oninput="recalc_totals()">
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${tax_rate.toFixed(2)}"
min="0" max="100" step="0.01"
${!checked ? 'disabled' : ''}
oninput="recalc_totals()">
</td>
<td class="text-end item_total">${checked ? format_number(qty * price, 2) : '—'}</td>
</tr>`;
});
@@ -511,7 +554,7 @@
function toggle_item_row(item_id) {
var $row = $(`#ret_item_${item_id}`);
var checked = $row.find('.item_check').is(':checked');
$row.find('.item_qty, .item_price, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack').prop('disabled', !checked);
$row.find('.item_qty, .item_price, .item_tax_rate, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack').prop('disabled', !checked);
if (!checked) {
$row.find('.item_total').text('—');
return;
@@ -609,8 +652,9 @@
var $row = $(this);
if (!$row.find('.item_check').is(':checked')) return;
var qty = parseFloat($row.find('.item_qty').val()) || 0;
var price = parseFloat($row.find('.item_price').val()) || 0;
var qty = parseFloat($row.find('.item_qty').val()) || 0;
var price = parseFloat($row.find('.item_price').val()) || 0;
var tax_rate = parseFloat($row.find('.item_tax_rate').val()) || 0;
if (qty <= 0) return;
var return_wh = parseInt($row.find('.item_return_warehouse').val()) || 0;
var return_rack = $row.find('.item_return_rack').val() || '';
@@ -634,9 +678,11 @@
stock_out_id: parseInt($row.find('.item_stock_out_id').val()) || 0,
stock_out_warehouse_id: parseInt($row.find('.item_stock_out_wh').val()) || 0,
stock_out_status: parseInt($row.find('.item_stock_out_status').val()) || 0,
quantity: qty,
price: price,
total_price: qty * price,
quantity: qty,
price: price,
tax_rate: tax_rate,
tax_amount: round_dp(qty * price * tax_rate / 100, 4),
total_price: round_dp(qty * price, 4),
});
});
return items;
@@ -657,14 +703,15 @@
}
return {
id: return_id,
order_id: $('#order_id').val() || 0,
invoice_id: $('#invoice_id').val() || 0,
contact_id: $('#contact_id').val() || 0,
return_date: $('#return_date').val().split('/').reverse().join('-'),
reason: $('#reason').val(),
items: JSON.stringify(items),
notes: '',
id: return_id,
order_id: $('#order_id').val() || 0,
invoice_id: $('#invoice_id').val() || 0,
contact_id: $('#contact_id').val() || 0,
return_date: $('#return_date').val().split('/').reverse().join('-'),
reason: $('#reason').val(),
items: JSON.stringify(items),
notes: '',
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
};
}
@@ -686,13 +733,17 @@
// ── Mode ──────────────────────────────────────────────────────────────────
function set_mode(status) {
status = parseInt(status);
var is_draft = status === 0;
var is_draft = status === 0;
var is_confirmed = status === 1;
var has_cn = return_data && return_data.has_active_cn;
var cn_id = return_data && return_data.active_cn_id;
$('#return_date, #reason, #invoice_id').prop('disabled', !is_draft);
$('#tax_adjustment').prop('disabled', !is_draft);
$('.item_check').prop('disabled', !is_draft);
$('#item_tbody tr.return-item-row').each(function() {
var checked = $(this).find('.item_check').is(':checked');
$(this).find('.item_qty, .item_price, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack')
$(this).find('.item_qty, .item_price, .item_tax_rate, .item_return_warehouse, .item_return_zone, .item_return_aisle, .item_return_rack')
.prop('disabled', !is_draft || !checked);
});
@@ -703,6 +754,16 @@
if (!return_id) {
$('#btn_confirm').prop('disabled', true);
}
set_btn_state('#btn_create_cn', is_confirmed && !has_cn,
!return_id ? 'Save the return first'
: !is_confirmed ? 'Confirm the return before creating a credit note'
: 'A credit note already exists for this return');
if (has_cn && cn_id) {
$('#btn_view_cn').attr('href', '<?php echo $server_url?>order/manage_invoice.php?id=' + cn_id);
}
$('#btn_view_cn').toggleClass('d-none', !(is_confirmed && has_cn));
set_btn_state('#btn_cancel', is_draft || status === 1,
status === -1 ? 'Return is already cancelled' : 'Return is completed and cannot be cancelled');
}
@@ -728,6 +789,8 @@
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
// Load order context (which also renders items with existing selection)
await load_order(r.order_id, r.items || []);
@@ -750,6 +813,7 @@
}
// ── Confirm return ────────────────────────────────────────────────────────
function confirm_return() {
bootbox.confirm({
@@ -775,6 +839,31 @@
}
// ── Proceed to credit note ────────────────────────────────────────────────
function proceed_to_credit_note() {
bootbox.confirm({
message: 'Create a credit note for this return?',
buttons: {
confirm: { label: 'Create Credit Note', className: 'btn-warning' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/proceed_to_credit_note.php',
autoPrepare: true,
checkRequired: 0,
action: 'create',
data: { return_id: return_id },
onSuccess: function(res) {
window.location.href = '<?php echo $server_url?>order/manage_invoice.php?id=' + res.new_id;
}
});
}
});
}
// ── Cancel return ─────────────────────────────────────────────────────────
function cancel_return() {
bootbox.confirm({
@@ -802,6 +891,7 @@
$(async function() {
try {
flatpickr('#return_date', { dateFormat: 'd/m/Y', allowInput: true });
await load_dept_cache();
await load_company_setting();
await retrieve_warehouses();
+27 -35
View File
@@ -72,11 +72,11 @@
<div class="col-lg-3 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">
<i class="ti ti-circle-check fs-4"></i>
<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">Completed</p>
<p class="mb-0 text-muted small">Cancelled</p>
<h3 class="fw-bold mb-0" id="stat_completed">—</h3>
</div>
</div>
@@ -92,32 +92,27 @@
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<div class="col-lg-4">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search order number, contact, or tracking...">
placeholder="Search order number, source, contact, or tracking...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="col-lg-3">
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="-2">Pending</option>
<option value="-1">Cancelled</option>
<option value="0">Draft</option>
<option value="1">Confirmed</option>
<option value="2">Processing</option>
<option value="3">Completed</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_payment" class="form-select">
<option value="">All Payment</option>
<option value="0">Unpaid</option>
<option value="1">Paid</option>
<option value="2">Partial</option>
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
@@ -139,14 +134,15 @@
<thead class="table-primary border-light">
<tr>
<th class="py-3">Order #</th>
<th class="py-3">Source</th>
<th class="py-3">Date</th>
<th class="py-3">Contact</th>
<th class="py-3">Department</th>
<th class="py-3">Items</th>
<th class="py-3">Grand Total</th>
<th class="py-3">Status</th>
<th class="py-3">Fulfillment</th>
<th class="py-3">Tracking #</th>
<th class="py-3">Payment</th>
<th class="py-3">Action</th>
</tr>
</thead>
@@ -170,26 +166,14 @@
function order_status_badge(status) {
status = parseInt(status);
const map = {
'-2': '<span class="badge bg-danger">Pending</span>',
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
'-1': '<span class="badge bg-secondary">Cancelled</span>',
'0': '<span class="badge bg-warning text-dark">Draft</span>',
'1': '<span class="badge bg-info text-white">Confirmed</span>',
'2': '<span class="badge bg-primary">Processing</span>',
'3': '<span class="badge bg-success">Completed</span>',
};
return map[String(status)] ?? '<span class="badge bg-light text-dark">—</span>';
}
function payment_status_badge(status) {
status = parseInt(status);
const map = {
'0': '<span class="badge bg-warning text-dark">Unpaid</span>',
'1': '<span class="badge bg-success">Paid</span>',
'2': '<span class="badge bg-info text-white">Partial</span>',
};
return map[status] ?? '<span class="badge bg-light text-dark">—</span>';
}
function fulfillment_status_badge(status) {
const map = {
'1': '<span class="badge bg-warning text-dark"><i class="ti ti-list-check me-1"></i>Picking</span>',
@@ -214,23 +198,24 @@
// Client-side filter
var keyword = $('#search_input').val().toLowerCase();
var fil_status = $('#filter_status').val();
var fil_payment = $('#filter_payment').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(o) {
var match_kw = !keyword ||
(o.order_number || '').toLowerCase().includes(keyword) ||
(o.source || 'direct').toLowerCase().includes(keyword) ||
(o.contact_name || '').toLowerCase().includes(keyword) ||
(o.shipping_tracking_number || '').toLowerCase().includes(keyword);
var match_st = fil_status === '' || String(o.status) === fil_status;
var match_pm = fil_payment === '' || String(o.payment_status) === fil_payment;
return match_kw && match_st && match_pm;
var match_st = fil_status === '' || String(o.status) === fil_status;
var match_dept = !fil_dept || String(o.department_id) === fil_dept;
return match_kw && match_st && match_dept;
});
// Stat cards
$('#stat_total').text(format_number(all.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) === 3).length));
$('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === -1).length));
// Pagination via alasql
alasql('CREATE TABLE IF NOT EXISTS order_list');
@@ -261,18 +246,23 @@
body += `<tr>
<td class="py-3 fw-semibold">${o.order_number}</td>
<td class="py-3">${o.source ? escape_html(o.source) : 'direct'}</td>
<td class="py-3">${format_date(o.order_date)}</td>
<td class="py-3">${o.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${get_dept_label(o.department_id)}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(o.grand_total, 2)}</td>
<td class="py-3">${order_status_badge(o.status)}</td>
<td class="py-3">${fulfillment_status_badge(o.fulfillment_status)}</td>
<td class="py-3">${o.shipping_tracking_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${payment_status_badge(o.payment_status)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>order/manage_order.php?id=${o.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
<a href="javascript:void(0);" class="ms-2 text-secondary" title="Stock"
onclick="show_stock_rows('order', ${o.id}, '${escape_html(o.order_number || '')}')">
<i class="ti ti-packages fs-5"></i>
</a>
${can_cancel ? `
<a href="javascript:void(0);" class="link-danger ms-2" title="Cancel"
onclick="cancel_order(${o.id})">
@@ -312,12 +302,14 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(function() {
$(async function() {
await load_dept_cache();
populate_dept_filter('filter_dept');
retrieve_orders();
$('#search_input').on('keyup', function(e) {
if (e.key === 'Enter') retrieve_orders();
});
$('#filter_status, #filter_dept').on('change', retrieve_orders);
});
</script>
+1 -1
View File
@@ -62,7 +62,7 @@
$doc_labels = [
'invoice' => 'INVOICE',
'credit_note' => 'CREDIT NOTE',
'debit_note' => 'DEBIT NOTE',
'supplier_credit_note' => 'SUPPLIER CREDIT NOTE',
];
$doc_label = $doc_labels[$inv['doc_type'] ?? 'invoice'] ?? 'INVOICE';
+31 -11
View File
@@ -19,9 +19,6 @@
<h1 class="fs-3 mb-1">Returns</h1>
<p class="mb-0">Manage customer return requests</p>
</div>
<a href="<?php echo $server_url?>order/manage_return.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Return
</a>
</div>
</div>
</div>
@@ -32,7 +29,7 @@
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<div class="col-lg-4">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search return number or contact...">
@@ -41,7 +38,7 @@
</span>
</div>
</div>
<div class="col-lg-3">
<div class="col-lg-2">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="-1">Cancelled</option>
@@ -49,6 +46,11 @@
<option value="1">Confirmed</option>
</select>
</div>
<div class="col-lg-2">
<select id="filter_dept" class="form-select">
<option value="">All Departments</option>
</select>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary w-100" onclick="retrieve_returns()">
<i class="ti ti-search me-1"></i>Search
@@ -72,10 +74,12 @@
<th class="py-3">Return #</th>
<th class="py-3">Order #</th>
<th class="py-3">Contact</th>
<th class="py-3">Department</th>
<th class="py-3">Date</th>
<th class="py-3">Items</th>
<th class="py-3">Refund Amount</th>
<th class="py-3">Status</th>
<th class="py-3">Stock</th>
<th class="py-3">Action</th>
</tr>
</thead>
@@ -104,6 +108,11 @@
return map[String(status)] ?? '—';
}
function receipt_status_badge(status) {
if (parseInt(status) === 3) return '<span class="badge bg-success">Restocked</span>';
return '<span class="text-muted">—</span>';
}
function retrieve_returns() {
return ajax_request({
@@ -114,15 +123,17 @@
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var fil_st = $('#filter_status').val();
var keyword = $('#search_input').val().toLowerCase();
var fil_st = $('#filter_status').val();
var fil_dept = $('#filter_dept').val();
var data = all.filter(function(r) {
var match_kw = !keyword ||
var match_kw = !keyword ||
(r.return_number || '').toLowerCase().includes(keyword) ||
(r.contact_name || '').toLowerCase().includes(keyword);
var match_st = fil_st === '' || String(r.status) === fil_st;
return match_kw && match_st;
var match_st = fil_st === '' || String(r.status) === fil_st;
var match_dept = !fil_dept || String(r.department_id) === fil_dept;
return match_kw && match_st && match_dept;
});
alasql('CREATE TABLE IF NOT EXISTS return_list');
@@ -152,14 +163,20 @@
<td class="py-3 fw-semibold">${r.return_number}</td>
<td class="py-3">${r.order_number || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${r.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${format_date(r.return_date)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.refund_amount, 2)}</td>
<td class="py-3">${return_status_badge(r.status)}</td>
<td class="py-3">${receipt_status_badge(r.receipt_status)}</td>
<td class="py-3">
<a href="<?php echo $server_url?>order/manage_return.php?id=${r.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
<a href="javascript:void(0);" class="ms-2 text-secondary" title="Stock"
onclick="show_stock_rows('return', ${r.id}, '${escape_html(r.return_number || '')}')">
<i class="ti ti-packages fs-5"></i>
</a>
${can_cancel ? `
<a href="javascript:void(0);" class="link-danger ms-2" title="Cancel"
onclick="cancel_return(${r.id})">
@@ -195,11 +212,14 @@
}
$(function() {
$(async function() {
await load_dept_cache();
populate_dept_filter('filter_dept');
retrieve_returns();
$('#search_input').on('keyup', function(e) {
if (e.key === 'Enter') retrieve_returns();
});
$('#filter_status, #filter_dept').on('change', retrieve_returns);
});
</script>