accounting workflows
This commit is contained in:
@@ -2,60 +2,104 @@
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
require '../../../assets/utils/classes/QuotationManager.php';
|
||||
|
||||
$quotation_id = (int)($data['quotation_id'] ?? 0);
|
||||
$convert_items = $data['convert_items'] ?? [];
|
||||
|
||||
$quotation_id = (int)($data['quotation_id'] ?? 0);
|
||||
if (!$quotation_id) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation ID required']));
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation ID required.']));
|
||||
}
|
||||
|
||||
// Load quotation — must be Accepted (status=2)
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT q.*, c.contact_name
|
||||
FROM ac_quotation q
|
||||
LEFT JOIN md_contact c ON c.id = q.contact_id
|
||||
WHERE q.id = :id AND q.company_id = :cid AND q.status = 2
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $quotation_id, ':cid' => $company_id]);
|
||||
$q = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$qm = new QuotationManager($pdo2, $company_id);
|
||||
|
||||
if (!$q) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation not found or not in Accepted status']));
|
||||
// Load quotation — must be Accepted
|
||||
$q = $qm->getById($quotation_id);
|
||||
if (!$q || (int)$q['status'] !== 2) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation not found or not in Accepted status.']));
|
||||
}
|
||||
|
||||
// Check not already converted
|
||||
if ((int)$q['order_id'] > 0) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation has already been converted to an order']));
|
||||
$qt_items = $q['items'];
|
||||
if (empty($qt_items)) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation has no items.']));
|
||||
}
|
||||
|
||||
// Map quotation items → order items (warehouse_id left 0 = to be filled by WMS)
|
||||
$qt_items = json_decode($q['items'] ?? '[]', true) ?: [];
|
||||
if ((float)$q['total_remaining'] <= 0.000001) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Quotation is already fully converted.']));
|
||||
}
|
||||
|
||||
// Index by item_id for validation
|
||||
$qt_by_id = [];
|
||||
foreach ($qt_items as $qi) {
|
||||
$qt_by_id[(int)$qi['item_id']] = $qi;
|
||||
}
|
||||
|
||||
// Default to all remaining when caller sends no selection
|
||||
if (empty($convert_items)) {
|
||||
$convert_items = [];
|
||||
foreach ($qt_items as $qi) {
|
||||
$remaining = (float)$qi['remaining_qty'];
|
||||
if ($remaining > 0.000001) {
|
||||
$convert_items[] = ['item_id' => (int)$qi['item_id'], 'quantity' => $remaining];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate quantities and build order items
|
||||
$ord_items = [];
|
||||
foreach ($qt_items as $i => $qi) {
|
||||
$validated = [];
|
||||
|
||||
foreach ($convert_items as $ci) {
|
||||
$item_id = (int)($ci['item_id'] ?? 0);
|
||||
$qty = (float)($ci['quantity'] ?? 0);
|
||||
|
||||
if ($qty <= 0) continue;
|
||||
if (!isset($qt_by_id[$item_id])) {
|
||||
exit(json_encode(['success' => 0, 'message' => "Item #{$item_id} not found in this quotation."]));
|
||||
}
|
||||
|
||||
$qi = $qt_by_id[$item_id];
|
||||
$remaining = (float)$qi['remaining_qty'];
|
||||
|
||||
if ($qty - $remaining > 0.000001) {
|
||||
$name = $qi['product_name'] ?: $qi['product_sku'];
|
||||
exit(json_encode([
|
||||
'success' => 0,
|
||||
'message' => "Cannot convert {$qty} for \"{$name}\": only {$remaining} remaining.",
|
||||
]));
|
||||
}
|
||||
|
||||
$unit_price = (float)$qi['unit_price'];
|
||||
$total_price = round($qty * $unit_price, 4);
|
||||
$tax_amount = round($total_price * (float)($qi['tax_amount'] / max((float)$qi['total_price'], 0.000001)), 4);
|
||||
|
||||
$ord_items[] = [
|
||||
'item_id' => $i,
|
||||
'product_sku' => $qi['product_sku'] ?? '',
|
||||
'product_name' => $qi['description'] ?? '',
|
||||
'warehouse_id' => 0,
|
||||
'zone' => '',
|
||||
'aisle' => '',
|
||||
'rack' => '',
|
||||
'lot_number' => '',
|
||||
'serial_number' => '',
|
||||
'quantity' => (float)($qi['quantity'] ?? 0),
|
||||
'price' => (float)($qi['unit_price'] ?? 0),
|
||||
'total_price' => (float)($qi['total_price'] ?? 0),
|
||||
'stock_out_id' => 0,
|
||||
'item_id' => $item_id - 1,
|
||||
'product_sku' => $qi['product_sku'],
|
||||
'product_name' => $qi['product_name'],
|
||||
'warehouse_id' => 0,
|
||||
'lot_number' => '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
'tax_amount' => $tax_amount,
|
||||
'stock_out_id' => 0,
|
||||
];
|
||||
$validated[] = ['item_id' => $item_id, 'quantity' => $qty];
|
||||
}
|
||||
|
||||
if (empty($ord_items)) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'No valid items to convert.']));
|
||||
}
|
||||
|
||||
$order_data = [
|
||||
'quotation_id' => $quotation_id,
|
||||
'source' => 'quotation',
|
||||
'source_id' => $quotation_id,
|
||||
'contact_id' => (int)$q['contact_id'],
|
||||
'department_id'=> (int)($q['department_id'] ?? 0),
|
||||
'order_date' => date('Y-m-d'),
|
||||
'items' => $ord_items,
|
||||
'discount' => (float)$q['discount'],
|
||||
'tax' => (float)$q['tax'],
|
||||
'discount' => 0,
|
||||
'shipping_fee' => 0,
|
||||
'shipping_tracking_number' => '',
|
||||
'notes' => $q['notes'] ?? '',
|
||||
@@ -64,21 +108,23 @@ $order_data = [
|
||||
try {
|
||||
$new_order_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($order_data, $company_id, $logging, $quotation_id, &$new_order_id) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$new_order_id = $order->saveOrder($order_data, $logging);
|
||||
dbTransaction($pdo2, function($pdo) use (
|
||||
$order_data, $company_id, $logging, $quotation_id, $validated, $qm, &$new_order_id
|
||||
) {
|
||||
$om = new OrderManager($pdo, $company_id);
|
||||
$new_order_id = $om->saveOrder($order_data, $logging);
|
||||
|
||||
// Mark quotation as Converted and link order_id
|
||||
$pdo->prepare(
|
||||
"UPDATE ac_quotation SET status = 5, order_id = :oid
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':oid' => $new_order_id, ':id' => $quotation_id, ':cid' => $company_id]);
|
||||
$qm2 = new QuotationManager($pdo, $company_id);
|
||||
$qm2->incrementConvertedQty($quotation_id, $validated);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Order created successfully';
|
||||
$answer['message'] = 'Order created successfully.';
|
||||
$answer['order_id'] = $new_order_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);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
require '../../../assets/utils/classes/InvoiceManager.php';
|
||||
|
||||
try {
|
||||
$new_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($data, $company_id, $logging, &$new_id) {
|
||||
$inv = new InvoiceManager($pdo, $company_id);
|
||||
$new_id = $inv->createManualCreditNote($data, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['new_id'] = $new_id;
|
||||
$answer['message'] = 'Credit note created.';
|
||||
|
||||
} 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,54 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/OrderManager.php';
|
||||
|
||||
$action = $data['action'] ?? '';
|
||||
$data['items'] = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
|
||||
if (!in_array($action, ['create', 'update'], true)) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Unknown action']));
|
||||
}
|
||||
|
||||
if (empty($data['items'])) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'At least one item is required']));
|
||||
}
|
||||
|
||||
try {
|
||||
$new_id = null;
|
||||
|
||||
dbTransaction($pdo2, function($pdo) use ($data, $action, $company_id, $logging, &$new_id) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
|
||||
if ($action === 'create') {
|
||||
$data['source'] = 'revenue';
|
||||
$data['source_id'] = 0;
|
||||
} else {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) {
|
||||
throw new Exception('Order ID required.');
|
||||
}
|
||||
|
||||
$order->assertRevenueOrderEditable($id);
|
||||
}
|
||||
|
||||
$new_id = $order->saveOrder($data, $logging);
|
||||
});
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = $action === 'create' ? 'Sales order created' : 'Sales order updated';
|
||||
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));
|
||||
?>
|
||||
@@ -1,107 +1,35 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/QuotationManager.php';
|
||||
|
||||
$action = $data['action'] ?? '';
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
|
||||
// ── Generate quotation number ─────────────────────────────────────────────────
|
||||
function generate_quotation_number(PDO $pdo, int $cid): string {
|
||||
$prefix = 'QT-' . date('Ym') . '-';
|
||||
$sql = "SELECT quotation_number FROM ac_quotation
|
||||
WHERE company_id = :cid AND quotation_number LIKE :prefix
|
||||
ORDER BY id DESC LIMIT 1";
|
||||
$sth = $pdo->prepare($sql);
|
||||
$sth->execute([':cid' => $cid, ':prefix' => $prefix . '%']);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$qm = new QuotationManager($pdo2, $company_id);
|
||||
|
||||
// ── Compute totals from items ─────────────────────────────────────────────────
|
||||
function compute_totals(array $items, float $discount, float $tax): array {
|
||||
$subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items));
|
||||
$grand = $subtotal - $discount + $tax;
|
||||
return [round($subtotal, 4), round(max(0, $grand), 4)];
|
||||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
|
||||
$items = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
[$subtotal, $grand] = compute_totals($items, $discount, $tax);
|
||||
|
||||
$qnum = generate_quotation_number($pdo2, $company_id);
|
||||
|
||||
$sql = "INSERT INTO ac_quotation
|
||||
(company_id, uuid, quotation_number, contact_id, quotation_date, valid_until,
|
||||
status, subtotal, discount, tax, grand_total, items, notes, log, created_at)
|
||||
VALUES
|
||||
(:cid, :uuid, :qnum, :contact_id, :qdate, :valid_until,
|
||||
0, :sub, :disc, :tax, :grand, :items, :notes, :log, NOW())";
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([
|
||||
':cid' => $company_id,
|
||||
':uuid' => $uuid,
|
||||
':qnum' => $qnum,
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':qdate' => $data['quotation_date'] ?: null,
|
||||
':valid_until'=> $data['valid_until'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':grand' => $grand,
|
||||
':items' => json_encode($items),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($logging),
|
||||
]);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Quotation created';
|
||||
$answer['new_id'] = (int)$pdo2->lastInsertId();
|
||||
$answer['quotation_number'] = $qnum;
|
||||
|
||||
} elseif ($action === 'update') {
|
||||
|
||||
if (!$id) { exit(json_encode(['success' => 0, 'message' => 'ID required'])); }
|
||||
|
||||
$items = json_decode($data['items'] ?? '[]', true) ?: [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
[$subtotal, $grand] = compute_totals($items, $discount, $tax);
|
||||
|
||||
$sql = "UPDATE ac_quotation SET
|
||||
contact_id = :contact_id,
|
||||
quotation_date = :qdate,
|
||||
valid_until = :valid_until,
|
||||
subtotal = :sub,
|
||||
discount = :disc,
|
||||
tax = :tax,
|
||||
grand_total = :grand,
|
||||
items = :items,
|
||||
notes = :notes
|
||||
WHERE id = :id AND company_id = :cid AND status = 0";
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':qdate' => $data['quotation_date'] ?: null,
|
||||
':valid_until' => $data['valid_until'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':grand' => $grand,
|
||||
':items' => json_encode($items),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':id' => $id,
|
||||
':cid' => $company_id,
|
||||
]);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Quotation updated';
|
||||
|
||||
} else {
|
||||
$answer['message'] = 'Unknown action';
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
$new_id = $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging);
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Quotation created.';
|
||||
$answer['new_id'] = $new_id;
|
||||
} elseif ($action === 'update') {
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
$qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging);
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Quotation updated.';
|
||||
} else {
|
||||
throw new Exception('Unknown action.');
|
||||
}
|
||||
} 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,40 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||
require '../../../assets/utils/classes/OrderManager.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) {
|
||||
$order = new OrderManager($pdo, $company_id);
|
||||
$order->assertRevenueOrder($order_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));
|
||||
?>
|
||||
@@ -1,19 +1,14 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/QuotationManager.php';
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 0) AS draft,
|
||||
SUM(status = 1) AS sent,
|
||||
SUM(status = 2) AS accepted,
|
||||
SUM(status = 3) AS rejected
|
||||
FROM ac_quotation
|
||||
WHERE company_id = :cid";
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([':cid' => $company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
try {
|
||||
$quotation = new QuotationManager($pdo2, $company_id);
|
||||
$answer['output'] = $quotation->getStats();
|
||||
$answer['success'] = 1;
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = $row;
|
||||
exit(json_encode($answer));
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/QuotationManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$qm = new QuotationManager($pdo2, $company_id);
|
||||
|
||||
if ($id) {
|
||||
// Single quotation
|
||||
$sql = "SELECT q.*, c.contact_name
|
||||
FROM ac_quotation q
|
||||
LEFT JOIN md_contact c ON c.id = q.contact_id
|
||||
WHERE q.id = :id AND q.company_id = :cid
|
||||
LIMIT 1";
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([':id' => $id, ':cid' => $company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { exit(json_encode(['success' => 0, 'message' => 'Not found'])); }
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true);
|
||||
$row = $qm->getById($id);
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
exit(json_encode(['success' => 0, 'message' => 'Not found.']));
|
||||
}
|
||||
$answer['output'] = $row;
|
||||
} else {
|
||||
// List
|
||||
$sql = "SELECT q.*, c.contact_name
|
||||
FROM ac_quotation q
|
||||
LEFT JOIN md_contact c ON c.id = q.contact_id
|
||||
WHERE q.company_id = :cid
|
||||
ORDER BY q.id DESC";
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute([':cid' => $company_id]);
|
||||
$answer['output'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$answer['output'] = $qm->getList();
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
|
||||
@@ -1,42 +1,27 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../../../assets/utils/db_auth.php';
|
||||
require '../../../assets/utils/classes/QuotationManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$action = $data['action_type'] ?? '';
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$action = $data['action_type'] ?? '';
|
||||
|
||||
if (!$id || !$action) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'ID and action required']));
|
||||
exit(json_encode(['success' => 0, 'message' => 'ID and action are required.']));
|
||||
}
|
||||
|
||||
// Load current status
|
||||
$sth = $pdo2->prepare("SELECT status FROM ac_quotation WHERE id = :id AND company_id = :cid LIMIT 1");
|
||||
$sth->execute([':id' => $id, ':cid' => $company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { exit(json_encode(['success' => 0, 'message' => 'Not found'])); }
|
||||
$qm = new QuotationManager($pdo2, $company_id);
|
||||
|
||||
$cur = (int)$row['status'];
|
||||
|
||||
$transitions = [
|
||||
'send' => ['from' => [0], 'to' => 1, 'label' => 'Sent'],
|
||||
'accept' => ['from' => [1], 'to' => 2, 'label' => 'Accepted'],
|
||||
'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'],
|
||||
'reopen' => ['from' => [1,2,3],'to' => 0, 'label' => 'Draft'],
|
||||
'cancel' => ['from' => [0,1], 'to' => -1,'label' => 'Cancelled'],
|
||||
];
|
||||
|
||||
if (!isset($transitions[$action])) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Invalid action']));
|
||||
try {
|
||||
$qm->updateStatus($id, $action, $logging);
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = '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);
|
||||
}
|
||||
|
||||
$t = $transitions[$action];
|
||||
if (!in_array($cur, $t['from'], true)) {
|
||||
exit(json_encode(['success' => 0, 'message' => 'Transition not allowed from current status']));
|
||||
}
|
||||
|
||||
$sth = $pdo2->prepare("UPDATE ac_quotation SET status = :status WHERE id = :id AND company_id = :cid");
|
||||
$sth->execute([':status' => $t['to'], ':id' => $id, ':cid' => $company_id]);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Status updated to ' . $t['label'];
|
||||
exit(json_encode($answer));
|
||||
|
||||
+400
-6
@@ -11,18 +11,176 @@
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row mb-6">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="fs-3 mb-1">Sales Invoices</h1>
|
||||
<p class="mb-0 text-muted">Sales invoice management</p>
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Sales Invoices</h1>
|
||||
<p class="mb-0 text-muted">All sales invoices and credit notes</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>revenue/manage_credit_note.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i>New Credit Note
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||
<i class="ti ti-file-invoice fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Invoices</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_invoice">—</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">Paid</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_paid">—</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">Open</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_open">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||
<i class="ti ti-file-minus fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Credit Notes</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_cn">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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-4">
|
||||
<div class="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search invoice number, 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-2">
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="0">Draft</option>
|
||||
<option value="1">Issued</option>
|
||||
<option value="2">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="4">Void</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select id="filter_dept" class="form-select">
|
||||
<option value="">All Departments</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary w-100" onclick="retrieve_invoices()">
|
||||
<i class="ti ti-search me-1"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card p-5 text-center text-muted">
|
||||
<i class="ti ti-file-invoice fs-1 mb-3 d-block"></i>
|
||||
<p class="mb-0">Sales Invoices coming soon.</p>
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered" id="invoice_list">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Order #</th>
|
||||
<th>Contact</th>
|
||||
<th>Department</th>
|
||||
<th>Issued Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Grand Total</th>
|
||||
<th>GL Formula</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
|
||||
<table class="table table-hover mb-0 table-centered d-none" id="cn_list">
|
||||
<thead class="table-warning border-light">
|
||||
<tr>
|
||||
<th>CN #</th>
|
||||
<th>Order #</th>
|
||||
<th>Ref Invoice #</th>
|
||||
<th>Contact</th>
|
||||
<th>Department</th>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>GL Formula</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,5 +190,241 @@
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var current_tab = <?php echo json_encode($_GET['tab'] ?? 'invoice'); ?>;
|
||||
var all_invoices = [];
|
||||
var formula_map = {};
|
||||
|
||||
function is_invoice_overdue(status, due_date) {
|
||||
if (String(status) !== '1' || !due_date) return false;
|
||||
var d = new Date();
|
||||
var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
return due_date < today;
|
||||
}
|
||||
|
||||
function invoice_status_badge(status, due_date, payment_state, doc_type) {
|
||||
if (payment_state === 'partial') {
|
||||
return '<span class="badge bg-warning text-dark">Partial</span>';
|
||||
}
|
||||
if (payment_state === 'paid') {
|
||||
return doc_type === 'credit_note'
|
||||
? '<span class="badge bg-success">Settled</span>'
|
||||
: '<span class="badge bg-success">Paid</span>';
|
||||
}
|
||||
if (is_invoice_overdue(status, due_date)) {
|
||||
return '<span class="badge bg-danger">Overdue</span>';
|
||||
}
|
||||
const map = {
|
||||
'0': '<span class="badge bg-secondary">Draft</span>',
|
||||
'1': '<span class="badge bg-info text-white">Issued</span>',
|
||||
'2': '<span class="badge bg-success">Paid</span>',
|
||||
'4': '<span class="badge bg-light text-dark">Void</span>',
|
||||
};
|
||||
return map[String(status)] || '—';
|
||||
}
|
||||
|
||||
function switch_tab(tab) {
|
||||
current_tab = tab;
|
||||
$('#invoice_tabs .nav-link').each(function() {
|
||||
var active = $(this).attr('onclick').includes("'" + tab + "'");
|
||||
$(this).toggleClass('active', active);
|
||||
});
|
||||
$('#invoice_list').toggleClass('d-none', tab !== 'invoice');
|
||||
$('#cn_list').toggleClass('d-none', tab !== 'credit_note');
|
||||
|
||||
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="overdue">Overdue</option>
|
||||
<option value="4">Void</option>
|
||||
`);
|
||||
}
|
||||
|
||||
retrieve_invoices();
|
||||
}
|
||||
|
||||
function update_stats() {
|
||||
$('#stat_invoice').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice').length));
|
||||
$('#stat_paid').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) === '2').length));
|
||||
$('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status))).length));
|
||||
$('#stat_cn').text(format_number(all_invoices.filter(i => i.doc_type === 'credit_note').length));
|
||||
}
|
||||
|
||||
function load_formula_map() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: {},
|
||||
onSuccess: function(res) {
|
||||
formula_map = {};
|
||||
$.each(res.output || [], function(i, f) {
|
||||
formula_map[f.id] = f.formula_name;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retrieve_stats_source() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { doc_type: '' },
|
||||
onSuccess: function(res) {
|
||||
all_invoices = res.output || [];
|
||||
update_stats();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 status = $('#filter_status').val();
|
||||
var fil_dept = $('#filter_dept').val();
|
||||
|
||||
var data = all.filter(function(inv) {
|
||||
var match_kw = !keyword ||
|
||||
(inv.invoice_number || '').toLowerCase().includes(keyword) ||
|
||||
(inv.order_number || '').toLowerCase().includes(keyword) ||
|
||||
(inv.contact_name || '').toLowerCase().includes(keyword);
|
||||
var match_status = status === ''
|
||||
|| String(inv.status) === status
|
||||
|| (status === 'overdue' && is_invoice_overdue(inv.status, inv.due_date));
|
||||
var match_dept = !fil_dept || String(inv.department_id) === fil_dept;
|
||||
return match_kw && match_status && match_dept;
|
||||
});
|
||||
|
||||
if (current_tab === 'invoice') {
|
||||
render_invoice_table(data);
|
||||
} else {
|
||||
render_cn_table(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_invoice_table(data) {
|
||||
alasql('DROP TABLE IF EXISTS invoice_list');
|
||||
alasql('CREATE TABLE invoice_list');
|
||||
alasql.tables.invoice_list.data = data;
|
||||
$('table#invoice_list tfoot').html(generate_pagination('invoice_list', data.length));
|
||||
change_page_invoice_list(1);
|
||||
}
|
||||
|
||||
function change_page_invoice_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var rows = alasql(`SELECT * FROM invoice_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#invoice_list tbody').html('<tr><td colspan="10" class="text-center py-5 text-muted">No invoices found.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
$.each(rows, function(i, inv) {
|
||||
var formula_name = inv.formula_id && formula_map[inv.formula_id]
|
||||
? escape_html(formula_map[inv.formula_id])
|
||||
: '<span class="text-muted">—</span>';
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(inv.invoice_number || '')}</td>
|
||||
<td class="py-3">${inv.order_number ? escape_html(inv.order_number) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${inv.contact_name ? escape_html(inv.contact_name) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${get_dept_label(inv.department_id)}</td>
|
||||
<td class="py-3">${inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3 fw-semibold">${format_number(inv.grand_total, 2)}</td>
|
||||
<td class="py-3 small">${formula_name}</td>
|
||||
<td class="py-3">${invoice_status_badge(inv.status, inv.due_date, inv.payment_state, inv.doc_type)}</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>revenue/manage_invoice.php?id=${inv.id}" title="View">
|
||||
<i class="ti ti-eye fs-5"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#invoice_list tbody').html(body);
|
||||
}
|
||||
|
||||
function render_cn_table(data) {
|
||||
alasql('DROP TABLE IF EXISTS cn_list');
|
||||
alasql('CREATE TABLE cn_list');
|
||||
alasql.tables.cn_list.data = data;
|
||||
$('table#cn_list tfoot').html(generate_pagination('cn_list', data.length));
|
||||
change_page_cn_list(1);
|
||||
}
|
||||
|
||||
function change_page_cn_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var rows = alasql(`SELECT * FROM cn_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#cn_list tbody').html('<tr><td colspan="10" class="text-center py-5 text-muted">No credit notes found.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
$.each(rows, function(i, cn) {
|
||||
var formula_name = cn.formula_id && formula_map[cn.formula_id]
|
||||
? escape_html(formula_map[cn.formula_id])
|
||||
: '<span class="text-muted">—</span>';
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(cn.invoice_number || '')}</td>
|
||||
<td class="py-3">${cn.order_number ? escape_html(cn.order_number) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${cn.ref_invoice_number ? escape_html(cn.ref_invoice_number) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${cn.contact_name ? escape_html(cn.contact_name) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${get_dept_label(cn.department_id)}</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 text-danger">${format_number(cn.grand_total, 2)}</td>
|
||||
<td class="py-3 small">${formula_name}</td>
|
||||
<td class="py-3">${invoice_status_badge(cn.status, cn.due_date, cn.payment_state, cn.doc_type)}</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>revenue/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);
|
||||
}
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
await load_dept_cache();
|
||||
populate_dept_filter('filter_dept');
|
||||
await retrieve_stats_source();
|
||||
await load_formula_map();
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_invoices();
|
||||
});
|
||||
$('#filter_status, #filter_dept').on('change', retrieve_invoices);
|
||||
if (current_tab !== 'invoice') {
|
||||
switch_tab(current_tab);
|
||||
} else {
|
||||
await retrieve_invoices();
|
||||
}
|
||||
} catch(e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$ref_invoice_id = (int)($_GET['ref_invoice_id'] ?? 0);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_ac.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<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">New Credit Note</h1>
|
||||
<p class="mb-0 text-muted">Accounting credit note without stock movement</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>revenue/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">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Credit Note Information</h2>
|
||||
<div class="row gx-5">
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Reference Invoice ID</label>
|
||||
<input type="number" id="ref_invoice_id" class="form-control"
|
||||
value="<?php echo $ref_invoice_id ?: ''; ?>" min="0" placeholder="Optional">
|
||||
</div>
|
||||
<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-12">
|
||||
<label class="form-label">Reason / Notes</label>
|
||||
<textarea id="notes" class="form-control" rows="3"
|
||||
placeholder="Billing correction, service credit, tax adjustment..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fs-5 mb-0">Credit Lines</h2>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="add_item_row()">
|
||||
<i class="ti ti-plus me-1"></i>Add Line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th style="width:100px;">Qty</th>
|
||||
<th style="width:130px;">Unit Price</th>
|
||||
<th style="width:80px;">Tax%</th>
|
||||
<th style="width:120px;" class="text-end">Total</th>
|
||||
<th style="width:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<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">Tax</span>
|
||||
<span id="summary_tax">0.00</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted small">Tax Adj.</span>
|
||||
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
|
||||
style="width:90px;" min="-0.3" max="0.3" step="0.01" value="0" oninput="recalc_totals()">
|
||||
</div>
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between fw-bold fs-5">
|
||||
<span>Credit Total</span>
|
||||
<span id="summary_grand_total" class="text-danger">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
<button class="btn btn-primary w-100" onclick="save_credit_note()">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save Draft CN
|
||||
</button>
|
||||
<a href="<?php echo $server_url?>revenue/invoice.php" class="btn btn-light w-100">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var item_index = 0;
|
||||
|
||||
function add_item_row(item) {
|
||||
item = item || {};
|
||||
var idx = item_index++;
|
||||
var row = `<tr id="item_row_${idx}">
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm item_desc"
|
||||
value="${escape_html(item.product_name || '')}" placeholder="Credit description">
|
||||
</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>
|
||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
||||
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||
</td>
|
||||
<td class="item_total text-end align-middle">0.00</td>
|
||||
<td class="align-middle">
|
||||
<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();
|
||||
}
|
||||
|
||||
function recalc_totals() {
|
||||
var subtotal = 0, tax_sum = 0;
|
||||
$('#item_tbody tr').each(function() {
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
var total = qty * price;
|
||||
subtotal += total;
|
||||
tax_sum += total * tax_rate / 100;
|
||||
$(this).find('.item_total').text(format_number(total, 2));
|
||||
});
|
||||
var tax_adj = parseFloat($('#tax_adjustment').val()) || 0;
|
||||
var tax = round_dp(tax_sum, 2) + tax_adj;
|
||||
$('#summary_subtotal').text(format_number(subtotal, 2));
|
||||
$('#summary_tax').text(format_number(tax, 2));
|
||||
$('#summary_grand_total').text('-' + format_number(subtotal + tax, 2));
|
||||
}
|
||||
|
||||
function collect_items() {
|
||||
var items = [];
|
||||
$('#item_tbody tr').each(function() {
|
||||
var desc = $(this).find('.item_desc').val().trim();
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
if (!desc && qty <= 0) return;
|
||||
items.push({
|
||||
product_sku: '',
|
||||
product_name: desc,
|
||||
quantity: qty,
|
||||
price: price,
|
||||
tax_rate: tax_rate,
|
||||
tax_amount: round_dp(qty * price * tax_rate / 100, 4),
|
||||
total_price: round_dp(qty * price, 4),
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function load_ref_invoice() {
|
||||
var ref_id = parseInt($('#ref_invoice_id').val()) || 0;
|
||||
if (!ref_id) return;
|
||||
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/retrieve_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { invoice_id: ref_id },
|
||||
onSuccess: function(res) {
|
||||
var inv = res.output || {};
|
||||
if (inv.doc_type !== 'invoice') return;
|
||||
$('#contact').val(inv.contact_name || '');
|
||||
$('#contact_id').val(inv.contact_id || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save_credit_note() {
|
||||
var items = collect_items();
|
||||
if (!items.length) {
|
||||
bootbox.alert('Please add at least one credit line.');
|
||||
return;
|
||||
}
|
||||
if (!$('#contact_id').val()) {
|
||||
bootbox.alert('Please select a contact.');
|
||||
return;
|
||||
}
|
||||
if (!$('#notes').val().trim()) {
|
||||
bootbox.alert('Please enter the credit note reason.');
|
||||
return;
|
||||
}
|
||||
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>revenue/api/engine/manage_credit_note.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'create',
|
||||
data: {
|
||||
ref_invoice_id: $('#ref_invoice_id').val() || 0,
|
||||
contact_id: $('#contact_id').val() || 0,
|
||||
items: JSON.stringify(items),
|
||||
notes: $('#notes').val(),
|
||||
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (res.new_id) {
|
||||
window.location.href = '<?php echo $server_url?>revenue/manage_invoice.php?id=' + res.new_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('contact:selected', '#contact', function(e, contact) {
|
||||
$('#contact_id').val(contact.id);
|
||||
});
|
||||
|
||||
$(function() {
|
||||
add_item_row();
|
||||
load_ref_invoice();
|
||||
$('#ref_invoice_id').on('change', load_ref_invoice);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,392 @@
|
||||
<?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_ac.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<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">Revenue invoice management</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>revenue/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">
|
||||
<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" style="width:60px;">Tax%</th>
|
||||
<th class="text-end">Tax Amt</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody">
|
||||
<tr><td colspan="4" class="text-center py-5 text-muted">Loading invoice...</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="item_tfoot"></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">Document Info</h2>
|
||||
<div class="mb-3 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>
|
||||
<span class="ms-1 fw-semibold" id="display_order">—</span>
|
||||
</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>
|
||||
<span class="ms-1" id="display_due">—</span>
|
||||
<input type="text" id="due_date" class="form-control form-control-sm mt-1 d-none"
|
||||
placeholder="DD/MM/YYYY" autocomplete="off">
|
||||
</div>
|
||||
<div class="mt-3 small">
|
||||
<span class="text-muted">GL Formula:</span>
|
||||
<div class="d-flex gap-1 mt-1">
|
||||
<select id="formula_id" class="form-select form-select-sm">
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary btn-sm px-2" id="btn_save_formula"
|
||||
onclick="save_formula()" title="Save formula">
|
||||
<i class="ti ti-device-floppy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 small">
|
||||
<span class="text-muted">Notes:</span>
|
||||
<div class="mt-1" id="display_notes">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
<button class="btn btn-outline-secondary w-100" onclick="print_invoice()">
|
||||
<i class="ti ti-printer me-1"></i>Print / Save PDF
|
||||
</button>
|
||||
<button class="btn btn-primary w-100 d-none" id="btn_save" onclick="save_invoice()">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save
|
||||
</button>
|
||||
<button class="btn btn-success w-100 d-none" id="btn_issue" onclick="issue_invoice()">
|
||||
<i class="ti ti-send me-1"></i>Issue Invoice
|
||||
</button>
|
||||
<a href="#" class="btn btn-outline-warning w-100 d-none" id="btn_create_cn">
|
||||
<i class="ti ti-file-minus me-1"></i>Create Credit Note
|
||||
</a>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="void_invoice()">
|
||||
<i class="ti ti-ban me-1"></i>Void Invoice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
var invoice_id = <?php echo $invoice_id; ?>;
|
||||
|
||||
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>',
|
||||
'supplier_credit_note': '<span class="badge bg-info text-white">Supplier Credit Note</span>',
|
||||
};
|
||||
return map[doc_type] || '—';
|
||||
}
|
||||
|
||||
function is_invoice_overdue(status, due_date) {
|
||||
if (String(status) !== '1' || !due_date) return false;
|
||||
var d = new Date();
|
||||
var today = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
return due_date < today;
|
||||
}
|
||||
|
||||
function invoice_status_badge(status, due_date, payment_state, doc_type) {
|
||||
if (payment_state === 'partial') {
|
||||
return '<span class="badge bg-warning text-dark">Partial</span>';
|
||||
}
|
||||
if (payment_state === 'paid') {
|
||||
return doc_type === 'credit_note'
|
||||
? '<span class="badge bg-success">Settled</span>'
|
||||
: '<span class="badge bg-success">Paid</span>';
|
||||
}
|
||||
if (is_invoice_overdue(status, due_date)) {
|
||||
return '<span class="badge bg-danger">Overdue</span>';
|
||||
}
|
||||
const map = {
|
||||
'0': '<span class="badge bg-secondary">Draft</span>',
|
||||
'1': '<span class="badge bg-info text-white">Issued</span>',
|
||||
'2': '<span class="badge bg-success">Paid</span>',
|
||||
'4': '<span class="badge bg-light text-dark">Void</span>',
|
||||
};
|
||||
return map[String(status)] || '—';
|
||||
}
|
||||
|
||||
function display_text(value) {
|
||||
return value ? escape_html(value) : '<span class="text-muted">—</span>';
|
||||
}
|
||||
|
||||
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) {
|
||||
var inv = res.output || {};
|
||||
|
||||
$('#page_title').text(inv.invoice_number || 'Invoice Detail');
|
||||
$('#page_subtitle').text(inv.doc_type === 'credit_note' ? 'Revenue credit note management' : 'Revenue invoice management');
|
||||
$('#badge_doc_type').html(doc_type_badge(inv.doc_type));
|
||||
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date, inv.payment_state, inv.doc_type));
|
||||
$('#display_order').html(inv.order_number ? escape_html(inv.order_number) : '<span class="text-muted">—</span>');
|
||||
$('#display_contact').html(display_text(inv.contact_name));
|
||||
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
|
||||
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
|
||||
|
||||
var rows = '';
|
||||
$.each(inv.items || [], function(i, item) {
|
||||
var name = item.product_name || item.product_sku || '';
|
||||
rows += `<tr>
|
||||
<td>${escape_html(name)}</td>
|
||||
<td class="text-end">${format_number(item.quantity, 2)}</td>
|
||||
<td class="text-end">${format_number(item.unit_price, 2)}</td>
|
||||
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
|
||||
<td class="text-end">${format_number(item.tax_amount, 4)}</td>
|
||||
<td class="text-end">${format_number(item.total_price, 2)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('#item_tbody').html(rows || '<tr><td colspan="6" class="text-center py-5 text-muted">No items found.</td></tr>');
|
||||
|
||||
var is_draft = parseInt(inv.status) === 0;
|
||||
var is_editable_doc = inv.doc_type === 'invoice' || inv.doc_type === 'credit_note';
|
||||
var tax_adj_val = parseFloat(inv.tax_adjustment) || 0;
|
||||
var tax_adj_cell = (is_draft && is_editable_doc)
|
||||
? `<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end d-inline-block" style="width:90px;" min="-0.3" max="0.3" step="0.01" value="${tax_adj_val.toFixed(2)}">`
|
||||
: `${format_number(tax_adj_val, 2)}`;
|
||||
|
||||
var tfoot = `
|
||||
<tr><td colspan="5" class="text-muted border-bottom-0">Subtotal</td><td class="text-end border-bottom-0">${format_number(inv.subtotal, 2)}</td></tr>
|
||||
<tr><td colspan="5" class="text-muted border-bottom-0">Discount</td><td class="text-end border-bottom-0">${format_number(inv.discount, 2)}</td></tr>
|
||||
<tr><td colspan="5" class="text-muted border-bottom-0">Tax</td><td class="text-end border-bottom-0">${format_number(inv.tax, 2)}</td></tr>
|
||||
<tr><td colspan="5" class="text-muted border-bottom-0">Tax Adj.</td><td class="text-end border-bottom-0">${tax_adj_cell}</td></tr>
|
||||
<tr><td colspan="5" class="text-muted">Shipping</td><td class="text-end">${format_number(inv.shipping_fee, 2)}</td></tr>
|
||||
<tr><td colspan="5" class="fw-bold">Grand Total</td><td class="text-end fw-bold">${format_number(inv.grand_total, 2)}</td></tr>`;
|
||||
$('#item_tfoot').html(tfoot);
|
||||
|
||||
if (res.credit_notes && res.credit_notes.length) {
|
||||
var cn_rows = '';
|
||||
$.each(res.credit_notes, function(i, cn) {
|
||||
cn_rows += `<tr>
|
||||
<td>${escape_html(cn.invoice_number || '')}</td>
|
||||
<td>${cn.issued_date ? 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, cn.due_date, cn.payment_state, cn.doc_type)}</td>
|
||||
<td><a href="<?php echo $server_url?>revenue/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');
|
||||
}
|
||||
|
||||
var gl_type = inv.doc_type === 'credit_note' ? 'credit_note' : 'invoice';
|
||||
var selected_formula = inv.formula_id || '';
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>accounting/api/engine/account_formula.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'get_by_type',
|
||||
data: { document_type: gl_type },
|
||||
onSuccess: function(fres) {
|
||||
var opts = '<option value="">— None —</option>';
|
||||
$.each(fres.output || [], function(i, f) {
|
||||
var sel = String(f.id) === String(selected_formula) ? ' selected' : '';
|
||||
opts += '<option value="' + f.id + '"' + sel + '>' + escape_html(f.formula_name) + '</option>';
|
||||
});
|
||||
$('#formula_id').html(opts);
|
||||
}
|
||||
});
|
||||
|
||||
set_action_mode(inv);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
|
||||
},
|
||||
onSuccess: function() { retrieve_invoice(); }
|
||||
});
|
||||
}
|
||||
|
||||
function set_action_mode(inv) {
|
||||
var is_invoice = inv.doc_type === 'invoice';
|
||||
var is_credit_note = inv.doc_type === 'credit_note';
|
||||
var status = parseInt(inv.status);
|
||||
$('#btn_save_formula').prop('disabled', status === 4);
|
||||
|
||||
$('#btn_save').toggleClass('d-none', !((is_invoice || is_credit_note) && status === 0));
|
||||
|
||||
$('#btn_issue')
|
||||
.toggleClass('d-none', !((is_invoice || is_credit_note) && status === 0))
|
||||
.html(is_credit_note
|
||||
? '<i class="ti ti-send me-1"></i>Issue Credit Note'
|
||||
: '<i class="ti ti-send me-1"></i>Issue Invoice');
|
||||
$('#btn_create_cn')
|
||||
.toggleClass('d-none', !(is_invoice && status >= 1 && status !== 4))
|
||||
.attr('href', '<?php echo $server_url?>revenue/manage_credit_note.php?ref_invoice_id=' + invoice_id);
|
||||
$('#btn_void').toggleClass('d-none', !((is_invoice || is_credit_note) && status >= 1 && status !== 4));
|
||||
$('#due_date').toggleClass('d-none', !(is_invoice && status === 0));
|
||||
$('#display_due').toggleClass('d-none', is_invoice && status === 0);
|
||||
}
|
||||
|
||||
function issue_invoice() {
|
||||
var due_date = $('#due_date').val().trim();
|
||||
var is_credit_note = $('#page_subtitle').text().toLowerCase().includes('credit note');
|
||||
if (!is_credit_note && !due_date) {
|
||||
bootbox.alert('Please enter a due date before issuing this invoice.');
|
||||
return;
|
||||
}
|
||||
bootbox.confirm({
|
||||
message: 'Issue this invoice?',
|
||||
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: {
|
||||
id: invoice_id,
|
||||
due_date: due_date ? due_date.split('/').reverse().join('-') : ''
|
||||
},
|
||||
onSuccess: function() { retrieve_invoice(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function void_invoice() {
|
||||
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: invoice_id, status: 4 },
|
||||
onSuccess: function() { retrieve_invoice(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function print_invoice() {
|
||||
if (!invoice_id) return;
|
||||
window.open('<?php echo $server_url?>order/print_invoice.php?id=' + invoice_id, '_blank');
|
||||
}
|
||||
|
||||
function save_formula() {
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'save_formula',
|
||||
data: { id: invoice_id, formula_id: $('#formula_id').val() || null },
|
||||
onSuccess: function() {
|
||||
bootbox.alert({ message: 'GL formula saved.', size: 'small' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(function() {
|
||||
if (invoice_id) retrieve_invoice();
|
||||
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$order_id = (int)($_GET['id'] ?? 0);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_ac.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<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 ? 'Sales Order Detail' : 'New Sales Order'; ?>
|
||||
</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle">
|
||||
<?php echo $order_id ? '' : 'Create a pending sales order for WMS warehouse assignment'; ?>
|
||||
</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>revenue/order.php" class="btn btn-light">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
<div class="col-lg-8">
|
||||
<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>
|
||||
|
||||
<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 style="width:120px;">SKU</th>
|
||||
<th>Description</th>
|
||||
<th style="width:90px;">Qty</th>
|
||||
<th style="width:120px;">Unit Price</th>
|
||||
<th style="width:80px;">Tax%</th>
|
||||
<th style="width:120px;">Total</th>
|
||||
<th style="width:40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="item_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<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 id="badge_status" class="mb-2"></div>
|
||||
<p class="mb-0 small fw-semibold text-muted" id="order_number_display"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-5" id="wms_status_alert">
|
||||
<div class="d-flex gap-2">
|
||||
<i class="ti ti-building-warehouse mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-semibold" id="wms_status_title">Pending WMS assignment</div>
|
||||
<div class="small" id="wms_status_message">
|
||||
This order will be sent to WMS as pending. WMS staff assign warehouses before confirmation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 align-items-center 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" step="any" oninput="recalc_totals()">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Tax</span>
|
||||
<span id="summary_tax" class="fw-semibold">0.00</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted small">Tax Adj.</span>
|
||||
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
|
||||
style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center 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" step="any" 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>
|
||||
|
||||
<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_order()">
|
||||
<i class="ti ti-device-floppy me-1"></i>Save Pending SO
|
||||
</button>
|
||||
<button class="btn btn-outline-primary w-100 d-none" id="btn_invoice" onclick="proceed_to_invoice()">
|
||||
<i class="ti ti-file-invoice me-1"></i>Create Invoice
|
||||
</button>
|
||||
<a href="#" class="btn btn-outline-primary w-100 d-none" id="link_invoice">
|
||||
<i class="ti ti-file-invoice me-1"></i>View Invoice
|
||||
</a>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_cancel" onclick="cancel_order()">
|
||||
<i class="ti ti-x me-1"></i>Cancel Order
|
||||
</button>
|
||||
<a href="<?php echo $server_url?>revenue/order.php" class="btn btn-light w-100">
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
// custom.js globals: remove_item(), init_product_search_inputs(), escape_html(), format_number()
|
||||
var order_id = <?php echo $order_id; ?>;
|
||||
var order_data = null;
|
||||
var item_index = 0;
|
||||
|
||||
var status_badge = {
|
||||
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
|
||||
'-1': '<span class="badge bg-secondary">Cancelled</span>',
|
||||
'0': '<span class="badge bg-warning text-dark">Draft</span>',
|
||||
'1': '<span class="badge bg-info text-white">Confirmed</span>',
|
||||
};
|
||||
|
||||
function recalc_totals() {
|
||||
var subtotal = 0;
|
||||
var tax_sum = 0;
|
||||
$('#item_tbody tr').each(function() {
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
var total = qty * price;
|
||||
var tax_amt = round_dp(total * tax_rate / 100, 4);
|
||||
$(this).find('.item_total').text(format_number(total, 2));
|
||||
$(this).find('.item_tax_amount').val(tax_amt);
|
||||
subtotal += total;
|
||||
tax_sum += tax_amt;
|
||||
});
|
||||
|
||||
var discount = parseFloat($('#discount').val()) || 0;
|
||||
var tax_adjustment = parseFloat($('#tax_adjustment').val()) || 0;
|
||||
var shipping = parseFloat($('#shipping_fee').val()) || 0;
|
||||
var derived_tax = round_dp(tax_sum, 2);
|
||||
$('#summary_subtotal').text(format_number(subtotal, 2));
|
||||
$('#summary_tax').text(format_number(derived_tax, 2));
|
||||
$('#summary_grand_total').text(format_number(subtotal - discount + derived_tax + tax_adjustment + shipping, 2));
|
||||
}
|
||||
|
||||
function add_item_row(item) {
|
||||
item = item || {};
|
||||
var idx = item_index++;
|
||||
var row = `<tr id="item_row_${idx}">
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm item_sku product_search"
|
||||
placeholder="SKU..." value="${escape_html(item.product_sku || '')}" autocomplete="off"
|
||||
data-product-name-target=".item_desc">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm item_desc"
|
||||
placeholder="Description..." value="${escape_html(item.product_name || item.description || '')}" autocomplete="off">
|
||||
</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.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
||||
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||
</td>
|
||||
<td class="item_total text-end align-middle">
|
||||
${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<a href="javascript:void(0);" class="link-danger remove_item_btn" onclick="remove_item(${idx})">
|
||||
<i class="ti ti-trash fs-5"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
$('#item_tbody').append(row);
|
||||
if (window.init_product_search_inputs) {
|
||||
window.init_product_search_inputs(`#item_row_${idx}`);
|
||||
}
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
|
||||
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_desc').val().trim();
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
|
||||
if (!sku || qty <= 0) { idx++; return; }
|
||||
items.push({
|
||||
item_id: idx,
|
||||
product_sku: sku,
|
||||
product_name: name,
|
||||
warehouse_id: 0,
|
||||
zone: '',
|
||||
aisle: '',
|
||||
rack: '',
|
||||
lot_number: '',
|
||||
serial_number: '',
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
total_price: round_dp(qty * price, 4),
|
||||
tax_rate: tax_rate,
|
||||
tax_amount: tax_amt,
|
||||
stock_out_id: 0,
|
||||
});
|
||||
idx++;
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function set_mode(status, has_active_invoice, active_invoice_id) {
|
||||
status = parseInt(status);
|
||||
has_active_invoice = has_active_invoice || false;
|
||||
active_invoice_id = parseInt(active_invoice_id) || 0;
|
||||
var editable = status === -2;
|
||||
$('#contact, #order_date, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
|
||||
.prop('disabled', !editable);
|
||||
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
|
||||
$('.remove_item_btn').toggleClass('d-none', !editable);
|
||||
$('#btn_save').toggleClass('d-none', !editable);
|
||||
|
||||
if (status === -2) {
|
||||
$('#wms_status_title').text('Pending WMS assignment');
|
||||
$('#wms_status_message').text('This order is pending. WMS staff will assign warehouses before confirmation.');
|
||||
} else if (status === 0) {
|
||||
$('#wms_status_title').text('Managed on WMS side');
|
||||
$('#wms_status_message').text('This order is now Draft in WMS. Continue warehouse assignment and confirmation from the WMS order screen.');
|
||||
} else {
|
||||
$('#wms_status_title').text('Managed on WMS side');
|
||||
$('#wms_status_message').text('This order has moved beyond Revenue editing. Continue operational updates from the WMS order screen.');
|
||||
}
|
||||
|
||||
$('#btn_cancel').toggleClass('d-none', status !== -2);
|
||||
|
||||
var is_existing = order_id > 0;
|
||||
var can_invoice = is_existing && status >= 1 && status !== -1 && !has_active_invoice;
|
||||
var invoice_title = !is_existing ? 'Save the order before creating an invoice'
|
||||
: status < 1 ? 'WMS must confirm the sales order before accounting can create an invoice'
|
||||
: status === -1 ? 'Cancelled orders cannot be invoiced'
|
||||
: has_active_invoice ? 'An active invoice already exists for this order'
|
||||
: '';
|
||||
|
||||
$('#btn_invoice')
|
||||
.toggleClass('d-none', !is_existing || has_active_invoice)
|
||||
.prop('disabled', !can_invoice)
|
||||
.attr('title', invoice_title);
|
||||
|
||||
$('#link_invoice')
|
||||
.toggleClass('d-none', !has_active_invoice || !active_invoice_id)
|
||||
.attr('href', active_invoice_id
|
||||
? '<?php echo $server_url?>revenue/manage_invoice.php?id=' + active_invoice_id
|
||||
: '#');
|
||||
}
|
||||
|
||||
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;
|
||||
if (!['revenue', 'quotation'].includes(String(o.source || ''))) {
|
||||
bootbox.alert('This is not a Revenue sales order.');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#page_title').text(o.order_number);
|
||||
$('#page_subtitle').text(parseInt(o.status) === -2 ? 'Pending sales order' : 'View sales order');
|
||||
$('#status_card').removeClass('d-none');
|
||||
$('#badge_status').html(status_badge[String(o.status)] || o.status);
|
||||
$('#order_number_display').text(o.order_number);
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id || 0);
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
||||
$('#shipping_fee').val(o.shipping_fee || 0);
|
||||
|
||||
$('#item_tbody').empty();
|
||||
item_index = 0;
|
||||
(o.items || []).forEach(function(item) { add_item_row(item); });
|
||||
|
||||
recalc_totals();
|
||||
set_mode(o.status, res.has_active_invoice, res.active_invoice_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save_order() {
|
||||
var items = collect_items();
|
||||
if (!items.length) {
|
||||
bootbox.alert('Please add at least one line item.');
|
||||
return;
|
||||
}
|
||||
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>revenue/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_adjustment: $('#tax_adjustment').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?>revenue/manage_order.php?id=' + res.new_id;
|
||||
} else {
|
||||
retrieve_order();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function proceed_to_invoice() {
|
||||
if (!order_id) return;
|
||||
bootbox.confirm({
|
||||
message: 'Create an invoice from this sales order?',
|
||||
buttons: {
|
||||
confirm: { label: 'Create Invoice', className: 'btn-primary' },
|
||||
cancel: { label: 'Back', className: 'btn-secondary' }
|
||||
},
|
||||
callback: function(result) {
|
||||
if (!result) return;
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>revenue/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?>revenue/manage_invoice.php?id=' + res.new_id;
|
||||
} else {
|
||||
retrieve_order();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cancel_order() {
|
||||
bootbox.confirm({
|
||||
message: 'Cancel this pending sales order? It will be removed from WMS queue.',
|
||||
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_order.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
data: { id: order_id },
|
||||
onSuccess: function() {
|
||||
retrieve_order();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('contact:selected', '#contact', function(e, contact) {
|
||||
$('#contact_id').val(contact.id);
|
||||
});
|
||||
|
||||
$(document).on('product:selected', '.item_sku', function(e, product) {
|
||||
var $row = $(this).closest('tr');
|
||||
var price = parseFloat(product.price || 0);
|
||||
if (!(parseFloat($row.find('.item_price').val()) > 0) && price > 0) {
|
||||
$row.find('.item_price').val(price);
|
||||
}
|
||||
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
|
||||
recalc_totals();
|
||||
});
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
if (order_id) {
|
||||
await retrieve_order();
|
||||
} else {
|
||||
$('#order_date').val(flatpickr.formatDate(new Date(), 'd/m/Y'));
|
||||
add_item_row();
|
||||
set_mode(-2, false, 0);
|
||||
}
|
||||
flatpickr('#order_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
} catch(e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Contact</label>
|
||||
<input type="text" id="contact" class="form-control"
|
||||
<input type="text" id="contact" class="form-control contact_search"
|
||||
placeholder="Search contact..." autocomplete="off">
|
||||
<input type="hidden" id="contact_id">
|
||||
</div>
|
||||
@@ -62,6 +62,13 @@
|
||||
placeholder="DD/MM/YYYY (optional)" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label class="form-label">Department</label>
|
||||
<select id="department_id" class="form-select">
|
||||
<option value="0">— No Department —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 col-lg-12">
|
||||
<label class="form-label">Notes / Terms</label>
|
||||
<textarea id="notes" class="form-control" rows="2"
|
||||
@@ -91,6 +98,7 @@
|
||||
<th>Description</th>
|
||||
<th style="width:90px;">Qty</th>
|
||||
<th style="width:120px;">Unit Price</th>
|
||||
<th style="width:80px;">Tax%</th>
|
||||
<th style="width:120px;">Total</th>
|
||||
<th style="width:40px;"></th>
|
||||
</tr>
|
||||
@@ -129,10 +137,14 @@
|
||||
<input type="number" id="discount" class="form-control form-control-sm text-end"
|
||||
style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Tax</span>
|
||||
<span id="summary_tax" class="fw-semibold">0.00</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted">Tax (VAT)</span>
|
||||
<input type="number" id="tax" class="form-control form-control-sm text-end"
|
||||
style="width:110px;" value="0" min="0" step="any" oninput="recalc_totals()">
|
||||
<span class="text-muted small">Tax Adj.</span>
|
||||
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
|
||||
style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()">
|
||||
</div>
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between fw-bold fs-5">
|
||||
@@ -193,6 +205,7 @@
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
// custom.js globals: remove_item(), init_product_search_inputs(), escape_html(), format_number()
|
||||
|
||||
var quotation_id = <?php echo $quotation_id; ?>;
|
||||
var quotation_data = null;
|
||||
@@ -211,19 +224,26 @@
|
||||
// ── Recalculate totals ────────────────────────────────────────────────────
|
||||
function recalc_totals() {
|
||||
var subtotal = 0;
|
||||
var tax_sum = 0;
|
||||
$('#item_tbody tr').each(function() {
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var total = qty * price;
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
var total = qty * price;
|
||||
var tax_amt = round_dp(total * tax_rate / 100, 4);
|
||||
$(this).find('.item_total').text(format_number(total, 2));
|
||||
$(this).find('.item_tax_amount').val(tax_amt);
|
||||
subtotal += total;
|
||||
tax_sum += tax_amt;
|
||||
});
|
||||
|
||||
var discount = parseFloat($('#discount').val()) || 0;
|
||||
var tax = parseFloat($('#tax').val()) || 0;
|
||||
var grand = subtotal - discount + tax;
|
||||
var discount = parseFloat($('#discount').val()) || 0;
|
||||
var tax_adjustment = parseFloat($('#tax_adjustment').val()) || 0;
|
||||
var derived_tax = round_dp(tax_sum, 2);
|
||||
var grand = subtotal - discount + derived_tax + tax_adjustment;
|
||||
|
||||
$('#summary_subtotal').text(format_number(subtotal, 2));
|
||||
$('#summary_tax').text(format_number(derived_tax, 2));
|
||||
$('#summary_grand_total').text(format_number(grand, 2));
|
||||
}
|
||||
|
||||
@@ -234,12 +254,13 @@
|
||||
var idx = item_index++;
|
||||
var row = `<tr id="item_row_${idx}">
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm item_sku"
|
||||
placeholder="SKU..." value="${escape_html(item.product_sku || '')}" autocomplete="off">
|
||||
<input type="text" class="form-control form-control-sm item_sku product_search"
|
||||
placeholder="SKU..." value="${escape_html(item.product_sku || '')}" autocomplete="off"
|
||||
data-product-name-target=".item_desc">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm item_desc"
|
||||
placeholder="Description..." value="${escape_html(item.description || '')}" autocomplete="off">
|
||||
placeholder="Description..." value="${escape_html(item.product_name || item.description || '')}" autocomplete="off">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_qty"
|
||||
@@ -249,6 +270,11 @@
|
||||
<input type="number" class="form-control form-control-sm item_price"
|
||||
value="${item.unit_price || 0}" min="0" step="any" oninput="recalc_totals()">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
||||
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||
</td>
|
||||
<td class="item_total text-end align-middle">
|
||||
${format_number((item.quantity || 1) * (item.unit_price || 0), 2)}
|
||||
</td>
|
||||
@@ -259,30 +285,34 @@
|
||||
</td>
|
||||
</tr>`;
|
||||
$('#item_tbody').append(row);
|
||||
if (window.init_product_search_inputs) {
|
||||
window.init_product_search_inputs(`#item_row_${idx}`);
|
||||
}
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
function remove_item(idx) {
|
||||
$(`#item_row_${idx}`).remove();
|
||||
recalc_totals();
|
||||
}
|
||||
|
||||
|
||||
// ── Collect items from DOM ────────────────────────────────────────────────
|
||||
function collect_items() {
|
||||
var items = [];
|
||||
$('#item_tbody tr').each(function() {
|
||||
var sku = $(this).find('.item_sku').val().trim();
|
||||
var desc = $(this).find('.item_desc').val().trim();
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var sku = $(this).find('.item_sku').val().trim();
|
||||
var desc = $(this).find('.item_desc').val().trim();
|
||||
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
|
||||
var price = parseFloat($(this).find('.item_price').val()) || 0;
|
||||
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
|
||||
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
|
||||
if (!desc && !sku && qty === 0) return;
|
||||
items.push({
|
||||
product_sku: sku,
|
||||
description: desc,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
total_price: parseFloat((qty * price).toFixed(4)),
|
||||
product_sku: sku,
|
||||
product_name: desc,
|
||||
description: desc,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
total_price: round_dp(qty * price, 4),
|
||||
tax_rate: tax_rate,
|
||||
tax_amount: tax_amt,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
@@ -300,9 +330,9 @@
|
||||
var is_editable = is_draft;
|
||||
|
||||
// Edit controls
|
||||
$('#contact, #quotation_date, #valid_until, #notes, #discount, #tax, #btn_add_item')
|
||||
$('#contact, #quotation_date, #valid_until, #department_id, #notes, #discount, #tax_adjustment, #btn_add_item')
|
||||
.prop('disabled', !is_editable);
|
||||
$('.item_sku, .item_desc, .item_qty, .item_price').prop('disabled', !is_editable);
|
||||
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !is_editable);
|
||||
$('.remove_item_btn').toggleClass('d-none', !is_editable);
|
||||
|
||||
// Action buttons
|
||||
@@ -343,9 +373,10 @@
|
||||
$('#contact_id').val(q.contact_id || 0);
|
||||
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
|
||||
$('#department_id').val(q.department_id || 0);
|
||||
$('#notes').val(q.notes || '');
|
||||
$('#discount').val(parseFloat(q.discount) || 0);
|
||||
$('#tax').val(parseFloat(q.tax) || 0);
|
||||
$('#tax_adjustment').val(parseFloat(q.tax_adjustment) || 0);
|
||||
|
||||
$('#item_tbody').empty();
|
||||
item_index = 0;
|
||||
@@ -374,12 +405,13 @@
|
||||
data: {
|
||||
id: quotation_id,
|
||||
contact_id: $('#contact_id').val() || 0,
|
||||
department_id: $('#department_id').val() || 0,
|
||||
quotation_date: to_iso_date($('#quotation_date').val()),
|
||||
valid_until: to_iso_date($('#valid_until').val()),
|
||||
items: JSON.stringify(items),
|
||||
discount: $('#discount').val() || 0,
|
||||
tax: $('#tax').val() || 0,
|
||||
notes: $('#notes').val(),
|
||||
discount: $('#discount').val() || 0,
|
||||
tax_adjustment: $('#tax_adjustment').val() || 0,
|
||||
notes: $('#notes').val(),
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!quotation_id && res.new_id) {
|
||||
@@ -441,7 +473,7 @@
|
||||
action: 'create',
|
||||
data: { quotation_id: quotation_id },
|
||||
onSuccess: function(res) {
|
||||
retrieve_quotation(); // reload — status now shows Converted
|
||||
window.location.href = '<?php echo $server_url?>revenue/manage_order.php?id=' + res.order_id;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -449,35 +481,14 @@
|
||||
}
|
||||
|
||||
|
||||
// ── Contact autocomplete ──────────────────────────────────────────────────
|
||||
function init_contact_search() {
|
||||
$('#contact').autocomplete({
|
||||
source: function(req, res) {
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>contact/api/engine/contact.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { keyword: req.term },
|
||||
onSuccess: function(data) {
|
||||
res((data.output || []).map(c => ({
|
||||
label: c.contact_name,
|
||||
value: c.contact_name,
|
||||
id: c.id,
|
||||
})));
|
||||
}
|
||||
});
|
||||
},
|
||||
select: function(e, ui) {
|
||||
$('#contact_id').val(ui.item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('contact:selected', '#contact', function(e, contact) {
|
||||
$('#contact_id').val(contact.id);
|
||||
});
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
$(async function() {
|
||||
try {
|
||||
load_departments('department_id');
|
||||
if (quotation_id) {
|
||||
await retrieve_quotation();
|
||||
} else {
|
||||
@@ -489,11 +500,20 @@
|
||||
|
||||
flatpickr('#quotation_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
flatpickr('#valid_until', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
init_contact_search();
|
||||
|
||||
} catch(e) { console.log(e); }
|
||||
});
|
||||
|
||||
$(document).on('product:selected', '.item_sku', function(e, product) {
|
||||
var $row = $(this).closest('tr');
|
||||
var price = parseFloat(product.price || 0);
|
||||
if (!(parseFloat($row.find('.item_price').val()) > 0) && price > 0) {
|
||||
$row.find('.item_price').val(price);
|
||||
}
|
||||
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
|
||||
recalc_totals();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
+235
-6
@@ -13,16 +13,135 @@
|
||||
|
||||
<div class="row mb-6">
|
||||
<div class="col-12">
|
||||
<h1 class="fs-3 mb-1">Sales Orders</h1>
|
||||
<p class="mb-0 text-muted">Sales order management</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Sales Orders</h1>
|
||||
<p class="mb-0 text-muted">Revenue sales orders and quotation conversions</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>revenue/manage_order.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i>New Sales Order
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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</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-danger bg-opacity-10 text-danger rounded-2">
|
||||
<i class="ti ti-building-warehouse fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Pending Warehouse</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_pending">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||
<i class="ti ti-pencil fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 text-muted small">Draft</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_draft">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="icon-shape icon-md bg-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">Confirmed+</p>
|
||||
<h3 class="fw-bold mb-0" id="stat_confirmed">—</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
<div class="col-12">
|
||||
<div class="card p-5 text-center text-muted">
|
||||
<i class="ti ti-shopping-cart fs-1 mb-3 d-block"></i>
|
||||
<p class="mb-0">Sales Orders coming soon.</p>
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-4">
|
||||
<div class="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search order number, source, contact, or tracking...">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="-2">Pending Warehouse</option>
|
||||
<option value="-1">Cancelled</option>
|
||||
<option value="0">Draft</option>
|
||||
<option value="1">Confirmed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<select id="filter_dept" class="form-select">
|
||||
<option value="">All Departments</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary w-100" onclick="retrieve_orders()">
|
||||
<i class="ti ti-search me-1"></i>Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="revenue_order_list">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Order #</th>
|
||||
<th>Source</th>
|
||||
<th>Date</th>
|
||||
<th>Contact</th>
|
||||
<th>Department</th>
|
||||
<th>Items</th>
|
||||
<th>Grand Total</th>
|
||||
<th>Status</th>
|
||||
<th>Tracking #</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,5 +151,115 @@
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
<script>
|
||||
function order_status_badge(status) {
|
||||
const map = {
|
||||
'-2': '<span class="badge bg-danger">Pending Warehouse</span>',
|
||||
'-1': '<span class="badge bg-secondary">Cancelled</span>',
|
||||
'0': '<span class="badge bg-warning text-dark">Draft</span>',
|
||||
'1': '<span class="badge bg-info text-white">Confirmed</span>',
|
||||
};
|
||||
return map[String(status)] || '<span class="badge bg-light text-dark">—</span>';
|
||||
}
|
||||
|
||||
function order_items_count(items) {
|
||||
if (Array.isArray(items)) return items.length;
|
||||
try { return JSON.parse(items || '[]').length; }
|
||||
catch(e) { return 0; }
|
||||
}
|
||||
|
||||
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 || []).filter(function(o) {
|
||||
return ['quotation', 'revenue'].includes(String(o.source || ''));
|
||||
});
|
||||
|
||||
var keyword = $('#search_input').val().toLowerCase();
|
||||
var status = $('#filter_status').val();
|
||||
var fil_dept = $('#filter_dept').val();
|
||||
var data = all.filter(function(o) {
|
||||
var source_id = String(o.source_id || '');
|
||||
var source = String(o.source || '');
|
||||
var source_label = source === 'revenue' ? 'direct' : source;
|
||||
var match_kw = !keyword ||
|
||||
(o.order_number || '').toLowerCase().includes(keyword) ||
|
||||
(o.contact_name || '').toLowerCase().includes(keyword) ||
|
||||
(o.shipping_tracking_number || '').toLowerCase().includes(keyword) ||
|
||||
source_label.includes(keyword) ||
|
||||
source_id.includes(keyword);
|
||||
var match_status = status === '' || String(o.status) === status;
|
||||
var match_dept = !fil_dept || String(o.department_id) === fil_dept;
|
||||
return match_kw && match_status && match_dept;
|
||||
});
|
||||
|
||||
$('#stat_total').text(format_number(all.length));
|
||||
$('#stat_pending').text(format_number(all.filter(o => String(o.status) === '-2').length));
|
||||
$('#stat_draft').text(format_number(all.filter(o => String(o.status) === '0').length));
|
||||
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) >= 1).length));
|
||||
|
||||
alasql('CREATE TABLE IF NOT EXISTS revenue_order_list');
|
||||
alasql.tables.revenue_order_list.data = data;
|
||||
$('table#revenue_order_list tfoot').html(generate_pagination('revenue_order_list', data.length));
|
||||
change_page_revenue_order_list(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function change_page_revenue_order_list(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var rows = alasql(`SELECT * FROM revenue_order_list LIMIT ${prop_limit} OFFSET ${offset}`);
|
||||
var body = '';
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#revenue_order_list tbody').html(
|
||||
'<tr><td colspan="9" class="text-center py-5 text-muted">No quotation sales orders found.</td></tr>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$.each(rows, function(i, o) {
|
||||
var item_count = order_items_count(o.items);
|
||||
var source = String(o.source || '');
|
||||
var source_display = source === 'quotation' && parseInt(o.source_id) > 0
|
||||
? `<a href="<?php echo $server_url?>revenue/manage_quotation.php?id=${o.source_id}">Quotation #${o.source_id}</a>`
|
||||
: (source === 'revenue' ? 'Direct' : '<span class="text-muted">—</span>');
|
||||
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(o.order_number || '')}</td>
|
||||
<td class="py-3">${source_display}</td>
|
||||
<td class="py-3">${format_date(o.order_date)}</td>
|
||||
<td class="py-3">${escape_html(o.contact_name || '—')}</td>
|
||||
<td class="py-3">${get_dept_label(o.department_id)}</td>
|
||||
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
|
||||
<td class="py-3 fw-semibold">${format_number(o.grand_total, 2)}</td>
|
||||
<td class="py-3">${order_status_badge(o.status)}</td>
|
||||
<td class="py-3">${o.shipping_tracking_number ? escape_html(o.shipping_tracking_number) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">
|
||||
<a href="<?php echo $server_url?>revenue/manage_order.php?id=${o.id}" title="View SO">
|
||||
<i class="ti ti-eye fs-5"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
$('table#revenue_order_list tbody').html(body);
|
||||
}
|
||||
|
||||
$(async function() {
|
||||
await load_dept_cache();
|
||||
populate_dept_filter('filter_dept');
|
||||
retrieve_orders();
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_orders();
|
||||
});
|
||||
$('#filter_status, #filter_dept').on('change', retrieve_orders);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-6">
|
||||
<div class="col-lg-4">
|
||||
<div class="position-relative">
|
||||
<input type="text" id="search_input" class="form-control ps-9"
|
||||
placeholder="Search quotation number or contact...">
|
||||
@@ -98,7 +98,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<div class="col-lg-2">
|
||||
<select id="filter_status" class="form-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="0">Draft</option>
|
||||
@@ -109,7 +109,12 @@
|
||||
<option value="-1">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<div class="col-lg-2">
|
||||
<select id="filter_dept" class="form-select">
|
||||
<option value="">All Departments</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary w-100" onclick="retrieve_quotations()">
|
||||
<i class="ti ti-search me-1"></i>Search
|
||||
</button>
|
||||
@@ -130,6 +135,7 @@
|
||||
<th>Date</th>
|
||||
<th>Valid Until</th>
|
||||
<th>Contact</th>
|
||||
<th>Department</th>
|
||||
<th>Items</th>
|
||||
<th>Grand Total</th>
|
||||
<th>Status</th>
|
||||
@@ -172,13 +178,15 @@
|
||||
|
||||
var keyword = $('#search_input').val().toLowerCase();
|
||||
var fil_status = $('#filter_status').val();
|
||||
var fil_dept = $('#filter_dept').val();
|
||||
|
||||
var data = all.filter(function(q) {
|
||||
var match_kw = !keyword ||
|
||||
var match_kw = !keyword ||
|
||||
(q.quotation_number || '').toLowerCase().includes(keyword) ||
|
||||
(q.contact_name || '').toLowerCase().includes(keyword);
|
||||
var match_st = fil_status === '' || String(q.status) === fil_status;
|
||||
return match_kw && match_st;
|
||||
var match_st = fil_status === '' || String(q.status) === fil_status;
|
||||
var match_dept = !fil_dept || String(q.department_id) === fil_dept;
|
||||
return match_kw && match_st && match_dept;
|
||||
});
|
||||
|
||||
// Stats from full list (unfiltered)
|
||||
@@ -208,6 +216,7 @@
|
||||
<td class="py-3">${format_date(q.quotation_date)}</td>
|
||||
<td class="py-3">${q.valid_until ? format_date(q.valid_until) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="py-3">${escape_html(q.contact_name || '—')}</td>
|
||||
<td class="py-3">${get_dept_label(q.department_id)}</td>
|
||||
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
||||
<td class="py-3 fw-semibold">${format_number(q.grand_total, 2)}</td>
|
||||
<td class="py-3">${status_badge[String(q.status)] || q.status}</td>
|
||||
@@ -222,11 +231,14 @@
|
||||
$('table#quotation_list tbody').html(body);
|
||||
}
|
||||
|
||||
$(function() {
|
||||
$(async function() {
|
||||
await load_dept_cache();
|
||||
populate_dept_filter('filter_dept');
|
||||
retrieve_quotations();
|
||||
$('#search_input').on('keyup', function(e) {
|
||||
if (e.key === 'Enter') retrieve_quotations();
|
||||
});
|
||||
$('#filter_status, #filter_dept').on('change', retrieve_quotations);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
+2
-34
@@ -1,36 +1,4 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
header('Location: ../finance/receipt.php');
|
||||
exit;
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_ac.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row mb-6">
|
||||
<div class="col-12">
|
||||
<h1 class="fs-3 mb-1">Receipts</h1>
|
||||
<p class="mb-0 text-muted">Customer receipts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card p-5 text-center text-muted">
|
||||
<i class="ti ti-cash fs-1 mb-3 d-block"></i>
|
||||
<p class="mb-0">Receipts coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
header('Location: manage_invoice.php?id=' . $id);
|
||||
exit;
|
||||
?>
|
||||
Reference in New Issue
Block a user