accounting modules

This commit is contained in:
Thanakorn S
2026-05-13 15:11:24 +07:00
parent 511360faa2
commit 04a683bd02
70 changed files with 4451 additions and 42 deletions
@@ -0,0 +1,87 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php';
$quotation_id = (int)($data['quotation_id'] ?? 0);
if (!$quotation_id) {
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);
if (!$q) {
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']));
}
// Map quotation items → order items (warehouse_id left 0 = to be filled by WMS)
$qt_items = json_decode($q['items'] ?? '[]', true) ?: [];
$ord_items = [];
foreach ($qt_items as $i => $qi) {
$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,
];
}
$order_data = [
'quotation_id' => $quotation_id,
'contact_id' => (int)$q['contact_id'],
'order_date' => date('Y-m-d'),
'items' => $ord_items,
'discount' => (float)$q['discount'],
'tax' => (float)$q['tax'],
'shipping_fee' => 0,
'shipping_tracking_number' => '',
'notes' => $q['notes'] ?? '',
];
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);
// 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]);
});
$answer['success'] = 1;
$answer['message'] = 'Order created successfully';
$answer['order_id'] = $new_order_id;
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
+107
View File
@@ -0,0 +1,107 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
$action = $data['action'] ?? '';
$id = (int)($data['id'] ?? 0);
// ── 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);
}
// ── 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';
}
exit(json_encode($answer));
@@ -0,0 +1,19 @@
<?php
session_start();
require '../../../assets/utils/db_auth.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);
$answer['success'] = 1;
$answer['output'] = $row;
exit(json_encode($answer));
@@ -0,0 +1,33 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
$id = (int)($data['id'] ?? 0);
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);
$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['success'] = 1;
exit(json_encode($answer));
@@ -0,0 +1,42 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
$id = (int)($data['id'] ?? 0);
$action = $data['action_type'] ?? '';
if (!$id || !$action) {
exit(json_encode(['success' => 0, 'message' => 'ID and action 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'])); }
$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']));
}
$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));
+36
View File
@@ -0,0 +1,36 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<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">Sales Invoices</h1>
<p class="mb-0 text-muted">Sales invoice management</p>
</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>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
</body>
</html>
+500
View File
@@ -0,0 +1,500 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$quotation_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">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title">
<?php echo $quotation_id ? 'Quotation Detail' : 'New Quotation'; ?>
</h1>
<p class="mb-0 text-muted" id="page_subtitle">
<?php echo $quotation_id ? '' : 'Create a new sales quotation'; ?>
</p>
</div>
<a href="<?php echo $server_url?>revenue/quotation.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<!-- Left: Quotation form -->
<div class="col-lg-8">
<!-- Header card -->
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Quotation 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"
placeholder="Search contact..." autocomplete="off">
<input type="hidden" id="contact_id">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Quotation Date</label>
<input type="text" id="quotation_date" class="form-control"
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Valid Until</label>
<input type="text" id="valid_until" class="form-control"
placeholder="DD/MM/YYYY (optional)" autocomplete="off">
</div>
<div class="mb-3 col-lg-12">
<label class="form-label">Notes / Terms</label>
<textarea id="notes" class="form-control" rows="2"
placeholder="Payment terms, delivery conditions, notes..."></textarea>
</div>
</div>
</div>
</div>
<!-- Items card -->
<div class="card mb-5">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Line 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 Line
</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:120px;">Total</th>
<th style="width:40px;"></th>
</tr>
</thead>
<tbody id="item_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: Status + Summary + Actions -->
<div class="col-lg-4">
<!-- Status card (existing quotation) -->
<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="quotation_number_display"></p>
</div>
</div>
<!-- Totals -->
<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 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()">
</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>
<!-- Actions -->
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_quotation()">
<i class="ti ti-device-floppy me-1"></i>Save Draft
</button>
<button class="btn btn-info text-white w-100 d-none" id="btn_send"
onclick="change_status('send')">
<i class="ti ti-send me-1"></i>Mark as Sent
</button>
<button class="btn btn-success w-100 d-none" id="btn_accept"
onclick="change_status('accept')">
<i class="ti ti-circle-check me-1"></i>Mark as Accepted
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_reject"
onclick="change_status('reject')">
<i class="ti ti-x me-1"></i>Mark as Rejected
</button>
<button class="btn btn-outline-secondary w-100 d-none" id="btn_reopen"
onclick="change_status('reopen')">
<i class="ti ti-refresh me-1"></i>Reopen as Draft
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_cancel"
onclick="change_status('cancel')">
<i class="ti ti-ban me-1"></i>Cancel Quotation
</button>
<hr class="d-none" id="hr_convert">
<a href="javascript:void(0);" class="btn btn-success w-100 d-none" id="btn_convert"
onclick="convert_to_order()">
<i class="ti ti-transfer me-1"></i>Convert to Order
</a>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var quotation_id = <?php echo $quotation_id; ?>;
var quotation_data = null;
var item_index = 0;
var status_badge = {
'-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">Sent</span>',
'2': '<span class="badge bg-success">Accepted</span>',
'3': '<span class="badge bg-danger">Rejected</span>',
'5': '<span class="badge bg-primary">Converted</span>',
};
// ── Recalculate totals ────────────────────────────────────────────────────
function recalc_totals() {
var subtotal = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var total = qty * price;
$(this).find('.item_total').text(format_number(total, 2));
subtotal += total;
});
var discount = parseFloat($('#discount').val()) || 0;
var tax = parseFloat($('#tax').val()) || 0;
var grand = subtotal - discount + tax;
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_grand_total').text(format_number(grand, 2));
}
// ── Add item row ──────────────────────────────────────────────────────────
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"
placeholder="SKU..." value="${escape_html(item.product_sku || '')}" autocomplete="off">
</td>
<td>
<input type="text" class="form-control form-control-sm item_desc"
placeholder="Description..." value="${escape_html(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 || 0}" min="0" step="any" oninput="recalc_totals()">
</td>
<td class="item_total text-end align-middle">
${format_number((item.quantity || 1) * (item.unit_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);
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;
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)),
});
});
return items;
}
// ── Set UI mode based on status ───────────────────────────────────────────
function set_mode(status) {
status = parseInt(status);
var is_draft = status === 0;
var is_sent = status === 1;
var is_accepted = status === 2;
var is_converted = status === 5;
var is_closed = status >= 2 || status === -1;
var is_editable = is_draft;
// Edit controls
$('#contact, #quotation_date, #valid_until, #notes, #discount, #tax, #btn_add_item')
.prop('disabled', !is_editable);
$('.item_sku, .item_desc, .item_qty, .item_price').prop('disabled', !is_editable);
$('.remove_item_btn').toggleClass('d-none', !is_editable);
// Action buttons
$('#btn_save').toggleClass('d-none', !is_draft);
$('#btn_send').toggleClass('d-none', !is_draft);
$('#btn_accept').toggleClass('d-none', !is_sent);
$('#btn_reject').toggleClass('d-none', !is_sent);
$('#btn_reopen').toggleClass('d-none', is_draft || is_closed);
$('#btn_cancel').toggleClass('d-none', is_closed || (is_draft && !quotation_id));
// Convert to Order — only on Accepted, not yet converted
var show_convert = is_accepted && !is_converted;
$('#hr_convert').toggleClass('d-none', !show_convert);
$('#btn_convert').toggleClass('d-none', !show_convert);
}
// ── Load existing quotation ───────────────────────────────────────────────
function retrieve_quotation() {
if (!quotation_id) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>revenue/api/engine/retrieve_quotation.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: quotation_id },
onSuccess: function(res) {
quotation_data = res.output;
var q = quotation_data;
$('#page_title').text(q.quotation_number);
$('#page_subtitle').text('');
$('#status_card').removeClass('d-none');
$('#badge_status').html(status_badge[String(q.status)] || q.status);
$('#quotation_number_display').text(q.quotation_number);
$('#contact').val(q.contact_name || '');
$('#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) : '');
$('#notes').val(q.notes || '');
$('#discount').val(parseFloat(q.discount) || 0);
$('#tax').val(parseFloat(q.tax) || 0);
$('#item_tbody').empty();
item_index = 0;
(q.items || []).forEach(function(item) { add_item_row(item); });
recalc_totals();
set_mode(q.status);
}
});
}
// ── Save draft ────────────────────────────────────────────────────────────
function save_quotation() {
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_quotation.php',
autoPrepare: true,
checkRequired: 0,
action: quotation_id ? 'update' : 'create',
data: {
id: quotation_id,
contact_id: $('#contact_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(),
},
onSuccess: function(res) {
if (!quotation_id && res.new_id) {
window.location.href = '<?php echo $server_url?>revenue/manage_quotation.php?id=' + res.new_id;
} else {
retrieve_quotation();
}
}
});
}
// ── Change status ─────────────────────────────────────────────────────────
function change_status(action_type) {
var labels = {
send: 'Mark this quotation as Sent to customer?',
accept: 'Mark this quotation as Accepted?',
reject: 'Mark this quotation as Rejected?',
reopen: 'Reopen this quotation as Draft?',
cancel: 'Cancel this quotation?',
};
bootbox.confirm({
message: labels[action_type] || 'Confirm?',
buttons: {
confirm: { label: 'Confirm', 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/update_quotation_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: quotation_id, action_type: action_type },
onSuccess: function() {
retrieve_quotation();
}
});
}
});
}
// ── Convert to Order ─────────────────────────────────────────────────────
function convert_to_order() {
bootbox.confirm({
message: 'Convert this quotation to a Pending sales order? WMS staff will assign warehouse locations before confirming.',
buttons: {
confirm: { label: 'Convert to Order', className: 'btn-success' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>revenue/api/engine/convert_quotation.php',
autoPrepare: true,
checkRequired: 0,
action: 'create',
data: { quotation_id: quotation_id },
onSuccess: function(res) {
retrieve_quotation(); // reload — status now shows Converted
}
});
}
});
}
// ── 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);
}
});
}
// ── Boot ──────────────────────────────────────────────────────────────────
$(async function() {
try {
if (quotation_id) {
await retrieve_quotation();
} else {
var today = flatpickr.formatDate(new Date(), 'd/m/Y');
$('#quotation_date').val(today);
add_item_row();
set_mode(0);
}
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); }
});
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<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">Sales Orders</h1>
<p class="mb-0 text-muted">Sales order management</p>
</div>
</div>
<div class="row">
<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>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
</body>
</html>
+235
View File
@@ -0,0 +1,235 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_ac.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Quotations</h1>
<p class="mb-0 text-muted">Sales quotations sent to customers</p>
</div>
<a href="<?php echo $server_url?>revenue/manage_quotation.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Quotation
</a>
</div>
</div>
</div>
<!-- Stat cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
<i class="ti ti-file-description fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Total</p>
<h3 class="fw-bold mb-0" id="stat_total">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
<i class="ti ti-pencil fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Draft</p>
<h3 class="fw-bold mb-0" id="stat_draft">—</h3>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4">
<div class="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
<i class="ti ti-send fs-4"></i>
</div>
<div>
<p class="mb-0 text-muted small">Sent</p>
<h3 class="fw-bold mb-0" id="stat_sent">—</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">Accepted</p>
<h3 class="fw-bold mb-0" id="stat_accepted">—</h3>
</div>
</div>
</div>
</div>
</div>
<!-- Filter + Table -->
<div class="row g-5">
<div class="col-12">
<div class="card">
<div class="card-body p-4">
<div class="row g-3 align-items-end">
<div class="col-lg-6">
<div class="position-relative">
<input type="text" id="search_input" class="form-control ps-9"
placeholder="Search quotation number or contact...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="col-lg-3">
<select id="filter_status" class="form-select">
<option value="">All Status</option>
<option value="0">Draft</option>
<option value="1">Sent</option>
<option value="2">Accepted</option>
<option value="3">Rejected</option>
<option value="5">Converted</option>
<option value="-1">Cancelled</option>
</select>
</div>
<div class="col-lg-3">
<button class="btn btn-secondary w-100" onclick="retrieve_quotations()">
<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="quotation_list">
<thead class="table-primary border-light">
<tr>
<th>Quotation #</th>
<th>Date</th>
<th>Valid Until</th>
<th>Contact</th>
<th>Items</th>
<th>Grand Total</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
var status_badge = {
'-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">Sent</span>',
'2': '<span class="badge bg-success">Accepted</span>',
'3': '<span class="badge bg-danger">Rejected</span>',
'5': '<span class="badge bg-primary">Converted</span>',
};
function retrieve_quotations() {
return ajax_request({
url: '<?php echo $server_url?>revenue/api/engine/retrieve_quotation.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var all = res.output || [];
var keyword = $('#search_input').val().toLowerCase();
var fil_status = $('#filter_status').val();
var data = all.filter(function(q) {
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;
});
// Stats from full list (unfiltered)
$('#stat_total').text(all.length);
$('#stat_draft').text(all.filter(q => String(q.status) === '0').length);
$('#stat_sent').text(all.filter(q => String(q.status) === '1').length);
$('#stat_accepted').text(all.filter(q => String(q.status) === '2').length);
alasql('CREATE TABLE IF NOT EXISTS quotation_list');
alasql.tables.quotation_list.data = data;
var page = generate_pagination('quotation_list', data.length);
$('table#quotation_list tfoot').html(page);
if (typeof change_page_quotation_list === 'function') change_page_quotation_list(1);
}
});
}
function change_page_quotation_list(page_num) {
var offset = (page_num - 1) * prop_limit;
var page_data = alasql(`SELECT * FROM quotation_list LIMIT ${prop_limit} OFFSET ${offset}`);
var body = '';
$.each(page_data, function(i, q) {
var items = typeof q.items === 'string' ? JSON.parse(q.items || '[]') : (q.items || []);
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(q.quotation_number)}</td>
<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">${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>
<td class="py-3">
<a href="<?php echo $server_url?>revenue/manage_quotation.php?id=${q.id}" title="View">
<i class="ti ti-eye fs-5"></i>
</a>
</td>
</tr>`;
});
$('table#quotation_list tbody').html(body);
}
$(function() {
retrieve_quotations();
$('#search_input').on('keyup', function(e) {
if (e.key === 'Enter') retrieve_quotations();
});
});
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<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>