Orders: order, return, invoice
This commit is contained in:
@@ -705,4 +705,39 @@ function format_number(value, decimal) {
|
||||
var parts = n.toString().split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* set_btn_state(selector, enabled, hint)
|
||||
*
|
||||
* Enable or disable a button with consistent visual feedback:
|
||||
* enabled → restores original btn-* classes, removes disabled, clears title
|
||||
* disabled → swaps to btn-secondary, adds disabled attr, sets title hint
|
||||
*
|
||||
* Stores the original class on first call via data-original-class so
|
||||
* repeated calls always restore the correct variant.
|
||||
*
|
||||
* @param {string} selector jQuery selector e.g. '#btn_confirm'
|
||||
* @param {boolean} enabled true = enable, false = disable
|
||||
* @param {string} hint Tooltip text shown when disabled
|
||||
*/
|
||||
function set_btn_state(selector, enabled, hint) {
|
||||
var $btn = $(selector);
|
||||
if (!$btn.length) return;
|
||||
|
||||
// Store original classes once
|
||||
if (!$btn.data('original-class')) {
|
||||
$btn.data('original-class', $btn.attr('class'));
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
$btn.attr('class', $btn.data('original-class'))
|
||||
.prop('disabled', false)
|
||||
.attr('title', '');
|
||||
} else {
|
||||
$btn.attr('class', $btn.data('original-class')
|
||||
.replace(/btn-(outline-)?[a-z]+/g, '') + ' btn-light')
|
||||
.prop('disabled', true)
|
||||
.attr('title', hint || '');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
* Current keys:
|
||||
* default_stock_status int 0 = draft (manual approve required)
|
||||
* 1 = auto-approve on save
|
||||
* auto_invoice_and_credit_note int 0 = manual
|
||||
* 1 = auto-generate invoice on confirm order
|
||||
* + auto-generate credit note on confirm return
|
||||
*
|
||||
* Designed to accept new keys without schema changes.
|
||||
*/
|
||||
@@ -17,7 +20,8 @@ class CompanySettingManager
|
||||
|
||||
// ── Known keys with their defaults ───────────────────────────────────────
|
||||
private const DEFAULTS = [
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'default_stock_status' => 1, // auto-approve by default
|
||||
'auto_invoice_and_credit_note' => 0, // manual by default
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $companyId)
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* InvoiceManager
|
||||
*
|
||||
* Handles all read and write operations for td_invoice.
|
||||
* Supports three doc_type values: invoice | credit_note | debit_note
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getInvoiceList, getInvoiceById,
|
||||
* generateInvoiceNumber, saveInvoice,
|
||||
* createFromOrder, createCreditNote,
|
||||
* voidInvoice
|
||||
*
|
||||
* Key design decisions:
|
||||
* - invoice.items is a JSON snapshot of td_order.items at issue time.
|
||||
* credit_note.items contains only the items being corrected.
|
||||
* - grand_total is positive for invoice/debit_note, negative for credit_note.
|
||||
* - createFromOrder() is called by confirmOrder() when auto_invoice=1,
|
||||
* or manually from the order detail page. Always creates status=0 (draft).
|
||||
* - createCreditNote() is called by ReturnManager::approveReturn() —
|
||||
* never directly by the user.
|
||||
* - voidInvoice() only works on invoices with no approved credit notes
|
||||
* summing to grand_total (partial credit notes must be resolved first).
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
*/
|
||||
class InvoiceManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildLogEntry(string $action): array {
|
||||
return [
|
||||
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||||
'dt' => date('Y-m-d H:i:s'),
|
||||
'login' => isset($_SESSION['otpTime'])
|
||||
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
||||
: null,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next sequential document number.
|
||||
*
|
||||
* @param string $doc_type 'invoice' | 'credit_note' | 'debit_note'
|
||||
* @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001"
|
||||
*/
|
||||
private function generateInvoiceNumber(string $doc_type): string
|
||||
{
|
||||
$prefix_map = [
|
||||
'invoice' => 'INV',
|
||||
'credit_note' => 'CN',
|
||||
'debit_note' => 'DN',
|
||||
];
|
||||
$prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT invoice_number FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND doc_type = :doc_type
|
||||
AND invoice_number LIKE :prefix
|
||||
ORDER BY invoice_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':doc_type' => $doc_type,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all invoices for the company ordered by id DESC.
|
||||
* Optionally filter by order_id or doc_type.
|
||||
*
|
||||
* @param int $order_id Filter by td_order.id (0 = all)
|
||||
* @param string $doc_type Filter by doc_type ('' = all)
|
||||
* @return array
|
||||
*/
|
||||
public function getInvoiceList(int $order_id = 0, string $doc_type = ''): array
|
||||
{
|
||||
$where = ['i.company_id = :company_id'];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($order_id > 0) {
|
||||
$where[] = 'i.order_id = :order_id';
|
||||
$params[':order_id'] = $order_id;
|
||||
}
|
||||
if ($doc_type !== '') {
|
||||
$where[] = 'i.doc_type = :doc_type';
|
||||
$params[':doc_type'] = $doc_type;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single invoice by id, with items decoded.
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @return array|false
|
||||
*/
|
||||
public function getInvoiceById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE i.company_id = :company_id
|
||||
AND i.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a draft invoice from a confirmed order.
|
||||
*
|
||||
* Snapshots td_order.items into td_invoice.items.
|
||||
* Copies totals from the order directly.
|
||||
* Always creates status=0 (draft) — user must manually issue.
|
||||
*
|
||||
* Called by:
|
||||
* - confirmOrder() engine when auto_invoice=1
|
||||
* - "Proceed to Invoice" button on order detail page (manual)
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_invoice.id
|
||||
* @throws Exception If order not found or invoice already exists for this order.
|
||||
*/
|
||||
public function createFromOrder(int $order_id, array $logging): int
|
||||
{
|
||||
// Load order
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
||||
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$order) {
|
||||
throw new Exception("Order not found.");
|
||||
}
|
||||
if ((int)$order['status'] < 1) {
|
||||
throw new Exception("Invoice can only be created for confirmed orders.");
|
||||
}
|
||||
|
||||
// Block duplicate invoice for same order
|
||||
$sth = $this->pdo->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' => $this->company_id, ':order_id' => $order_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("An active invoice already exists for this order.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_from_order'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_invoice
|
||||
(company_id, uuid, doc_type, invoice_number, ref_invoice_id,
|
||||
order_id, contact_id, issued_date, due_date,
|
||||
subtotal, discount, tax, shipping_fee, grand_total,
|
||||
items, status, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, 'invoice', :invoice_number, 0,
|
||||
:order_id, :contact_id, :issued_date, NULL,
|
||||
:subtotal, :discount, :tax, :shipping_fee, :grand_total,
|
||||
:items, 0, '', :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('invoice'),
|
||||
':order_id' => $order_id,
|
||||
':contact_id' => (int)$order['contact_id'],
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':subtotal' => $order['subtotal'],
|
||||
':discount' => $order['discount'],
|
||||
':tax' => $order['tax'],
|
||||
':shipping_fee' => $order['shipping_fee'],
|
||||
':grand_total' => $order['grand_total'],
|
||||
':items' => $order['items'], // already JSON string
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata on an existing draft invoice.
|
||||
*
|
||||
* Only allowed while status = 0 (draft).
|
||||
* Editable fields: due_date, notes.
|
||||
* Totals are not editable — they snapshot from the order.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, due_date, notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception If invoice not found or not in draft status.
|
||||
*/
|
||||
public function saveInvoice(array $data, array $logging): void
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Invoice not found.");
|
||||
}
|
||||
if ((int)$row['status'] !== 0) {
|
||||
throw new Exception("Only draft invoices can be edited.");
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET
|
||||
due_date = :due_date,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':due_date' => $data['due_date'] ?: null,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a draft invoice (status 0 → 1).
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception If not found or not draft.
|
||||
*/
|
||||
public function issueInvoice(int $id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Invoice not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft invoices can be issued.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'issue']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET
|
||||
status = 1,
|
||||
issued_date = :issued_date,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a credit note linked to a parent invoice.
|
||||
*
|
||||
* Called automatically by ReturnManager::approveReturn().
|
||||
* grand_total is stored as negative value for net-balance queries.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $ref_invoice_id Parent td_invoice.id
|
||||
* @param array $items Items being credited (subset of invoice items)
|
||||
* @param float $amount Credit amount (positive — stored as negative internally)
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_invoice.id (credit note)
|
||||
* @throws Exception If parent invoice not found or not issued.
|
||||
*/
|
||||
public function createCreditNote(int $ref_invoice_id, array $items, float $amount, array $logging): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id AND doc_type = 'invoice'"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $ref_invoice_id]);
|
||||
$parent = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$parent) {
|
||||
throw new Exception("Parent invoice not found.");
|
||||
}
|
||||
if ((int)$parent['status'] < 1) {
|
||||
throw new Exception("Cannot credit a draft invoice. Issue it first.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_invoice
|
||||
(company_id, uuid, doc_type, invoice_number, ref_invoice_id,
|
||||
order_id, contact_id, issued_date, due_date,
|
||||
subtotal, discount, tax, shipping_fee, grand_total,
|
||||
items, status, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, 'credit_note', :invoice_number, :ref_invoice_id,
|
||||
:order_id, :contact_id, :issued_date, NULL,
|
||||
:amount, 0, 0, 0, :grand_total,
|
||||
:items, 1, '', :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':invoice_number' => $this->generateInvoiceNumber('credit_note'),
|
||||
':ref_invoice_id' => $ref_invoice_id,
|
||||
':order_id' => (int)$parent['order_id'],
|
||||
':contact_id' => (int)$parent['contact_id'],
|
||||
':issued_date' => date('Y-m-d'),
|
||||
':amount' => $amount,
|
||||
':grand_total' => -abs($amount), // negative for net-balance queries
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Void an invoice (status → 4).
|
||||
*
|
||||
* Only allowed if no issued credit notes exist for this invoice,
|
||||
* or the sum of credit notes equals the full grand_total.
|
||||
*
|
||||
* @param int $id td_invoice.id
|
||||
* @param array $logging Audit entry.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function voidInvoice(int $id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Invoice not found.");
|
||||
if ((int)$row['status'] === 4) throw new Exception("Invoice is already void.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'void']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET status = 4, `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* OrderManager
|
||||
*
|
||||
* Handles all read and write operations for sales orders (td_order)
|
||||
* and their downstream stock-out effects on td_stock_<warehouse> tables.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
||||
* saveOrder, confirmOrder, cancelOrder
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Order items are stored as a JSON array in td_order.items.
|
||||
* No separate child table exists. SKU-level reporting is served by
|
||||
* td_stock_* rows (source='order') rather than querying items JSON.
|
||||
* - confirmOrder() picks racks via FIFO — oldest approved stock-in row
|
||||
* still rack-occupied, ordered by td_stock_<wh>.date ASC.
|
||||
* - confirmOrder() creates stock-out rows with status=0 (draft).
|
||||
* Warehouse staff approve them via the existing approve_stock.php
|
||||
* engine, which handles rack release and balance adjustment.
|
||||
* - cancelOrder() sets td_order.status = -1 and flips all linked
|
||||
* draft stock-out rows (status=0) to status=-1 (cancelled).
|
||||
* Approved stock-out rows block cancellation — caller must handle
|
||||
* these manually before cancelling.
|
||||
* - Cancel logic is intentionally self-contained here. status=-1 is
|
||||
* an order-domain concept with no rack/balance side effects, so
|
||||
* WarehouseManager and StockManager are not involved.
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are sanitised with
|
||||
* preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation —
|
||||
* no user input ever reaches a table identifier directly.
|
||||
*/
|
||||
class OrderManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a standard audit log entry array for append-to-JSON log columns.
|
||||
*
|
||||
* @param string $action Short label e.g. 'create', 'update', 'confirm', 'cancel'.
|
||||
* @return array
|
||||
*/
|
||||
private function buildLogEntry(string $action): array {
|
||||
return [
|
||||
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||||
'dt' => date('Y-m-d H:i:s'),
|
||||
'login' => isset($_SESSION['otpTime'])
|
||||
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
||||
: null,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<warehouse> table name for a warehouse_id.
|
||||
*
|
||||
* Does not filter by status — allows reading inactive warehouses
|
||||
* for historical order lookups.
|
||||
*
|
||||
* @param int $warehouse_id
|
||||
* @return string Sanitised table name e.g. "td_stock_Main".
|
||||
* @throws Exception If warehouse not found.
|
||||
*/
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
}
|
||||
|
||||
/**
|
||||
* FIFO rack pick — find the oldest approved stock-in row for a SKU
|
||||
* in a given warehouse that is still rack-occupied.
|
||||
*
|
||||
* "Oldest" = smallest date value among status=1 stock-in rows
|
||||
* that have an md_rack row pointing back to them (td_stock_id IS set).
|
||||
*
|
||||
* @param int $warehouse_id
|
||||
* @param string $product_sku
|
||||
* @return array|null Full td_stock row + zone/aisle/rack from md_rack,
|
||||
* or null if no available stock in this warehouse.
|
||||
*/
|
||||
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
|
||||
{
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack
|
||||
FROM `{$table}` s
|
||||
INNER JOIN md_rack r
|
||||
ON r.company_id = s.company_id
|
||||
AND r.warehouse = :warehouse_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.status = 1
|
||||
ORDER BY s.date ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_id' => $warehouse_id,
|
||||
':product_sku' => $product_sku,
|
||||
]);
|
||||
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the next sequential order number in ORD-YYYYMMDD-XXXX format.
|
||||
*
|
||||
* Finds the highest sequence number already used today and increments by 1.
|
||||
* Thread-safe enough for single-company use; wrap in transaction if needed.
|
||||
*
|
||||
* @return string e.g. "ORD-20260502-0001"
|
||||
*/
|
||||
private function generateOrderNumber(): string
|
||||
{
|
||||
$prefix = 'ORD-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT order_number FROM td_order
|
||||
WHERE company_id = :company_id
|
||||
AND order_number LIKE :prefix
|
||||
ORDER BY order_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all orders for the company, ordered by created_at DESC.
|
||||
*
|
||||
* Joins md_contact for contact_name display.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getOrderList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT o.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_order o
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = o.company_id
|
||||
AND c.id = o.contact_id
|
||||
WHERE o.company_id = :company_id
|
||||
ORDER BY o.created_at DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single order row by its primary key.
|
||||
*
|
||||
* Returns the row with items decoded as a PHP array.
|
||||
*
|
||||
* @param int $id td_order.id
|
||||
* @return array|false
|
||||
*/
|
||||
public function getOrderById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT o.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_order o
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = o.company_id
|
||||
AND c.id = o.contact_id
|
||||
WHERE o.company_id = :company_id
|
||||
AND o.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new order (draft) or update metadata on an existing one.
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* - Generates order_number.
|
||||
* - Stores items as JSON array.
|
||||
* - Calculates grand_total from items + adjustments.
|
||||
* - Sets status = 0 (draft), created_at = now().
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Only allowed while status = 0 (draft).
|
||||
* - Updates contact, date, items, totals, notes.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, order_date, items (array),
|
||||
* discount, tax, shipping_fee, notes.
|
||||
* @param array $logging Audit entry to append to log column.
|
||||
* @return int New td_order.id on insert, 0 on update.
|
||||
* @throws Exception If updating a non-draft order.
|
||||
*/
|
||||
public function saveOrder(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
// Calculate totals from items
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
// Fetch existing row to check status and load log
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception("Order not found.");
|
||||
}
|
||||
if ((int)$row['status'] !== 0) {
|
||||
throw new Exception("Only draft orders can be edited.");
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
contact_id = :contact_id,
|
||||
order_date = :order_date,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_order
|
||||
(company_id, uuid, order_number, contact_id, order_date,
|
||||
status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, grand_total, items, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :order_number, :contact_id, :order_date,
|
||||
0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':order_number' => $this->generateOrderNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a draft order — create stock-out rows (status=0) for each item
|
||||
* using FIFO rack selection, then advance the order to status=1.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
|
||||
* 2. INSERT into td_stock_<wh> with type='out', status=0,
|
||||
* source='order', source_id=order_id.
|
||||
* 3. Write back stock_out_id into the item object in td_order.items.
|
||||
*
|
||||
* After all items are processed:
|
||||
* 4. UPDATE td_order.items with stock_out_id values written back.
|
||||
* 5. UPDATE td_order.status = 1 (confirmed).
|
||||
*
|
||||
* Rack release and balance adjustment are intentionally deferred —
|
||||
* they happen when warehouse staff approve the stock-out rows via
|
||||
* the existing approve_stock.php engine (StockManager::approveStock).
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id to confirm.
|
||||
* @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}.
|
||||
* @param array $logging Audit entry appended to td_order.log.
|
||||
* @throws Exception If order not found, not in draft status, or any item
|
||||
* has no available FIFO rack in its assigned warehouse.
|
||||
*/
|
||||
public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
||||
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$order) {
|
||||
throw new Exception("Order not found.");
|
||||
}
|
||||
if ((int)$order['status'] !== 0) {
|
||||
throw new Exception("Only draft orders can be confirmed.");
|
||||
}
|
||||
|
||||
$items = json_decode($order['items'] ?? '[]', true) ?: [];
|
||||
|
||||
if (empty($items)) {
|
||||
throw new Exception("Cannot confirm an order with no items.");
|
||||
}
|
||||
|
||||
// ── Per-item: FIFO pick + insert stock-out row ────────────────────
|
||||
foreach ($items as $i => &$item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception(
|
||||
"Item #{$i}: missing warehouse_id or product_sku."
|
||||
);
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
|
||||
if (!$rack_stock) {
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"No available stock for \"{$name}\" in the selected warehouse."
|
||||
);
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$item_uuid = $uuid . '_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
ref_id, lot_number, serial_number,
|
||||
source, source_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'out',
|
||||
:ref_id, :lot_number, :serial_number,
|
||||
'order', :source_id, :price, 0)"
|
||||
)->execute([
|
||||
':uuid' => $item_uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => (float)$item['quantity'],
|
||||
':zone' => $rack_stock['zone'],
|
||||
':aisle' => $rack_stock['aisle'],
|
||||
':rack' => $rack_stock['rack'],
|
||||
':contact_id' => (int)$order['contact_id'],
|
||||
':description' => $order['order_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':ref_id' => (int)$rack_stock['id'],
|
||||
':lot_number' => $rack_stock['lot_number'] ?? '',
|
||||
':serial_number' => $rack_stock['serial_number'] ?? '',
|
||||
':source_id' => $order_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
]);
|
||||
|
||||
// Write rack context + stock_out_id back into the item object
|
||||
$item['zone'] = $rack_stock['zone'];
|
||||
$item['aisle'] = $rack_stock['aisle'];
|
||||
$item['rack'] = $rack_stock['rack'];
|
||||
$item['lot_number'] = $rack_stock['lot_number'] ?? '';
|
||||
$item['serial_number'] = $rack_stock['serial_number'] ?? '';
|
||||
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Auto-approve: immediately release rack + adjust balance
|
||||
if ($auto_approve) {
|
||||
$stock = new StockManager($this->pdo, $this->company_id);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
$stock->approveStock($item['stock_out_id'], $warehouse_id, 'out', $whMgmt);
|
||||
}
|
||||
}
|
||||
unset($item); // break reference
|
||||
|
||||
// ── Update order: items (with stock_out_id) + status=1 ───────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
status = 1,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order and flip all linked draft stock-out rows to status=-1.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load the order — must be status=0 (draft) or status=1 (confirmed).
|
||||
* Orders at status=2 (processing) or status=3 (completed) cannot be
|
||||
* cancelled here; they require manual stock reversal first.
|
||||
* 2. Check that no linked stock-out rows are already approved (status=1).
|
||||
* If any are approved, throw — those must be deleted via the existing
|
||||
* deleteStockOut() flow before cancellation can proceed.
|
||||
* 3. UPDATE all td_stock_<wh> rows where
|
||||
* source='order' AND source_id=order_id AND status=0 → status=-1.
|
||||
* 4. UPDATE td_order.status = -1.
|
||||
*
|
||||
* Cancel logic is intentionally self-contained — status=-1 rows have no
|
||||
* rack/balance side effects so WarehouseManager is not involved.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id to cancel.
|
||||
* @param array $logging Audit entry appended to td_order.log.
|
||||
* @throws Exception If order not found, already cancelled, in a non-cancellable
|
||||
* status, or has approved stock-out rows.
|
||||
*/
|
||||
public function cancelOrder(int $order_id, array $logging): void
|
||||
{
|
||||
// ── Load order ────────────────────────────────────────────────────
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
||||
$order = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$order) {
|
||||
throw new Exception("Order not found.");
|
||||
}
|
||||
|
||||
$status = (int)$order['status'];
|
||||
|
||||
if ($status === -1) {
|
||||
throw new Exception("Order is already cancelled.");
|
||||
}
|
||||
if ($status >= 2) {
|
||||
throw new Exception(
|
||||
"Cannot cancel an order that is processing or completed. " .
|
||||
"Please reverse stock movements manually first."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Guard: no approved stock-out rows ─────────────────────────────
|
||||
// Discover all td_stock_* tables and check for approved rows
|
||||
// linked to this order before making any changes.
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
AND status = 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_id,
|
||||
]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot cancel — some stock-out rows for this order are already " .
|
||||
"approved. Please delete them from the Stock Out page first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flip draft stock-out rows to cancelled ────────────────────────
|
||||
foreach ($tables as $table) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
AND status = 0"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Cancel the order ──────────────────────────────────────────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'cancel']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
status = -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ReturnManager
|
||||
*
|
||||
* Handles all read and write operations for td_return.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
|
||||
* saveReturn, confirmReturn
|
||||
*
|
||||
* Key design decisions:
|
||||
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
|
||||
* - confirmReturn() is the single restock path for all customer returns:
|
||||
* 1. INSERT td_stock_<wh> in rows (source='return', source_id=return_id,
|
||||
* status=1 ALWAYS — confirming a return is a final business decision,
|
||||
* no separate warehouse approval step needed).
|
||||
* 2. occupyRack() + adjustBalance() via WarehouseManager.
|
||||
* 3. If auto_invoice_and_credit_note=1 → InvoiceManager::createCreditNote()
|
||||
* auto-generates a CN linked to the original invoice (status=1 issued).
|
||||
* If =0 → user creates CN manually from the return detail page.
|
||||
* - Return items stored as JSON in td_return.items (same pattern as td_order).
|
||||
* - Each item references stock_out_id from the original order so the correct
|
||||
* warehouse/rack/lot context is known for restock.
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names are sanitised before interpolation.
|
||||
*/
|
||||
class ReturnManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildLogEntry(string $action): array {
|
||||
return [
|
||||
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||||
'dt' => date('Y-m-d H:i:s'),
|
||||
'login' => isset($_SESSION['otpTime'])
|
||||
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
||||
: null,
|
||||
'action' => $action,
|
||||
];
|
||||
}
|
||||
|
||||
private function generateReturnNumber(): string
|
||||
{
|
||||
$prefix = 'RET-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT return_number FROM td_return
|
||||
WHERE company_id = :company_id
|
||||
AND return_number LIKE :prefix
|
||||
ORDER BY return_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all return requests for the company ordered by id DESC.
|
||||
*
|
||||
* @param int $order_id Filter by td_order.id (0 = all)
|
||||
* @return array
|
||||
*/
|
||||
public function getReturnList(int $order_id = 0): array
|
||||
{
|
||||
$where = ['r.company_id = :company_id'];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($order_id > 0) {
|
||||
$where[] = 'r.order_id = :order_id';
|
||||
$params[':order_id'] = $order_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = r.company_id
|
||||
AND o.id = r.order_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single return by id, with items decoded.
|
||||
*
|
||||
* @param int $id td_return.id
|
||||
* @return array|false
|
||||
*/
|
||||
public function getReturnById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number
|
||||
FROM td_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = r.company_id
|
||||
AND o.id = r.order_id
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new return request (status=0 draft) or update an existing draft.
|
||||
*
|
||||
* items JSON array — each element mirrors td_order.items with stock_out_id
|
||||
* referencing the original stock-out row so confirmReturn() knows which
|
||||
* rack/warehouse/lot to restock.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param array $data Keys: id, order_id, invoice_id, contact_id,
|
||||
* return_date, reason, items (array), notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_return.id on insert, 0 on update.
|
||||
* @throws Exception If updating a non-draft return.
|
||||
*/
|
||||
public function saveReturn(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$refund_amount = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Return not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be edited.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET
|
||||
contact_id = :contact_id,
|
||||
return_date = :return_date,
|
||||
reason = :reason,
|
||||
items = :items,
|
||||
refund_amount = :refund_amount,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':refund_amount' => $refund_amount,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_return
|
||||
(company_id, uuid, return_number, order_id, invoice_id,
|
||||
contact_id, return_date, status,
|
||||
reason, refund_amount, items, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, :return_number, :order_id, :invoice_id,
|
||||
:contact_id, :return_date, 0,
|
||||
:reason, :refund_amount, :items, :notes, :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':order_id' => (int)($data['order_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode([$logging]),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a return — restock items back to warehouse + optionally create credit note.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. INSERT td_stock_<wh> in row with type='in', status=1 (force confirmed),
|
||||
* source='return', source_id=return_id.
|
||||
* 2. occupyRack() — place returned stock back into the original rack.
|
||||
* 3. adjustBalance() — update warehouse_balance.
|
||||
*
|
||||
* After all items:
|
||||
* 4. UPDATE td_return.status = 1 (confirmed).
|
||||
* 5. If auto_invoice_and_credit_note=true AND invoice_id > 0:
|
||||
* InvoiceManager::createCreditNote() — auto CN, status=1 (issued).
|
||||
* If false: CN must be created manually from the return detail page.
|
||||
*
|
||||
* Stock-in rows are ALWAYS status=1 regardless of default_stock_status config.
|
||||
* Confirming a return is a final business decision — no warehouse re-approval needed.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $return_id td_return.id to confirm.
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param object $whMgmt WarehouseManager instance.
|
||||
* @param object $invMgmt InvoiceManager instance.
|
||||
* @param bool $auto_cn Whether to auto-create credit note.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirmReturn(
|
||||
int $return_id,
|
||||
string $uuid,
|
||||
array $logging,
|
||||
$whMgmt,
|
||||
$invMgmt,
|
||||
bool $auto_cn = false
|
||||
): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$return) throw new Exception("Return request not found.");
|
||||
if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled.");
|
||||
|
||||
$items = json_decode($return['items'] ?? '[]', true) ?: [];
|
||||
if (empty($items)) throw new Exception("Cannot confirm a return with no items.");
|
||||
|
||||
// ── Per-item: restock ─────────────────────────────────────────────
|
||||
foreach ($items as $i => $item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception("Item #{$i}: missing warehouse_id or product_sku.");
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$item_uuid = $uuid . '_ret_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
// INSERT stock-in row — status=1 forced
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `in`,
|
||||
zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
lot_number, serial_number,
|
||||
source, source_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'in',
|
||||
:lot_number, :serial_number,
|
||||
'return', :source_id, :price, 1)"
|
||||
)->execute([
|
||||
':uuid' => $item_uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => $quantity,
|
||||
':zone' => $item['zone'] ?? '',
|
||||
':aisle' => $item['aisle'] ?? '',
|
||||
':rack' => $item['rack'] ?? '',
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':lot_number' => $item['lot_number'] ?? '',
|
||||
':serial_number' => $item['serial_number'] ?? '',
|
||||
':source_id' => $return_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
]);
|
||||
|
||||
$stock_in_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Occupy rack + adjust balance — forced since status=1
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$item['zone'] ?? '',
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
|
||||
// ── Update return status → 1 (confirmed) ─────────────────────────
|
||||
$log = json_decode($return['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET status = 1, `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $return_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// ── Auto credit note ──────────────────────────────────────────────
|
||||
$invoice_id = (int)$return['invoice_id'];
|
||||
|
||||
if ($auto_cn && $invoice_id > 0) {
|
||||
$invMgmt->createCreditNote(
|
||||
$invoice_id,
|
||||
$items,
|
||||
(float)$return['refund_amount'],
|
||||
array_merge($logging, ['action' => 'auto_credit_note'])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -8,11 +8,49 @@
|
||||
|
||||
$stats = $report->getDashboardStats($month);
|
||||
|
||||
// Sales stats — query td_order and td_invoice directly
|
||||
$month_start = $month . '-01';
|
||||
$month_end = date('Y-m-t', strtotime($month_start));
|
||||
|
||||
// Total orders this month (excluding cancelled)
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*), COALESCE(SUM(grand_total), 0)
|
||||
FROM td_order
|
||||
WHERE company_id = :company_id
|
||||
AND status != -1
|
||||
AND order_date BETWEEN :start AND :end"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':start' => $month_start, ':end' => $month_end]);
|
||||
[$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM);
|
||||
|
||||
// Unpaid invoices (status=0 draft + status=1 issued + status=3 overdue)
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND doc_type = 'invoice'
|
||||
AND status IN (0, 1, 3)"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id]);
|
||||
$unpaid_invoices = (int)$sth->fetchColumn();
|
||||
|
||||
// Pending returns (status=0 draft)
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM td_return
|
||||
WHERE company_id = :company_id
|
||||
AND status = 0"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id]);
|
||||
$pending_returns = (int)$sth->fetchColumn();
|
||||
|
||||
$answer['output'] = [
|
||||
'total_in' => $stats['total_in'],
|
||||
'total_out' => $stats['total_out'],
|
||||
'active_products' => $stats['active_products'],
|
||||
'low_stock' => $report->getDashboardLowStockCount(),
|
||||
'total_orders' => (int)$total_orders,
|
||||
'total_revenue' => (float)$total_revenue,
|
||||
'unpaid_invoices' => $unpaid_invoices,
|
||||
'pending_returns' => $pending_returns,
|
||||
'stock_movement' => $report->getStockMovementChart(12),
|
||||
'most_moved' => $report->getMostMovedProducts($month),
|
||||
'low_stock_items' => $report->getLowStockItems(),
|
||||
|
||||
+43
-50
@@ -22,9 +22,10 @@
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="mb-0 text-nowrap fw-semibold">Month</label>
|
||||
<select id="month_filter" class="form-select" style="min-width:160px;">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
<div class="input-group" style="min-width:160px;">
|
||||
<span class="input-group-text"><i class="ti ti-calendar"></i></span>
|
||||
<input type="text" id="month_filter" class="form-control" readonly placeholder="Select month" style="cursor:pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,38 +71,39 @@
|
||||
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2" style="position:relative;">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-success text-white rounded-2">
|
||||
<i class="ti ti-package fs-4"></i>
|
||||
<i class="ti ti-shopping-cart fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Active Products</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_active_products">—</h3>
|
||||
<p class="text-success mb-0 small">SKUs with movement</p>
|
||||
<h2 class="mb-3 fs-6">Total Orders</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_total_orders">—</h3>
|
||||
<p class="text-success mb-0 small">Orders this month</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-loader-overlay" id="overlay_active">
|
||||
<div class="card-loader-overlay" id="overlay_orders">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2" style="position:relative;">
|
||||
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2" style="position:relative;">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-warning text-white rounded-2">
|
||||
<i class="ti ti-alert-triangle fs-4"></i>
|
||||
<div class="icon-shape icon-md bg-info text-white rounded-2">
|
||||
<i class="ti ti-currency-baht fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Low Stock Alerts</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_low_stock">—</h3>
|
||||
<p class="text-warning mb-0 small">Products below min stock</p>
|
||||
<h2 class="mb-3 fs-6">Total Revenue</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_total_revenue">—</h3>
|
||||
<p class="text-info mb-0 small">Grand total this month</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-loader-overlay" id="overlay_low">
|
||||
<div class="card-loader-overlay" id="overlay_revenue">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Stock Movement Chart -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-12">
|
||||
@@ -182,7 +184,7 @@
|
||||
|
||||
// ── Show all overlays ─────────────────────────────────────────────────
|
||||
function show_overlays() {
|
||||
$('#overlay_in, #overlay_out, #overlay_active, #overlay_low, #overlay_chart, #overlay_moved, #overlay_low_list, #overlay_activity').show();
|
||||
$('#overlay_in, #overlay_out, #overlay_orders, #overlay_revenue, #overlay_chart, #overlay_moved, #overlay_low_list, #overlay_activity').show();
|
||||
}
|
||||
|
||||
|
||||
@@ -192,9 +194,9 @@
|
||||
// Stats
|
||||
$('#stat_total_in').text(format_number(d.total_in, 2));
|
||||
$('#stat_total_out').text(format_number(d.total_out, 2));
|
||||
$('#stat_active_products').text(format_number(d.active_products));
|
||||
$('#stat_low_stock').text(format_number(d.low_stock));
|
||||
$('#overlay_in, #overlay_out, #overlay_active, #overlay_low').hide();
|
||||
$('#stat_total_orders').text(format_number(d.total_orders));
|
||||
$('#stat_total_revenue').text(format_number(d.total_revenue, 2));
|
||||
$('#overlay_in, #overlay_out, #overlay_orders, #overlay_revenue').hide();
|
||||
|
||||
// Chart
|
||||
var mv = d.stock_movement;
|
||||
@@ -308,7 +310,10 @@
|
||||
// ── Fetch + render ────────────────────────────────────────────────────
|
||||
function retrieve_dashboard() {
|
||||
|
||||
var month = $('#month_filter').val() || '<?php echo date('Y-m'); ?>';
|
||||
var fpDate = monthPicker.selectedDates[0];
|
||||
var month = fpDate
|
||||
? fpDate.getFullYear() + '-' + String(fpDate.getMonth() + 1).padStart(2, '0')
|
||||
: '<?php echo date('Y-m'); ?>';
|
||||
show_overlays();
|
||||
|
||||
return ajax_request({
|
||||
@@ -344,45 +349,33 @@
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
var monthPicker = flatpickr('#month_filter', {
|
||||
plugins: [
|
||||
new monthSelectPlugin({
|
||||
shorthand: true,
|
||||
dateFormat: 'M Y',
|
||||
altFormat: 'M Y',
|
||||
theme: 'light'
|
||||
})
|
||||
],
|
||||
disableMobile: true,
|
||||
onChange: function() {
|
||||
retrieve_dashboard();
|
||||
}
|
||||
});
|
||||
monthPicker.setDate('<?php echo date('M Y'); ?>', false); // false = don't trigger onChange
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
var month = '<?php echo date('Y-m'); ?>';
|
||||
show_overlays();
|
||||
|
||||
await ajax_request({
|
||||
url: "<?php echo $server_url?>dashboard/api/engine_report/reports_stats.php",
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { "month": month },
|
||||
onSuccess: async function(res) {
|
||||
|
||||
// Populate month picker
|
||||
var option = '';
|
||||
var months = res.output.months || [month];
|
||||
$.each(months, function(i, m) {
|
||||
var sel = (m === month) ? 'selected' : '';
|
||||
option += `<option value="${m}" ${sel}>${format_month(m)}</option>`;
|
||||
});
|
||||
if (!months.length) {
|
||||
option = `<option value="${month}">${format_month(month)}</option>`;
|
||||
}
|
||||
$('#month_filter').html(option);
|
||||
|
||||
await render(res.output, month);
|
||||
}
|
||||
});
|
||||
|
||||
await retrieve_dashboard();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ── Month filter change ───────────────────────────────────────────────
|
||||
$('#month_filter').on('change', function() {
|
||||
retrieve_dashboard();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
// ── Boot ─────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouse();
|
||||
await retrieve_for_edit();
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
|
||||
<!-- flatpickr monthSelect plugin -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/plugins/monthSelect/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/plugins/monthSelect/index.js"></script>
|
||||
|
||||
<!-- autocomplete -->
|
||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.1/themes/base/jquery-ui.css">
|
||||
<script src="https://code.jquery.com/ui/1.14.1/jquery-ui.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
$current_page = basename($_SERVER['PHP_SELF']);
|
||||
?>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="sidebar overflow-y-auto d-flex flex-column">
|
||||
<div class="logo-area">
|
||||
<a href="<?php echo $server_url?>index.php" class="d-inline-flex">
|
||||
<img
|
||||
src="data:image/svg+xml,%3csvg%20width='62'%20height='67'%20viewBox='0%200%2062%2067'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M30.604%2066.378L0.00805664%2048.1582V35.7825L30.604%2054.0023V66.378Z'%20fill='%23302C4D'/%3e%3cpath%20d='M61.1996%2048.1582L30.604%2066.378V54.0023L61.1996%2035.7825V48.1582Z'%20fill='%23E66239'/%3e%3cpath%20d='M30.5955%200L0%2018.2198V30.5955L30.5955%2012.3757V0Z'%20fill='%23657E92'/%3e%3cpath%20d='M61.191%2018.2198L30.5955%200V12.3757L61.191%2030.5955V18.2198Z'%20fill='%23A3B2BE'/%3e%3cpath%20d='M30.604%2048.8457L0.00805664%2030.6259V18.2498L30.604%2036.47V48.8457Z'%20fill='%23302C4D'/%3e%3cpath%20d='M61.1996%2030.6259L30.604%2048.8457V36.47L61.1996%2018.2498V30.6259Z'%20fill='%23E66239'/%3e%3c/svg%3e"
|
||||
alt=""
|
||||
width="24"
|
||||
/>
|
||||
<span class="logo-text ms-2">
|
||||
<img src="<?php echo $server_url?>assets/images/logo.svg" alt="" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="nav flex-column">
|
||||
|
||||
<!-- Master Data -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Master Data</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo in_array($current_page, ['product.php', 'manage_product.php', 'manage_category.php']) ? 'active' : ''; ?>"
|
||||
href="<?php echo $server_url?>inventory/product.php">
|
||||
<i class="ti ti-category"></i>
|
||||
<span class="nav-text">Products</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo in_array($current_page, ['warehouse.php', 'manage_warehouse.php', 'manage_storage.php']) ? 'active' : ''; ?>"
|
||||
href="<?php echo $server_url?>inventory/warehouse.php">
|
||||
<i class="ti ti-map-pin"></i>
|
||||
<span class="nav-text">Warehouse Location</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo in_array($current_page, ['contact.php', 'manage_contact.php', 'manage_contact_type.php']) ? 'active' : ''; ?>"
|
||||
href="<?php echo $server_url?>contact/contact.php">
|
||||
<i class="ti ti-users"></i>
|
||||
<span class="nav-text">Contact</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Navigation -->
|
||||
<li class="nav-text-space mt-2">
|
||||
<small class="nav-text text-muted">Navigation</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>dashboard/index.php">
|
||||
<i class="ti ti-arrow-left"></i>
|
||||
<span class="nav-text">Back to App</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</aside>
|
||||
+26
-26
@@ -34,32 +34,6 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Master Data -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Master Data</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>inventory/product.php">
|
||||
<i class="ti ti-category"></i>
|
||||
<span class="nav-text">Products</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>inventory/warehouse.php">
|
||||
<i class="ti ti-map-pin"></i>
|
||||
<span class="nav-text">Warehouse Location</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>contact/contact.php">
|
||||
<i class="ti ti-users"></i>
|
||||
<span class="nav-text">Contact</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Inventory / Stock -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Inventory / Stock</small>
|
||||
@@ -93,6 +67,32 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Orders & Sales -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Orders & Sales</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>order/order.php">
|
||||
<i class="ti ti-shopping-cart"></i>
|
||||
<span class="nav-text">Orders</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>order/return.php">
|
||||
<i class="ti ti-arrow-back-up"></i>
|
||||
<span class="nav-text">Returns</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>order/invoice.php">
|
||||
<i class="ti ti-file-invoice"></i>
|
||||
<span class="nav-text">Invoices</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Reports -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Reports</small>
|
||||
|
||||
+12
-5
@@ -76,9 +76,13 @@ $topbar_avatar_src = $topbar_picture
|
||||
|
||||
<!-- User dropdown -->
|
||||
<li class="ms-3 dropdown">
|
||||
<a href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<img src="<?php echo $topbar_avatar_src; ?>" alt=""
|
||||
class="avatar avatar-sm rounded-circle" />
|
||||
<a class="position-relative btn-icon btn-sm btn-primary btn rounded-circle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0" style="min-width: 200px;">
|
||||
<div>
|
||||
@@ -97,6 +101,9 @@ $topbar_avatar_src = $topbar_picture
|
||||
<a href="<?php echo $server_url?>setting/profile.php">
|
||||
<span>Setting</span>
|
||||
</a>
|
||||
<a href="<?php echo $server_url?>inventory/product.php">
|
||||
<span>Master Data</span>
|
||||
</a>
|
||||
<a href="javascript:;" onclick="log_out();">
|
||||
<span>Log Out</span>
|
||||
</a>
|
||||
@@ -158,8 +165,8 @@ function switch_branch() {
|
||||
'<span class="d-flex align-items-center justify-content-center rounded-circle bg-label-primary flex-shrink-0" style="width:36px;height:36px;">' +
|
||||
'<i class="ti ti-building small"></i></span>' +
|
||||
'<div class="flex-grow-1 overflow-hidden">' +
|
||||
'<p class="mb-0 small fw-semibold text-truncate">' + $('<span>').text(c.company_name).html() + '</p>' +
|
||||
'<p class="mb-0 x-small text-muted">' + $('<span>').text(c.branch || c.role).html() + '</p>' +
|
||||
'<p class="mb-0 small fw-semibold text-truncate">' + $('<span>').text(c.channel_name).html() + '</p>' +
|
||||
'<p class="mb-0 x-small text-muted">' + $('<span>').text(c.company_name).html() + $('<span>').text(c.branch || c.role).html() + '</p>' +
|
||||
'</div>' +
|
||||
(is_active ? '<i class="ti ti-check text-success ms-auto"></i>' : '') +
|
||||
'</a>';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php';?>
|
||||
<?php require '../include_sidebar.php';?>
|
||||
<?php require '../include_master_sidebar.php';?>
|
||||
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid order ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$order->cancelOrder($id, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Order cancelled.';
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id) {
|
||||
$sth = $pdo->prepare(
|
||||
"SELECT status FROM td_return
|
||||
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("Return not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be cancelled.");
|
||||
|
||||
$pdo->prepare(
|
||||
"UPDATE td_return SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $id, ':company_id' => $company_id]);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Return cancelled.';
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
require '../../../assets/utils/classes/StockManager.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid order ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||
$auto_approve = (int)$csm->get('default_stock_status') === 1;
|
||||
$auto_invoice = (int)$csm->get('auto_invoice_and_credit_note') === 1;
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_approve, $auto_invoice) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$order->confirmOrder($id, $uuid, $logging, $auto_approve);
|
||||
|
||||
if ($auto_invoice) {
|
||||
$invMgmt = new InvoiceManager($pdo, $company_id);
|
||||
$invMgmt->createFromOrder($id, $logging);
|
||||
}
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Order confirmed.';
|
||||
$answer['auto_approved'] = $auto_approve;
|
||||
$answer['auto_invoice'] = $auto_invoice;
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ReturnManager.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||
$auto_cn = (int)$csm->get('auto_invoice_and_credit_note') === 1;
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_cn) {
|
||||
$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);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Return confirmed.';
|
||||
$answer['auto_cn'] = $auto_cn;
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
|
||||
$inv = new InvoiceManager($pdo, $company_id);
|
||||
$inv->issueInvoice($id, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Invoice issued.';
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging) {
|
||||
$inv = new InvoiceManager($pdo, $company_id);
|
||||
$inv->saveInvoice($data, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
|
||||
// Decode items JSON string back to array
|
||||
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
|
||||
try {
|
||||
$new_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$new_id = $order->saveOrder($data, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
if ($new_id) {
|
||||
$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));
|
||||
?>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ReturnManager.php';
|
||||
|
||||
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
|
||||
try {
|
||||
$new_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
|
||||
$ret = new ReturnManager($pdo, $company_id);
|
||||
$new_id = $ret->saveReturn($data, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
if ($new_id) {
|
||||
$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));
|
||||
?>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
$order_id = (int)($data['order_id'] ?? 0);
|
||||
|
||||
if (!$order_id) {
|
||||
$answer['message'] = 'Invalid order ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
$new_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($order_id, $company_id, $logging, &$new_id) {
|
||||
$inv = new InvoiceManager($pdo, $company_id);
|
||||
$new_id = $inv->createFromOrder($order_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));
|
||||
?>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
$inv = new InvoiceManager($pdo2, $company_id);
|
||||
$id = (int)($data['invoice_id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
$result = $inv->getInvoiceById($id);
|
||||
if (!$result) {
|
||||
http_response_code(404);
|
||||
$answer['message'] = 'Invoice not found.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
$answer['output'] = $result;
|
||||
|
||||
// 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);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
|
||||
$order = new OrderManager($pdo2, $company_id);
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
$result = $order->getOrderById($id);
|
||||
if (!$result) {
|
||||
http_response_code(404);
|
||||
$answer['message'] = 'Order not found.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
$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) {
|
||||
$returnable[] = array_merge($item, [
|
||||
'item_id' => $i,
|
||||
'original_qty' => (float)$item['quantity'],
|
||||
'returned_qty' => $already_returned,
|
||||
'returnable_qty' => $remaining,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$answer['returnable_items'] = $returnable;
|
||||
} else {
|
||||
$answer['output'] = $order->getOrderList();
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/ReturnManager.php';
|
||||
|
||||
$ret = new ReturnManager($pdo2, $company_id);
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
$result = $ret->getReturnById($id);
|
||||
if (!$result) {
|
||||
http_response_code(404);
|
||||
$answer['message'] = 'Return not found.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
$answer['output'] = $result;
|
||||
} else {
|
||||
$order_id = (int)($data['order_id'] ?? 0);
|
||||
$answer['output'] = $ret->getReturnList($order_id);
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
$id = (int)($data['invoice_id'] ?? 0);
|
||||
$status = (int)($data['status'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid invoice ID.';
|
||||
http_response_code(400);
|
||||
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)) {
|
||||
$answer['message'] = 'Invalid status value.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Invoice 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));
|
||||
?>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
|
||||
$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 3=refunded
|
||||
if (!in_array($payment_status, [0, 1, 2, 3], 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));
|
||||
?>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging) {
|
||||
$inv = new InvoiceManager($pdo, $company_id);
|
||||
$inv->voidInvoice($id, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Invoice voided.';
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
require '../../../assets/utils/classes/StockManager.php';
|
||||
require '../../../assets/utils/classes/WarehouseManager.php';
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
require '../../../assets/utils/classes/CompanySettingManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
$answer['message'] = 'Invalid order ID.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$csm = new CompanySettingManager($pdo1, $company_id);
|
||||
$auto_approve = (int)$csm->get('default_stock_status') === 1;
|
||||
$auto_invoice = (int)$csm->get('auto_invoice_and_credit_note') === 1;
|
||||
|
||||
try {
|
||||
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_approve, $auto_invoice) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$order->confirmOrder($id, $uuid, $logging, $auto_approve);
|
||||
|
||||
if ($auto_invoice) {
|
||||
$invMgmt = new InvoiceManager($pdo, $company_id);
|
||||
$invMgmt->createFromOrder($id, $logging);
|
||||
}
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Order confirmed.';
|
||||
$answer['auto_approved'] = $auto_approve;
|
||||
|
||||
} 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));
|
||||
?>
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="row g-5 mb-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="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search 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">
|
||||
<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="4">Void</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">Order #</th>
|
||||
<th class="py-3">Contact</th>
|
||||
<th class="py-3">Issued Date</th>
|
||||
<th class="py-3">Due Date</th>
|
||||
<th class="py-3">Grand Total</th>
|
||||
<th class="py-3">Status</th>
|
||||
<th class="py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
var current_tab = 'invoice';
|
||||
|
||||
|
||||
// ── Badges ────────────────────────────────────────────────────────────────
|
||||
function invoice_status_badge(status) {
|
||||
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)] ?? '—';
|
||||
}
|
||||
|
||||
|
||||
// ── 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 },
|
||||
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;
|
||||
});
|
||||
|
||||
if (current_tab === 'invoice') {
|
||||
render_invoice_table(data);
|
||||
} else {
|
||||
render_cn_table(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── 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 = '';
|
||||
|
||||
$.each(page_data, function(i, inv) {
|
||||
var status = parseInt(inv.status);
|
||||
var can_void = status >= 1 && status <= 3;
|
||||
|
||||
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">${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">
|
||||
<a href="<?php echo $server_url?>order/manage_invoice.php?id=${inv.id}" title="View">
|
||||
<i class="ti ti-eye fs-5"></i>
|
||||
</a>
|
||||
${can_void ? `
|
||||
<a href="javascript:void(0);" class="link-danger ms-2" title="Void"
|
||||
onclick="void_invoice(${inv.id})">
|
||||
<i class="ti ti-ban fs-5"></i>
|
||||
</a>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('table#invoice_list > tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Render credit note table ──────────────────────────────────────────────
|
||||
function render_cn_table(data) {
|
||||
alasql('DROP TABLE IF EXISTS cn_list');
|
||||
alasql('CREATE TABLE cn_list');
|
||||
alasql.tables.cn_list.data = data;
|
||||
|
||||
var page = generate_pagination('cn_list', data.length);
|
||||
$('table#cn_list tfoot').html(page);
|
||||
|
||||
if (typeof change_page_cn_list === 'function') {
|
||||
change_page_cn_list(1);
|
||||
}
|
||||
}
|
||||
|
||||
function change_page_cn_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var page_data = alasql(`SELECT * FROM cn_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
$.each(page_data, function(i, cn) {
|
||||
var amount = '<span class="text-danger">' + format_number(cn.grand_total, 2) + '</span>';
|
||||
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${cn.invoice_number}</td>
|
||||
<td class="py-3">${cn.order_number || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${cn.ref_invoice_number || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${cn.contact_name || '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${cn.issued_date ? format_date(cn.issued_date) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3 fw-semibold">${amount}</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>order/manage_invoice.php?id=${cn.id}" title="View">
|
||||
<i class="ti ti-eye fs-5"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('table#cn_list > tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Void invoice ──────────────────────────────────────────────────────────
|
||||
function void_invoice(id) {
|
||||
bootbox.confirm({
|
||||
message: '<strong>Void this invoice?</strong><br>This action cannot be undone.',
|
||||
buttons: {
|
||||
confirm: { label: 'Void Invoice', className: 'btn-danger' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/update_invoice_status.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { invoice_id: id, status: 4 },
|
||||
onSuccess: function() { retrieve_invoices(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
$(function() {
|
||||
retrieve_invoices();
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_invoices();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$invoice_id = (int)($_GET['id'] ?? 0);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1" id="page_title">Invoice Detail</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle"></p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>order/invoice.php" class="btn btn-light">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
|
||||
<!-- Left: Items table -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Items</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th class="text-end">Qty</th>
|
||||
<th class="text-end">Unit Price</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody"></tbody>
|
||||
<tfoot id="item_tfoot"></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked credit notes -->
|
||||
<div class="card d-none" id="cn_card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Credit Notes</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>CN #</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cn_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Info + actions -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Status + info -->
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">Document Info</h2>
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<span id="badge_doc_type"></span>
|
||||
<span id="badge_status"></span>
|
||||
</div>
|
||||
<div class="mb-2 small">
|
||||
<span class="text-muted">Order:</span>
|
||||
<a href="#" id="link_order" class="ms-1 fw-semibold"></a>
|
||||
</div>
|
||||
<div class="mb-2 small">
|
||||
<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">Issued:</span>
|
||||
<span class="ms-1" id="display_issued"></span>
|
||||
</div>
|
||||
<div class="mb-2 small">
|
||||
<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">Notes:</span>
|
||||
<textarea id="notes" class="form-control form-control-sm mt-1" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
|
||||
<button class="btn btn-primary w-100" id="btn_save"
|
||||
onclick="save_invoice()">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
var invoice_id = <?php echo $invoice_id; ?>;
|
||||
var invoice_data = null;
|
||||
|
||||
|
||||
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>',
|
||||
'debit_note': '<span class="badge bg-info text-white">Debit Note</span>',
|
||||
};
|
||||
return map[doc_type] ?? '—';
|
||||
}
|
||||
|
||||
function invoice_status_badge(status) {
|
||||
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)] ?? '—';
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
// Fields editable only on draft invoice
|
||||
$('#due_date, #notes').prop('disabled', !(is_draft && is_invoice));
|
||||
|
||||
// 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' : '';
|
||||
set_btn_state('#invoice_status_group button', can_change_status, status_hint);
|
||||
$('#invoice_status_select').prop('disabled', !can_change_status);
|
||||
|
||||
}
|
||||
|
||||
|
||||
function retrieve_invoice() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { invoice_id: invoice_id },
|
||||
onSuccess: function(res) {
|
||||
invoice_data = res.output;
|
||||
var inv = invoice_data;
|
||||
|
||||
$('#page_title').text(inv.invoice_number);
|
||||
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
|
||||
$('#badge_status').html(invoice_status_badge(inv.status));
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
if (inv.order_id) {
|
||||
$('#link_order')
|
||||
.attr('href', '<?php echo $server_url?>order/manage_order.php?id=' + inv.order_id)
|
||||
.text(inv.order_number || 'Order #' + inv.order_id);
|
||||
}
|
||||
|
||||
// Items
|
||||
var items = inv.items || [];
|
||||
var tbody = '';
|
||||
$.each(items, function(i, item) {
|
||||
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.total_price, 2)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('#item_tbody').html(tbody);
|
||||
|
||||
// Totals
|
||||
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>`;
|
||||
$('#item_tfoot').html(tfoot);
|
||||
|
||||
// Linked credit notes
|
||||
if (res.credit_notes && res.credit_notes.length) {
|
||||
var cn_rows = '';
|
||||
$.each(res.credit_notes, function(i, cn) {
|
||||
cn_rows += `<tr>
|
||||
<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><a href="<?php echo $server_url?>order/manage_invoice.php?id=${cn.id}"><i class="ti ti-eye fs-5"></i></a></td>
|
||||
</tr>`;
|
||||
});
|
||||
$('#cn_tbody').html(cn_rows);
|
||||
$('#cn_card').removeClass('d-none');
|
||||
}
|
||||
|
||||
set_mode(inv);
|
||||
$('#invoice_status_select').val(inv.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function save_invoice() {
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: {
|
||||
id: invoice_id,
|
||||
due_date: $('#due_date').val().split('/').reverse().join('-'),
|
||||
notes: $('#notes').val(),
|
||||
},
|
||||
onSuccess: function() { retrieve_invoice(); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function issue_invoice() {
|
||||
bootbox.confirm({
|
||||
message: 'Issue this invoice? It will be sent to the customer.',
|
||||
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] },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/update_invoice_status.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { invoice_id: invoice_id, status: status },
|
||||
onSuccess: function() { retrieve_invoice(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$(function() {
|
||||
if (invoice_id) {
|
||||
retrieve_invoice();
|
||||
}
|
||||
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,696 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$order_id = (int)($_GET['id'] ?? 0);
|
||||
$is_edit = isset($_GET['edit']) || $order_id === 0;
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1" id="page_title">
|
||||
<?php echo $order_id ? 'Order Detail' : 'New Order'; ?>
|
||||
</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle">
|
||||
<?php echo $order_id ? 'View or manage this order' : 'Create a new sales order'; ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2" id="header_actions">
|
||||
<a href="<?php echo $server_url?>order/order.php" class="btn btn-light">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
|
||||
<!-- Left: Order form -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Order header card -->
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Order Information</h2>
|
||||
<div class="row gx-5">
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Contact</label>
|
||||
<input type="text" id="contact" class="form-control contact_search"
|
||||
placeholder="Search contact..." autocomplete="off">
|
||||
<input type="hidden" id="contact_id">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Order Date</label>
|
||||
<input type="text" id="order_date" class="form-control"
|
||||
placeholder="DD/MM/YYYY" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea id="notes" class="form-control" rows="2"
|
||||
placeholder="Internal notes..."></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order items card -->
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fs-5 mb-0">Order Items</h2>
|
||||
<button class="btn btn-sm btn-outline-primary" id="btn_add_item"
|
||||
onclick="add_item_row()">
|
||||
<i class="ti ti-plus me-1"></i>Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0" id="item_table">
|
||||
<thead class="table-light">
|
||||
<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:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right: Summary + actions -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Status card (view mode only) -->
|
||||
<div class="card mb-5 d-none" id="status_card">
|
||||
<div class="card-body p-4">
|
||||
<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>
|
||||
</div>
|
||||
<p class="mb-0 small text-muted" id="order_number_display"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Totals card -->
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">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">Discount</span>
|
||||
<input type="number" id="discount" class="form-control form-control-sm text-end"
|
||||
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
|
||||
</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()">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<span class="text-muted">Shipping</span>
|
||||
<input type="number" id="shipping_fee" class="form-control form-control-sm text-end"
|
||||
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
|
||||
</div>
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between fw-bold fs-5">
|
||||
<span>Grand Total</span>
|
||||
<span id="summary_grand_total">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
|
||||
<!-- Save draft (edit mode) -->
|
||||
<button class="btn btn-primary w-100" id="btn_save"
|
||||
onclick="save_order()">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save Draft
|
||||
</button>
|
||||
|
||||
<!-- Confirm order (draft only) -->
|
||||
<?php if(isset($_GET["id"])){ ?>
|
||||
<button class="btn btn-success w-100" id="btn_confirm"
|
||||
onclick="confirm_order()">
|
||||
<i class="ti ti-check me-1"></i>Confirm Order
|
||||
</button>
|
||||
<?php } ?>
|
||||
|
||||
<!-- Proceed to invoice (confirmed, manual mode) -->
|
||||
<button class="btn btn-outline-primary w-100" id="btn_invoice"
|
||||
onclick="proceed_to_invoice()">
|
||||
<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice
|
||||
</button>
|
||||
|
||||
<!-- Update payment status (confirmed and above) -->
|
||||
<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>
|
||||
<option value="3">Refunded</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>
|
||||
|
||||
<!-- Create Return (confirmed and above) -->
|
||||
<button class="btn btn-outline-warning w-100" id="btn_return"
|
||||
onclick="create_return()">
|
||||
<i class="ti ti-arrow-back-up me-1"></i>Create Return
|
||||
</button>
|
||||
|
||||
<!-- Cancel order -->
|
||||
<button class="btn btn-outline-danger w-100" id="btn_cancel"
|
||||
onclick="cancel_order()">
|
||||
<i class="ti ti-x me-1"></i>Cancel Order
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
var order_id = <?php echo $order_id; ?>;
|
||||
var order_data = null;
|
||||
var warehouses = [];
|
||||
var item_index = 0;
|
||||
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────
|
||||
function order_status_badge(status) {
|
||||
const map = {
|
||||
'-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>',
|
||||
'3': '<span class="badge bg-secondary">Refunded</span>',
|
||||
};
|
||||
return map[String(status)] ?? '—';
|
||||
}
|
||||
|
||||
|
||||
// ── Recalculate totals ────────────────────────────────────────────────────
|
||||
function recalc_totals() {
|
||||
var subtotal = 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;
|
||||
$(this).find('.item_total').text(format_number(total, 2));
|
||||
subtotal += total;
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
$('#summary_subtotal').text(format_number(subtotal, 2));
|
||||
$('#summary_grand_total').text(format_number(grand_total, 2));
|
||||
}
|
||||
|
||||
|
||||
// ── Add item row ──────────────────────────────────────────────────────────
|
||||
function add_item_row(item) {
|
||||
item = item || {};
|
||||
var idx = item_index++;
|
||||
|
||||
var wh_options = warehouses.map(w =>
|
||||
`<option value="${w.id}" ${item.warehouse_id == w.id ? 'selected' : ''}>${w.warehouse_name}</option>`
|
||||
).join('');
|
||||
|
||||
var row = `<tr id="item_row_${idx}">
|
||||
<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}">
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm item_warehouse">
|
||||
${wh_options}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_qty"
|
||||
value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_price"
|
||||
value="${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>
|
||||
<a href="javascript:void(0);" class="link-danger" onclick="remove_item(${idx})">
|
||||
<i class="ti ti-trash fs-5"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
$('#item_tbody').append(row);
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
function remove_item(idx) {
|
||||
$(`#item_row_${idx}`).remove();
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
|
||||
// ── Collect items from DOM ────────────────────────────────────────────────
|
||||
function collect_items() {
|
||||
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; }
|
||||
items.push({
|
||||
item_id: idx,
|
||||
product_sku: sku,
|
||||
product_name: name,
|
||||
warehouse_id: wh,
|
||||
zone: '',
|
||||
aisle: '',
|
||||
rack: '',
|
||||
lot_number: '',
|
||||
serial_number: '',
|
||||
quantity: qty,
|
||||
price: price,
|
||||
total_price: qty * price,
|
||||
stock_out_id: 0,
|
||||
});
|
||||
idx++;
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
// ── Set view/edit mode ────────────────────────────────────────────────────
|
||||
function set_mode(status, has_active_invoice, returnable_count) {
|
||||
status = parseInt(status);
|
||||
has_active_invoice = has_active_invoice || false;
|
||||
returnable_count = returnable_count ?? 1;
|
||||
var is_draft = status === 0;
|
||||
var can_confirm = status === 0;
|
||||
var can_cancel = status === 0 || status === 1;
|
||||
var is_cancelled = status === -1;
|
||||
|
||||
// Edit controls
|
||||
$('#btn_add_item, #contact, #order_date, #notes, #discount, #tax, #shipping_fee')
|
||||
.prop('disabled', !is_draft);
|
||||
$('.item_sku, .item_qty, .item_price, .item_warehouse').prop('disabled', !is_draft);
|
||||
$('a[onclick^="remove_item"]').toggleClass('d-none', !is_draft);
|
||||
|
||||
// Action buttons — set_btn_state handles class swap + disabled + title
|
||||
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_cancel', can_cancel && !is_cancelled,
|
||||
is_cancelled ? 'Order is already cancelled' : 'Only draft or confirmed orders can be cancelled');
|
||||
|
||||
// Order status selector — confirmed and above, not cancelled
|
||||
var can_change_status = status >= 1 && !is_cancelled;
|
||||
$('#order_status_group').toggleClass('d-none', !can_change_status);
|
||||
set_btn_state('#order_status_group button', can_change_status,
|
||||
is_cancelled ? 'Order is cancelled' : 'Confirm the order first');
|
||||
$('#order_status_select').prop('disabled', !can_change_status);
|
||||
|
||||
// Payment status selector
|
||||
set_btn_state('#payment_status_group button', status >= 1 && !is_cancelled,
|
||||
'Confirm the order before updating payment status');
|
||||
$('#payment_status_select').prop('disabled', status < 1 || is_cancelled);
|
||||
|
||||
// Create Return
|
||||
var can_return = status >= 1 && !is_cancelled && returnable_count > 0;
|
||||
var return_title = is_cancelled ? 'Order is cancelled'
|
||||
: status < 1 ? 'Confirm the order before creating a return'
|
||||
: returnable_count === 0 ? 'All items have already been returned' : '';
|
||||
set_btn_state('#btn_return', can_return, return_title);
|
||||
$('#btn_return').html(returnable_count === 0 && status >= 1 && !is_cancelled
|
||||
? '<i class="ti ti-arrow-back-up me-1"></i>All Items Returned'
|
||||
: '<i class="ti ti-arrow-back-up me-1"></i>Create Return');
|
||||
|
||||
// Proceed to Invoice
|
||||
var can_invoice = status === 1 && !has_active_invoice;
|
||||
var invoice_title = is_cancelled ? 'Order is cancelled'
|
||||
: status < 1 ? 'Confirm the order before creating an invoice'
|
||||
: status > 1 ? 'Invoice can only be created for confirmed orders'
|
||||
: has_active_invoice ? 'An active invoice already exists for this order' : '';
|
||||
set_btn_state('#btn_invoice', can_invoice, invoice_title);
|
||||
$('#btn_invoice').html(has_active_invoice
|
||||
? '<i class="ti ti-file-invoice me-1"></i>Invoice Already Created'
|
||||
: '<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice');
|
||||
}
|
||||
|
||||
|
||||
// ── Load existing order ───────────────────────────────────────────────────
|
||||
function retrieve_order() {
|
||||
if (!order_id) return Promise.resolve();
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: order_id },
|
||||
onSuccess: function(res) {
|
||||
order_data = res.output;
|
||||
var o = order_data;
|
||||
|
||||
// Header
|
||||
$('#page_title').text(o.order_number);
|
||||
$('#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));
|
||||
$('#order_number_display').text(o.order_number);
|
||||
|
||||
// Fields
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id);
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
$('#tax').val(o.tax || 0);
|
||||
$('#shipping_fee').val(o.shipping_fee || 0);
|
||||
|
||||
// Items
|
||||
$('#item_tbody').empty();
|
||||
(o.items || []).forEach(function(item) { add_item_row(item); });
|
||||
|
||||
recalc_totals();
|
||||
set_mode(o.status, res.has_active_invoice, (res.returnable_items || []).length);
|
||||
$('#payment_status_select').val(o.payment_status ?? 0);
|
||||
$('#order_status_select').val(o.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Save draft ────────────────────────────────────────────────────────────
|
||||
function save_order() {
|
||||
var items = collect_items();
|
||||
if (!items.length) {
|
||||
bootbox.alert('Please add at least one item.');
|
||||
return;
|
||||
}
|
||||
|
||||
const dateStr = $('#order_date').val();
|
||||
const [day, month, year] = dateStr.split('/');
|
||||
const formatted = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/manage_order.php',
|
||||
autoPrepare: true,
|
||||
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,
|
||||
notes: $('#notes').val(),
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!order_id && res.new_id) {
|
||||
window.location.href = '<?php echo $server_url?>order/manage_order.php?id=' + res.new_id;
|
||||
} else {
|
||||
retrieve_order();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Confirm order ─────────────────────────────────────────────────────────
|
||||
function confirm_order() {
|
||||
bootbox.confirm({
|
||||
message: 'Confirm this order? Stock-out rows will be created for all items using FIFO rack selection.',
|
||||
buttons: {
|
||||
confirm: { label: 'Confirm Order', 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/confirm_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: order_id },
|
||||
onSuccess: function() {
|
||||
retrieve_order();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Update order status ───────────────────────────────────────────────────
|
||||
function update_order_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,
|
||||
status: $('#order_status_select').val(),
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// ── Proceed to invoice ────────────────────────────────────────────────────
|
||||
function proceed_to_invoice() {
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/proceed_to_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'create',
|
||||
data: { order_id: order_id },
|
||||
onSuccess: function(res) {
|
||||
if (res.new_id) {
|
||||
window.location.href = '<?php echo $server_url?>order/manage_invoice.php?id=' + res.new_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Cancel order ──────────────────────────────────────────────────────────
|
||||
function cancel_order() {
|
||||
bootbox.confirm({
|
||||
message: 'Cancel this order? All pending stock-out rows will be cancelled.',
|
||||
buttons: {
|
||||
confirm: { label: 'Yes, Cancel Order', className: 'btn-danger' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/cancel_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: order_id },
|
||||
onSuccess: function() {
|
||||
retrieve_order();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Retrieve warehouses ───────────────────────────────────────────────────
|
||||
function retrieve_warehouses() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>inventory/api/engine/warehouse.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
warehouses = res.output || [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
await retrieve_warehouses();
|
||||
|
||||
if (order_id) {
|
||||
await retrieve_order();
|
||||
} else {
|
||||
// New order — set today's date, add one empty row
|
||||
var today = flatpickr.formatDate(new Date(), 'd/m/Y');
|
||||
$('#order_date').val(today);
|
||||
add_item_row();
|
||||
set_mode(0);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$return_id = (int)($_GET['id'] ?? 0);
|
||||
$invoice_id = (int)($_GET['invoice_id'] ?? 0);
|
||||
$order_id = (int)($_GET['order_id'] ?? 0);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1" id="page_title">
|
||||
<?php echo $return_id ? 'Return Detail' : 'New Return'; ?>
|
||||
</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle"></p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>order/returns.php" class="btn btn-light">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
|
||||
<!-- Left: Items -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Return header -->
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Return Information</h2>
|
||||
<div class="row gx-5">
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Order</label>
|
||||
<input type="text" id="order_display" class="form-control" readonly
|
||||
placeholder="Loading...">
|
||||
<input type="hidden" id="order_id" value="<?php echo $order_id; ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Invoice <span class="text-muted small">(optional)</span></label>
|
||||
<select id="invoice_id" class="form-select">
|
||||
<option value="0">— no invoice —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Contact</label>
|
||||
<input type="text" id="contact" class="form-control" readonly
|
||||
placeholder="Auto-filled from order">
|
||||
<input type="hidden" id="contact_id">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Return Date</label>
|
||||
<input type="text" id="return_date" class="form-control"
|
||||
placeholder="DD/MM/YYYY" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label class="form-label">Reason</label>
|
||||
<textarea id="reason" class="form-control" rows="2"
|
||||
placeholder="Reason for return..."></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return items — loaded from order -->
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-3">Select Items to Return</h2>
|
||||
<p class="text-muted small mb-4">Only items with remaining returnable quantity are shown.</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0" id="item_table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th>Product</th>
|
||||
<th class="text-end" style="width:100px;">Order Qty</th>
|
||||
<th class="text-end" style="width:100px;">Returned</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody">
|
||||
<tr id="loading_row">
|
||||
<td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="ti ti-loader me-1"></i>Loading items...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right: Summary + actions -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Status card (view mode) -->
|
||||
<div class="card mb-5 d-none" id="status_card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">Status</h2>
|
||||
<span id="badge_status"></span>
|
||||
<p class="mb-0 small text-muted mt-2" id="return_number_display"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refund summary -->
|
||||
<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 fw-bold fs-5">
|
||||
<span>Total Refund</span>
|
||||
<span id="summary_refund">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
|
||||
<button class="btn btn-primary w-100" id="btn_save"
|
||||
onclick="save_return()">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save Draft
|
||||
</button>
|
||||
|
||||
<button class="btn btn-success w-100" id="btn_confirm"
|
||||
onclick="confirm_return()">
|
||||
<i class="ti ti-check me-1"></i>Confirm Return
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-danger w-100" id="btn_cancel"
|
||||
onclick="cancel_return()">
|
||||
<i class="ti ti-x me-1"></i>Cancel Return
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
var return_id = <?php echo $return_id; ?>;
|
||||
var return_data = null;
|
||||
var order_data = null;
|
||||
var returnable = []; // returnable_items from order
|
||||
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────
|
||||
function return_status_badge(status) {
|
||||
const map = {
|
||||
'-1': '<span class="badge bg-secondary">Cancelled</span>',
|
||||
'0': '<span class="badge bg-warning text-dark">Draft</span>',
|
||||
'1': '<span class="badge bg-success">Confirmed</span>',
|
||||
};
|
||||
return map[String(status)] ?? '—';
|
||||
}
|
||||
|
||||
|
||||
// ── Recalculate totals ────────────────────────────────────────────────────
|
||||
function recalc_totals() {
|
||||
var total = 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;
|
||||
$row.find('.item_total').text(format_number(t, 2));
|
||||
total += t;
|
||||
});
|
||||
$('#summary_refund').text(format_number(total, 2));
|
||||
}
|
||||
|
||||
|
||||
// ── Render returnable items ───────────────────────────────────────────────
|
||||
function render_returnable_items(items, selected_items) {
|
||||
// selected_items: array from existing return (edit mode)
|
||||
// keyed by item_id for quick lookup
|
||||
var selected_map = {};
|
||||
if (selected_items) {
|
||||
selected_items.forEach(function(si) {
|
||||
selected_map[si.item_id] = si;
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
$('#item_tbody').html(
|
||||
'<tr><td colspan="7" class="text-center text-muted py-4">No returnable items for this order.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = '';
|
||||
items.forEach(function(item) {
|
||||
var si = selected_map[item.item_id] || null;
|
||||
var checked = si ? 'checked' : '';
|
||||
var qty = si ? si.quantity : item.returnable_qty;
|
||||
var price = item.price || 0;
|
||||
|
||||
rows += `<tr class="return-item-row" id="ret_item_${item.item_id}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input item_check"
|
||||
onchange="toggle_item_row(${item.item_id}); recalc_totals();" ${checked}>
|
||||
<input type="hidden" class="item_id" value="${item.item_id}">
|
||||
<input type="hidden" class="item_sku" value="${item.product_sku}">
|
||||
<input type="hidden" class="item_name" value="${item.product_name || ''}">
|
||||
<input type="hidden" class="item_warehouse_id" value="${item.warehouse_id || 0}">
|
||||
<input type="hidden" class="item_zone" value="${item.zone || ''}">
|
||||
<input type="hidden" class="item_aisle" value="${item.aisle || ''}">
|
||||
<input type="hidden" class="item_rack" value="${item.rack || ''}">
|
||||
<input type="hidden" class="item_lot" value="${item.lot_number || ''}">
|
||||
<input type="hidden" class="item_serial" value="${item.serial_number || ''}">
|
||||
<input type="hidden" class="item_stock_out_id" value="${item.stock_out_id || 0}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">${item.product_sku}</div>
|
||||
<small class="text-muted">${item.product_name || ''}</small>
|
||||
</td>
|
||||
<td class="text-end">${format_number(item.original_qty, 2)}</td>
|
||||
<td class="text-end text-muted">${item.returned_qty > 0 ? format_number(item.returned_qty, 2) : '—'}</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_qty text-end"
|
||||
value="${format_number(qty, 2)}"
|
||||
min="0.0001" max="${item.returnable_qty}" step="any"
|
||||
${!checked ? 'disabled' : ''}
|
||||
oninput="recalc_totals()">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_price text-end"
|
||||
value="${price}"
|
||||
min="0" step="any"
|
||||
${!checked ? 'disabled' : ''}
|
||||
oninput="recalc_totals()">
|
||||
</td>
|
||||
<td class="text-end item_total">${checked ? format_number(qty * price, 2) : '—'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('#item_tbody').html(rows);
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
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').prop('disabled', !checked);
|
||||
if (!checked) $row.find('.item_total').text('—');
|
||||
}
|
||||
|
||||
|
||||
// ── Load order + items ────────────────────────────────────────────────────
|
||||
function load_order(oid, selected_items) {
|
||||
if (!oid) return Promise.resolve();
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: oid },
|
||||
onSuccess: function(res) {
|
||||
order_data = res.output;
|
||||
returnable = res.returnable_items || [];
|
||||
|
||||
$('#order_display').val(order_data.order_number || '');
|
||||
$('#contact').val(order_data.contact_name || '');
|
||||
$('#contact_id').val(order_data.contact_id || 0);
|
||||
|
||||
// Load invoices dropdown
|
||||
load_invoices_for_order(oid);
|
||||
|
||||
// Render items
|
||||
render_returnable_items(returnable, selected_items || null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Load invoices for order ───────────────────────────────────────────────
|
||||
function load_invoices_for_order(oid) {
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { order_id: oid, doc_type: 'invoice' },
|
||||
onSuccess: function(res) {
|
||||
var opts = '<option value="0">— no invoice —</option>';
|
||||
$.each(res.output || [], function(i, inv) {
|
||||
opts += `<option value="${inv.id}">${inv.invoice_number}</option>`;
|
||||
});
|
||||
$('#invoice_id').html(opts);
|
||||
|
||||
// Restore selected invoice if editing existing return
|
||||
if (return_data && return_data.invoice_id) {
|
||||
$('#invoice_id').val(return_data.invoice_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Collect selected items ────────────────────────────────────────────────
|
||||
function collect_items() {
|
||||
var items = [];
|
||||
$('#item_tbody tr.return-item-row').each(function() {
|
||||
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;
|
||||
if (qty <= 0) return;
|
||||
|
||||
items.push({
|
||||
item_id: parseInt($row.find('.item_id').val()),
|
||||
product_sku: $row.find('.item_sku').val(),
|
||||
product_name: $row.find('.item_name').val(),
|
||||
warehouse_id: parseInt($row.find('.item_warehouse_id').val()) || 0,
|
||||
zone: $row.find('.item_zone').val(),
|
||||
aisle: $row.find('.item_aisle').val(),
|
||||
rack: $row.find('.item_rack').val(),
|
||||
lot_number: $row.find('.item_lot').val(),
|
||||
serial_number: $row.find('.item_serial').val(),
|
||||
stock_out_id: parseInt($row.find('.item_stock_out_id').val()) || 0,
|
||||
quantity: qty,
|
||||
price: price,
|
||||
total_price: qty * price,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
// ── Mode ──────────────────────────────────────────────────────────────────
|
||||
function set_mode(status) {
|
||||
status = parseInt(status);
|
||||
var is_draft = status === 0;
|
||||
|
||||
$('#return_date, #reason, #invoice_id').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').prop('disabled', !is_draft || !checked);
|
||||
});
|
||||
|
||||
set_btn_state('#btn_save', is_draft,
|
||||
status === -1 ? 'Return is cancelled' : 'Return has already been confirmed');
|
||||
set_btn_state('#btn_confirm', is_draft,
|
||||
status === -1 ? 'Return is cancelled' : 'Return is already confirmed');
|
||||
set_btn_state('#btn_cancel', is_draft,
|
||||
status === -1 ? 'Return is already cancelled' : 'Only draft returns can be cancelled');
|
||||
}
|
||||
|
||||
|
||||
// ── Load existing return (edit mode) ──────────────────────────────────────
|
||||
function retrieve_return() {
|
||||
if (!return_id) return Promise.resolve();
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: return_id },
|
||||
onSuccess: async function(res) {
|
||||
return_data = res.output;
|
||||
var r = return_data;
|
||||
|
||||
$('#page_title').text(r.return_number);
|
||||
$('#status_card').removeClass('d-none');
|
||||
$('#badge_status').html(return_status_badge(r.status));
|
||||
$('#return_number_display').text(r.return_number);
|
||||
|
||||
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||
$('#reason').val(r.reason || '');
|
||||
|
||||
// Load order context (which also renders items with existing selection)
|
||||
await load_order(r.order_id, r.items || []);
|
||||
|
||||
set_mode(r.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Save draft ────────────────────────────────────────────────────────────
|
||||
function save_return() {
|
||||
var items = collect_items();
|
||||
if (!items.length) {
|
||||
bootbox.alert('Please select at least one item to return.');
|
||||
return;
|
||||
}
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/manage_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: return_id ? 'update' : 'create',
|
||||
data: {
|
||||
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: '',
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!return_id && res.new_id) {
|
||||
window.location.href = '<?php echo $server_url?>order/manage_return.php?id=' + res.new_id;
|
||||
} else {
|
||||
retrieve_return();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Confirm return ────────────────────────────────────────────────────────
|
||||
function confirm_return() {
|
||||
bootbox.confirm({
|
||||
message: 'Confirm this return? Items will be restocked and a credit note may be generated.',
|
||||
buttons: {
|
||||
confirm: { label: 'Confirm Return', 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/confirm_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: return_id },
|
||||
onSuccess: function() { retrieve_return(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Cancel return ─────────────────────────────────────────────────────────
|
||||
function cancel_return() {
|
||||
bootbox.confirm({
|
||||
message: 'Cancel this return request?',
|
||||
buttons: {
|
||||
confirm: { label: 'Yes, Cancel', className: 'btn-danger' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/cancel_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: return_id },
|
||||
onSuccess: function() { retrieve_return(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
flatpickr('#return_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
|
||||
if (return_id) {
|
||||
// Edit/view mode — load existing return
|
||||
await retrieve_return();
|
||||
} else {
|
||||
// New return from order
|
||||
var pre_order = <?php echo $order_id; ?>;
|
||||
if (pre_order) {
|
||||
await load_order(pre_order, null);
|
||||
}
|
||||
var today = flatpickr.formatDate(new Date(), 'd/m/Y');
|
||||
$('#return_date').val(today);
|
||||
set_mode(0);
|
||||
}
|
||||
|
||||
} catch(e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Orders</h1>
|
||||
<p class="mb-0">Manage and track all sales orders</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>order/manage_order.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus"></i>
|
||||
<span>New Order</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<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-shopping-cart fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Total Orders</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-clock 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-package fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Confirmed</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_confirmed">—</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-success bg-opacity-10 text-success rounded-2">
|
||||
<i class="ti ti-circle-check fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Completed</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_completed">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter + Table -->
|
||||
<div class="row g-5">
|
||||
|
||||
<!-- Search & filter bar -->
|
||||
<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="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search order 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">
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="">All Status</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>
|
||||
<option value="3">Refunded</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary w-100" onclick="retrieve_orders()">
|
||||
<i class="ti ti-search me-1"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered" id="order_list">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th class="py-3">Order #</th>
|
||||
<th class="py-3">Date</th>
|
||||
<th class="py-3">Contact</th>
|
||||
<th class="py-3">Items</th>
|
||||
<th class="py-3">Grand Total</th>
|
||||
<th class="py-3">Status</th>
|
||||
<th class="py-3">Payment</th>
|
||||
<th class="py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────
|
||||
function order_status_badge(status) {
|
||||
status = parseInt(status);
|
||||
const map = {
|
||||
'-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[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>',
|
||||
'3': '<span class="badge bg-secondary">Refunded</span>',
|
||||
};
|
||||
return map[status] ?? '<span class="badge bg-light text-dark">—</span>';
|
||||
}
|
||||
|
||||
|
||||
// ── Retrieve orders ───────────────────────────────────────────────────────
|
||||
function retrieve_orders() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
|
||||
var all = res.output || [];
|
||||
|
||||
// Client-side filter
|
||||
var keyword = $('#search_input').val().toLowerCase();
|
||||
var fil_status = $('#filter_status').val();
|
||||
var fil_payment = $('#filter_payment').val();
|
||||
|
||||
var data = all.filter(function(o) {
|
||||
var match_kw = !keyword ||
|
||||
(o.order_number || '').toLowerCase().includes(keyword) ||
|
||||
(o.contact_name || '').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;
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
// Pagination via alasql
|
||||
alasql('CREATE TABLE IF NOT EXISTS order_list');
|
||||
alasql.tables.order_list.data = data;
|
||||
|
||||
var page = generate_pagination('order_list', data.length);
|
||||
$('table#order_list tfoot').html(page);
|
||||
|
||||
if (typeof change_page_order_list === 'function') {
|
||||
change_page_order_list(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Render page ───────────────────────────────────────────────────────────
|
||||
function change_page_order_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var page_data = alasql(`SELECT * FROM order_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
$.each(page_data, function(i, o) {
|
||||
var items = JSON.parse(o.items || '[]');
|
||||
var item_count = items.length;
|
||||
var can_edit = parseInt(o.status) === 0;
|
||||
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
|
||||
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${o.order_number}</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">${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">${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>
|
||||
${can_cancel ? `
|
||||
<a href="javascript:void(0);" class="link-danger ms-2" title="Cancel"
|
||||
onclick="cancel_order(${o.id})">
|
||||
<i class="ti ti-x fs-5"></i>
|
||||
</a>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('table#order_list > tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
// ── Cancel order ─────────────────────────────────────────────────────────
|
||||
function cancel_order(id) {
|
||||
bootbox.confirm({
|
||||
message: 'Cancel this order? All pending stock-out rows will be cancelled.',
|
||||
buttons: {
|
||||
confirm: { label: 'Yes, Cancel Order', className: 'btn-danger' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/cancel_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: id },
|
||||
onSuccess: function() {
|
||||
retrieve_orders();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
$(function() {
|
||||
retrieve_orders();
|
||||
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_orders();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="row g-5 mb-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="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search return 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">
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="-1">Cancelled</option>
|
||||
<option value="0">Draft</option>
|
||||
<option value="1">Confirmed</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<table class="table table-hover mb-0 table-centered" id="return_list">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th class="py-3">Return #</th>
|
||||
<th class="py-3">Order #</th>
|
||||
<th class="py-3">Contact</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">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
|
||||
function return_status_badge(status) {
|
||||
const map = {
|
||||
'-1': '<span class="badge bg-secondary">Cancelled</span>',
|
||||
'0': '<span class="badge bg-warning text-dark">Draft</span>',
|
||||
'1': '<span class="badge bg-success">Confirmed</span>',
|
||||
};
|
||||
return map[String(status)] ?? '—';
|
||||
}
|
||||
|
||||
|
||||
function retrieve_returns() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var all = res.output || [];
|
||||
|
||||
var keyword = $('#search_input').val().toLowerCase();
|
||||
var fil_st = $('#filter_status').val();
|
||||
|
||||
var data = all.filter(function(r) {
|
||||
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;
|
||||
});
|
||||
|
||||
alasql('CREATE TABLE IF NOT EXISTS return_list');
|
||||
alasql.tables.return_list.data = data;
|
||||
|
||||
var page = generate_pagination('return_list', data.length);
|
||||
$('table#return_list tfoot').html(page);
|
||||
|
||||
if (typeof change_page_return_list === 'function') {
|
||||
change_page_return_list(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function change_page_return_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var page_data = alasql(`SELECT * FROM return_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
$.each(page_data, function(i, r) {
|
||||
var items = JSON.parse(r.items || '[]');
|
||||
var can_cancel = parseInt(r.status) === 0;
|
||||
|
||||
body += `<tr>
|
||||
<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">${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">
|
||||
<a href="<?php echo $server_url?>order/manage_return.php?id=${r.id}" title="View">
|
||||
<i class="ti ti-eye fs-5"></i>
|
||||
</a>
|
||||
${can_cancel ? `
|
||||
<a href="javascript:void(0);" class="link-danger ms-2" title="Cancel"
|
||||
onclick="cancel_return(${r.id})">
|
||||
<i class="ti ti-x fs-5"></i>
|
||||
</a>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('table#return_list > tbody').html(body);
|
||||
}
|
||||
|
||||
|
||||
function cancel_return(id) {
|
||||
bootbox.confirm({
|
||||
message: 'Cancel this return request?',
|
||||
buttons: {
|
||||
confirm: { label: 'Yes, Cancel', className: 'btn-danger' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/cancel_return.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: id },
|
||||
onSuccess: function() { retrieve_returns(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$(function() {
|
||||
retrieve_returns();
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_returns();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,7 +17,7 @@ $action = $data['action'] ?? '';
|
||||
if ($action === 'read') {
|
||||
|
||||
$sth = $pdo1->prepare(
|
||||
"SELECT cl.company_id, cl.company_name, cl.branch, cmu.role
|
||||
"SELECT cl.company_id, cl.channel_name, cl.company_name, cl.branch, cmu.role
|
||||
FROM company_map_user cmu
|
||||
JOIN company_list cl ON cl.company_id = cmu.company_id
|
||||
WHERE cmu.user_id = :user_id
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- Stock Settings -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card border-0 shadow-sm mb-5">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h6 class="mb-0"><i class="ti ti-package me-2"></i>Stock Settings</h6>
|
||||
</div>
|
||||
@@ -66,6 +66,48 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice & Credit Note Settings -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent border-bottom">
|
||||
<h6 class="mb-0"><i class="ti ti-file-invoice me-2"></i>Invoice & Credit Note Settings</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Invoice & Credit Note Generation</label>
|
||||
<p class="text-muted small mb-2">
|
||||
Controls automatic document generation.
|
||||
When <strong>Auto</strong> is selected, a draft invoice is created when an order is confirmed,
|
||||
and a credit note is created automatically when a return is confirmed.
|
||||
When <strong>Manual</strong>, staff create these documents themselves from the order or return detail page.
|
||||
</p>
|
||||
<div class="d-flex flex-column gap-2" id="auto_invoice_and_credit_note_group">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="auto_invoice_and_credit_note"
|
||||
id="invoice_auto" value="1">
|
||||
<label class="form-check-label" for="invoice_auto">
|
||||
<span class="fw-semibold">Auto</span>
|
||||
<span class="text-muted ms-1 small">— invoice on order confirm, credit note on return confirm</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="auto_invoice_and_credit_note"
|
||||
id="invoice_manual" value="0">
|
||||
<label class="form-check-label" for="invoice_manual">
|
||||
<span class="fw-semibold">Manual</span>
|
||||
<span class="text-muted ms-1 small">— staff create invoice and credit note manually</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="save_config();">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,18 +126,20 @@
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var val = res.output.default_stock_status ?? 1;
|
||||
$('input[name="default_stock_status"][value="' + val + '"]').prop('checked', true);
|
||||
var s = res.output;
|
||||
$('input[name="default_stock_status"][value="' + (s.default_stock_status ?? 1) + '"]').prop('checked', true);
|
||||
$('input[name="auto_invoice_and_credit_note"][value="' + (s.auto_invoice_and_credit_note ?? 0) + '"]').prop('checked', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
function save_config() {
|
||||
var status = $('input[name="default_stock_status"]:checked').val();
|
||||
var stock_status = $('input[name="default_stock_status"]:checked').val();
|
||||
var auto_invoice_and_cn = $('input[name="auto_invoice_and_credit_note"]:checked').val();
|
||||
|
||||
if (status === undefined) {
|
||||
bootbox.alert('Please select a default stock status.');
|
||||
if (stock_status === undefined || auto_invoice_and_cn === undefined) {
|
||||
bootbox.alert('Please select all settings before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +149,8 @@
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: {
|
||||
default_stock_status: status,
|
||||
default_stock_status: stock_status,
|
||||
auto_invoice_and_credit_note: auto_invoice_and_cn,
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
bootbox.alert('Settings saved.');
|
||||
|
||||
Reference in New Issue
Block a user