Files
wms-app/app/order/manage_order.php
T

744 lines
30 KiB
PHP

<?php
session_start();
require '../config.php';
require '../include_header.php';
$order_id = (int)($_GET['id'] ?? 0);
$is_edit = isset($_GET['edit']) || $order_id === 0;
$source = $order_id ? '' : preg_replace('/[^a-z0-9_-]/i', '', $_GET['source'] ?? '');
$source_id = $order_id ? 0 : (int)($_GET['source_id'] ?? 0);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar.php'; ?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1" id="page_title">
<?php echo $order_id ? 'Order Detail' : 'New Order'; ?>
</h1>
<p class="mb-0 text-muted" id="page_subtitle">
<?php echo $order_id ? 'View or manage this order' : 'Create a new sales order'; ?>
</p>
</div>
<div class="d-flex gap-2" id="header_actions">
<a href="<?php echo $server_url?>order/order.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
</div>
<div class="row g-5">
<!-- Left: Order form -->
<div class="col-lg-8">
<!-- Order header card -->
<div class="card mb-5">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Order Information</h2>
<div class="row gx-5">
<div class="mb-3 col-lg-6">
<label class="form-label">Contact</label>
<input type="text" id="contact" class="form-control contact_search"
placeholder="Search contact..." autocomplete="off">
<input type="hidden" id="contact_id">
</div>
<div class="mb-3 col-lg-6">
<label class="form-label">Order Date</label>
<input type="text" id="order_date" class="form-control"
placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<div class="mb-3 col-lg-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</label>
<textarea id="notes" class="form-control" rows="2"
placeholder="Internal notes..."></textarea>
</div>
</div>
</div>
</div>
<!-- Order items card -->
<div class="card mb-5">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Order Items</h2>
<button class="btn btn-sm btn-outline-primary" id="btn_add_item"
onclick="add_item_row()">
<i class="ti ti-plus me-1"></i>Add Item
</button>
</div>
<div class="table-responsive">
<table class="table mb-0" id="item_table">
<thead class="table-light">
<tr>
<th>Product</th>
<th>Warehouse</th>
<th style="width: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>
<!-- Right: Summary + actions -->
<div class="col-lg-4">
<!-- Status card (view mode only) -->
<div class="card mb-5 d-none" id="status_card">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Status</h2>
<div class="d-flex gap-2 mb-2">
<span id="badge_status"></span>
<span id="badge_fulfillment"></span>
</div>
<p class="mb-0 small text-muted" id="order_number_display"></p>
<p class="mb-0 small text-muted d-none" id="source_display"></p>
<p class="mb-0 small text-muted d-none" id="tracking_number_display"></p>
</div>
</div>
<div class="alert alert-warning d-none mb-5" id="pending_workflow_hint">
<div class="d-flex gap-2">
<i class="ti ti-building-warehouse mt-1"></i>
<div>
<div class="fw-semibold">Warehouse assignment required</div>
<div class="small">
Select a warehouse for every item, then save it. The order will move to Draft and can be confirmed.
</div>
</div>
</div>
</div>
<!-- Totals card -->
<div class="card mb-5">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Summary</h2>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal</span>
<span id="summary_subtotal">0.00</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Discount</span>
<input type="number" id="discount" class="form-control form-control-sm text-end"
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Tax</span>
<span id="summary_tax" class="fw-semibold">0.00</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="text-muted small">Tax Adj.</span>
<input type="number" id="tax_adjustment" class="form-control form-control-sm text-end"
style="width:90px;" value="0" min="-0.3" max="0.3" step="0.01" oninput="recalc_totals()">
</div>
<div class="d-flex justify-content-between mb-3">
<span class="text-muted">Shipping</span>
<input type="number" id="shipping_fee" class="form-control form-control-sm text-end"
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
</div>
<hr>
<div class="d-flex justify-content-between fw-bold fs-5">
<span>Grand Total</span>
<span id="summary_grand_total">0.00</span>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<!-- Save draft (edit mode) -->
<button class="btn btn-primary w-100" id="btn_save"
onclick="save_order()">
<i class="ti ti-device-floppy me-1"></i>Save Draft
</button>
<!-- Confirm order (draft only) -->
<?php if(isset($_GET["id"])){ ?>
<button class="btn btn-success w-100" id="btn_confirm"
onclick="confirm_order()">
<i class="ti ti-check me-1"></i>Confirm Order
</button>
<?php } ?>
<!-- Proceed to invoice (confirmed, manual mode) -->
<button class="btn btn-outline-primary w-100" id="btn_invoice"
onclick="proceed_to_invoice()">
<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice
</button>
<button class="btn btn-outline-primary w-100" id="btn_tracking"
onclick="update_tracking_number()">
<i class="ti ti-truck-delivery me-1"></i>Save Tracking
</button>
<!-- Create Return (confirmed and above) -->
<button class="btn btn-outline-warning w-100" id="btn_return"
onclick="create_return()">
<i class="ti ti-arrow-back-up me-1"></i>Create Return
</button>
<!-- Cancel order -->
<button class="btn btn-outline-danger w-100" id="btn_cancel"
onclick="cancel_order()">
<i class="ti ti-x me-1"></i>Cancel Order
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete"
onclick="delete_order()">
<i class="ti ti-trash me-1"></i>Delete Order
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script>
// custom.js globals: remove_item(), init_product_search_inputs(), format_number()
const can_delete = ['admin', 'owner'].includes(user_role);
var order_id = <?php echo $order_id; ?>;
var order_source = <?php echo json_encode($source); ?>;
var order_source_id = <?php echo (int)$source_id; ?>;
var order_data = null;
var warehouses = [];
var item_index = 0;
// ── Status helpers ────────────────────────────────────────────────────────
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)] ?? '—';
}
function fulfillment_status_badge(status) {
const map = {
'1': '<span class="badge bg-warning text-dark"><i class="ti ti-list-check me-1"></i>Picking</span>',
'2': '<span class="badge bg-info text-white"><i class="ti ti-package me-1"></i>Packed</span>',
'3': '<span class="badge bg-success"><i class="ti ti-truck me-1"></i>Shipped</span>',
};
return map[String(status)] ?? '';
}
function source_display_label(source, source_id) {
source = String(source || '').trim();
source_id = parseInt(source_id) || 0;
if (!source) return '';
const labels = {
quotation: 'Quotation',
api: 'API',
marketplace: 'Marketplace',
revenue: 'Revenue',
};
return (labels[source] || source) + (source_id > 0 ? ' #' + source_id : '');
}
// ── Recalculate totals ────────────────────────────────────────────────────
function recalc_totals() {
var subtotal = 0;
var tax_sum = 0;
$('#item_tbody tr').each(function() {
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var 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_fee = parseFloat($('#shipping_fee').val()) || 0;
var derived_tax = round_dp(tax_sum, 2);
var grand_total = subtotal - discount + derived_tax + tax_adjustment + shipping_fee;
$('#summary_subtotal').text(format_number(subtotal, 2));
$('#summary_tax').text(format_number(derived_tax, 2));
$('#summary_grand_total').text(format_number(grand_total, 2));
}
// ── Add item row ──────────────────────────────────────────────────────────
function add_item_row(item) {
item = item || {};
var idx = item_index++;
var wh_options = '<option value="">Select warehouse</option>' + warehouses.map(w =>
`<option value="${w.id}" ${item.warehouse_id == w.id ? 'selected' : ''}>${w.warehouse_name}</option>`
).join('');
var row = `<tr id="item_row_${idx}">
<td>
<input type="text" class="form-control form-control-sm item_sku product_search"
placeholder="SKU..." value="${item.product_sku || ''}" autocomplete="off"
data-idx="${idx}" data-product-name-target=".item_name">
<input type="hidden" class="item_name" value="${item.product_name || ''}">
<input type="hidden" class="item_id" value="${item.item_id ?? idx}">
<small class="text-muted item_name_display">${item.product_name || ''}</small>
</td>
<td>
<select class="form-select form-select-sm item_warehouse">
${wh_options}
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm item_qty"
value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()">
</td>
<td>
<input type="number" class="form-control form-control-sm item_price"
value="${item.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="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
<td>
<a href="javascript:void(0);" class="link-danger" onclick="remove_item(${idx})">
<i class="ti ti-trash fs-5"></i>
</a>
</td>
</tr>`;
$('#item_tbody').append(row);
if (window.init_product_search_inputs) {
window.init_product_search_inputs(`#item_row_${idx}`);
}
recalc_totals();
}
// ── Collect items from DOM ────────────────────────────────────────────────
function collect_items() {
var items = [];
var idx = 0;
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var name = $(this).find('.item_name').val().trim();
var wh = parseInt($(this).find('.item_warehouse').val()) || 0;
var qty = parseFloat($(this).find('.item_qty').val()) || 0;
var price = parseFloat($(this).find('.item_price').val()) || 0;
var tax_rate = parseFloat($(this).find('.item_tax_rate').val()) || 0;
var tax_amt = parseFloat($(this).find('.item_tax_amount').val()) || 0;
if (!sku || qty <= 0) { idx++; return; }
if (!wh && !(!order_id && order_source)) { idx++; return; }
items.push({
item_id: idx,
product_sku: sku,
product_name: name,
warehouse_id: wh,
zone: '',
aisle: '',
bin: '',
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;
}
// ── Set view/edit mode ────────────────────────────────────────────────────
function set_mode(status, has_active_invoice, returnable_count, fulfillment_status) {
status = parseInt(status);
has_active_invoice = has_active_invoice || false;
returnable_count = returnable_count ?? 1;
fulfillment_status = parseInt(fulfillment_status) || 0;
var is_pending = status === -2;
var is_draft = status === 0;
var is_editable = is_pending || is_draft; // both Pending and Draft are editable
var can_confirm = status === 0; // only Draft can be confirmed (not Pending)
var can_cancel = status === -2 || status === 0 || status === 1;
var is_cancelled = status === -1;
var can_update_tracking = status === 1 && !is_cancelled;
// Edit controls
$('#btn_add_item, #contact, #order_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee')
.prop('disabled', !is_editable);
$('.item_sku, .item_qty, .item_price, .item_tax_rate, .item_warehouse').prop('disabled', !is_editable);
$('a[onclick^="remove_item"]').toggleClass('d-none', !is_editable);
// Pending hint
if (is_pending) {
$('#page_subtitle').text('Pending warehouse assignment');
}
$('#pending_workflow_hint').toggleClass('d-none', !is_pending);
// Action buttons
set_btn_state('#btn_save', is_editable, 'Only pending or draft orders can be edited');
$('#btn_save').html(is_pending
? '<i class="ti ti-device-floppy me-1"></i>Save Warehouse'
: '<i class="ti ti-device-floppy me-1"></i>Save Draft');
set_btn_state('#btn_confirm', can_confirm,
is_pending ? 'Assign warehouses and save first. The order will move to Draft.' : 'Order must be in draft to confirm');
set_btn_state('#btn_cancel', can_cancel && !is_cancelled,
is_cancelled ? 'Order is already cancelled' : 'Only draft or confirmed orders can be cancelled');
$('#btn_delete').toggleClass('d-none', !can_delete || !order_id);
set_btn_state('#btn_tracking', can_update_tracking,
is_cancelled ? 'Order is cancelled'
: status < 1 ? 'Confirm the order before adding a tracking number' : '');
// Create Return
var can_return = status >= 1 && !is_cancelled && returnable_count > 0;
var return_title = is_cancelled ? 'Order is cancelled'
: status < 1 ? 'Confirm the order before creating a return'
: returnable_count === 0 ? 'All items have already been returned' : '';
set_btn_state('#btn_return', can_return, return_title);
$('#btn_return').html(returnable_count === 0 && status >= 1 && !is_cancelled
? '<i class="ti ti-arrow-back-up me-1"></i>All Items Returned'
: '<i class="ti ti-arrow-back-up me-1"></i>Create Return');
// Proceed to Invoice
var can_invoice = status >= 1 && !is_cancelled && !has_active_invoice;
var invoice_title = is_cancelled ? 'Order is cancelled'
: status < 1 ? 'Confirm the order before creating an invoice'
: has_active_invoice ? 'An active invoice already exists for this order' : '';
set_btn_state('#btn_invoice', can_invoice, invoice_title);
$('#btn_invoice').html(has_active_invoice
? '<i class="ti ti-file-invoice me-1"></i>Invoice Already Created'
: '<i class="ti ti-file-invoice me-1"></i>Proceed to Invoice');
}
// ── Load existing order ───────────────────────────────────────────────────
function retrieve_order() {
if (!order_id) return Promise.resolve();
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: order_id },
onSuccess: function(res) {
order_data = res.output;
var o = order_data;
// Header
$('#page_title').text(o.order_number);
$('#page_subtitle').text('');
$('#status_card').removeClass('d-none');
$('#badge_status').html(order_status_badge(o.status));
$('#badge_fulfillment').html(fulfillment_status_badge(o.fulfillment_status));
$('#order_number_display').text(o.order_number);
var source_label = source_display_label(o.source || '', o.source_id || 0);
$('#source_display')
.toggleClass('d-none', !source_label)
.text(source_label ? 'Source: ' + source_label : '');
var tracking = o.shipping_tracking_number || '';
$('#tracking_number_display')
.toggleClass('d-none', !tracking)
.html(tracking ? '<i class="ti ti-truck-delivery me-1"></i>' + escape_html(tracking) : '');
// Fields
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id);
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
$('#shipping_fee').val(o.shipping_fee || 0);
// Items
$('#item_tbody').empty();
(o.items || []).forEach(function(item) { add_item_row(item); });
recalc_totals();
set_mode(o.status, res.has_active_invoice, (res.returnable_items || []).length, o.fulfillment_status);
}
});
}
// ── Save draft ────────────────────────────────────────────────────────────
function save_order() {
var missing_warehouse = false;
$('#item_tbody tr').each(function() {
var sku = $(this).find('.item_sku').val().trim();
var wh = parseInt($(this).find('.item_warehouse').val()) || 0;
if (sku && !wh) missing_warehouse = true;
});
if (missing_warehouse && !(!order_id && order_source)) {
bootbox.alert('Select a warehouse for every item before saving.');
return;
}
var items = collect_items();
if (!items.length) {
bootbox.alert('Please add at least one item.');
return;
}
const dateStr = $('#order_date').val();
const [day, month, year] = dateStr.split('/');
const formatted = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_order.php',
autoPrepare: true,
checkRequired: 0,
action: order_id ? 'update' : 'create',
data: {
id: order_id,
contact_id: $('#contact_id').val() || 0,
department_id: $('#department_id').val() || 0,
order_date: to_iso_date($('#order_date').val()),
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
shipping_fee: $('#shipping_fee').val() || 0,
source: order_id ? '' : order_source,
source_id: order_id ? 0 : order_source_id,
notes: $('#notes').val(),
},
onSuccess: function(res) {
if (!order_id && res.new_id) {
window.location.href = '<?php echo $server_url?>order/manage_order.php?id=' + res.new_id;
} else {
retrieve_order();
}
}
});
}
// ── Confirm order ─────────────────────────────────────────────────────────
function confirm_order() {
bootbox.confirm({
message: 'Confirm this order? Stock-out rows will be created for all items using FIFO bin selection.',
buttons: {
confirm: { label: 'Confirm Order', className: 'btn-success' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/confirm_order.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: order_id },
onSuccess: function() {
retrieve_order();
}
});
}
});
}
// ── Update tracking number ────────────────────────────────────────────────
function update_tracking_number() {
var current = (order_data && order_data.shipping_tracking_number) ? order_data.shipping_tracking_number : '';
bootbox.prompt({
title: '<i class="ti ti-truck-delivery me-2"></i>Shipping Tracking Number',
value: current,
inputType: 'text',
placeholder: 'Enter carrier tracking number...',
callback: function(result) {
if (result === null) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/update_order_status.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: order_id,
shipping_tracking_number: result.trim(),
},
onSuccess: function() {
retrieve_order();
}
});
}
});
}
// ── Create return ─────────────────────────────────────────────────────────
function create_return() {
window.location.href = '<?php echo $server_url?>order/manage_return.php?order_id=' + order_id;
}
// ── Proceed to invoice ────────────────────────────────────────────────────
function proceed_to_invoice() {
ajax_request({
url: '<?php echo $server_url?>order/api/engine/proceed_to_invoice.php',
autoPrepare: true,
checkRequired: 0,
action: 'create',
data: { order_id: order_id },
onSuccess: function(res) {
if (res.new_id) {
window.location.href = '<?php echo $server_url?>order/manage_invoice.php?id=' + res.new_id;
}
}
});
}
// ── Cancel order ──────────────────────────────────────────────────────────
function delete_order() {
bootbox.confirm({
message: 'Delete this order? Unconfirmed stock-out rows will also be removed.',
buttons: { confirm: { label: 'Delete', 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/delete_order.php',
autoPrepare: false,
checkRequired: 0,
action: 'delete',
data: { id: order_id },
onSuccess: function() { window.location.href = '<?php echo $server_url?>order/order.php'; }
});
}
});
}
function cancel_order() {
bootbox.confirm({
message: 'Cancel this order? All pending stock-out rows will be cancelled.',
buttons: {
confirm: { label: 'Yes, Cancel Order', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>order/api/engine/cancel_order.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: { id: order_id },
onSuccess: function() {
retrieve_order();
}
});
}
});
}
// ── Retrieve warehouses ───────────────────────────────────────────────────
function retrieve_warehouses() {
return ajax_request({
url: '<?php echo $server_url?>inventory/api/engine/warehouse.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
warehouses = res.output || [];
}
});
}
$(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');
$row.find('.item_name').val(product.product_name || '');
$row.find('.item_name_display').text(product.product_name || '');
$row.find('.item_tax_rate').val(parseFloat(product.tax_rate || 0).toFixed(2));
recalc_totals();
});
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
await Promise.resolve(load_departments('department_id')).catch(function() {});
await retrieve_warehouses();
if (order_id) {
await retrieve_order();
} else {
// New order — set today's date, add one empty row
var today = flatpickr.formatDate(new Date(), 'd/m/Y');
$('#order_date').val(today);
add_item_row();
set_mode(0);
if (order_source) {
$('#page_title').text('New Revenue Sales Order');
$('#page_subtitle').text('Create a pending sales order for WMS warehouse assignment');
}
}
// Init flatpickr on order date
flatpickr('#order_date', { dateFormat: 'd/m/Y', allowInput: true });
} catch(e) {
console.log(e);
}
});
</script>
</body>
</html>