501 lines
19 KiB
PHP
501 lines
19 KiB
PHP
<?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>
|