Apply the configured timezone to PHP and both DB connections, wrap unwrapped ajax payloads so delete buttons reach their engines, normalise and validate invoice due dates, reject stock quantities below the stored 4dp scale, and list stock movements across all warehouses.
1019 lines
40 KiB
PHP
1019 lines
40 KiB
PHP
<?php
|
|
session_start();
|
|
require '../config.php';
|
|
require '../include_header.php';
|
|
|
|
$po_id = (int)($_GET['id'] ?? 0);
|
|
?>
|
|
|
|
<body>
|
|
<?php require '../include_topbar.php'; ?>
|
|
<?php require '../include_sidebar.php'; ?>
|
|
|
|
<main id="content" class="content py-15">
|
|
<div class="container-fluid">
|
|
|
|
<!-- Page header -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="mb-6 d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h1 class="fs-3 mb-1" id="page_title">
|
|
<?php echo $po_id ? 'Purchase Order Detail' : 'New Purchase Order'; ?>
|
|
</h1>
|
|
<p class="mb-0 text-muted" id="page_subtitle">
|
|
<?php echo $po_id ? 'View or manage this purchase order' : 'Create a new purchase order'; ?>
|
|
</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<a href="<?php echo $server_url?>po/po.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: PO form -->
|
|
<div class="col-lg-8">
|
|
|
|
<!-- PO 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">Supplier</label>
|
|
<input type="text" id="contact" class="form-control contact_search"
|
|
placeholder="Search supplier..." autocomplete="off">
|
|
<input type="hidden" id="contact_id">
|
|
</div>
|
|
|
|
<div class="mb-3 col-lg-6">
|
|
<label class="form-label">PO Date</label>
|
|
<input type="text" id="po_date" class="form-control"
|
|
placeholder="DD/MM/YYYY" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="mb-3 col-lg-6">
|
|
<label class="form-label">Expected Delivery <span class="text-muted fw-normal">(optional)</span></label>
|
|
<input type="text" id="expected_date" class="form-control"
|
|
placeholder="DD/MM/YYYY" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="mb-3 col-lg-6">
|
|
<label class="form-label">Default Warehouse</label>
|
|
<select id="warehouse_id" class="form-select">
|
|
<option value="">Select warehouse...</option>
|
|
</select>
|
|
</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>
|
|
|
|
<!-- PO 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">Items to Order</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 style="width:90px;">Qty</th>
|
|
<th style="width:120px;">Unit Price</th>
|
|
<th style="width:80px;">Tax%</th>
|
|
<th style="width:130px;">Total</th>
|
|
<th style="width:40px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="item_tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Receive goods card (confirmed / partial only) -->
|
|
<div class="card mb-5 d-none" id="receive_card">
|
|
<div class="card-body p-5">
|
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
|
<div>
|
|
<h2 class="fs-5 mb-1">Receive Goods</h2>
|
|
<p class="mb-0 text-muted small">Enter quantities received and assign bin locations for each line</p>
|
|
</div>
|
|
<button class="btn btn-success" id="btn_receive" onclick="receive_goods()">
|
|
<i class="ti ti-package-import me-1"></i>Confirm Receipt
|
|
</button>
|
|
</div>
|
|
<div id="receive_rows_container"></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>
|
|
</div>
|
|
<p class="mb-0 small text-muted" id="po_number_display"></p>
|
|
<p class="mb-0 small text-muted d-none" id="expected_date_display"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Totals card -->
|
|
<div class="card mb-5">
|
|
<div class="card-body p-4">
|
|
<h2 class="fs-5 mb-3">Summary</h2>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Subtotal</span>
|
|
<span id="summary_subtotal">0.00</span>
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Discount</span>
|
|
<input type="number" id="discount" class="form-control form-control-sm text-end"
|
|
style="width:110px;" value="0" min="0" oninput="recalc_totals()">
|
|
</div>
|
|
<div class="d-flex justify-content-between mb-2">
|
|
<span class="text-muted">Tax</span>
|
|
<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">
|
|
|
|
<button class="btn btn-primary w-100" id="btn_save" onclick="save_po()">
|
|
<i class="ti ti-device-floppy me-1"></i>Save Draft
|
|
</button>
|
|
|
|
<?php if ($po_id): ?>
|
|
<button class="btn btn-success w-100" id="btn_confirm" onclick="confirm_po()">
|
|
<i class="ti ti-check me-1"></i>Confirm PO
|
|
</button>
|
|
<?php endif; ?>
|
|
|
|
<hr class="my-1">
|
|
|
|
<button class="btn btn-outline-primary w-100 d-none" id="btn_proceed_pi"
|
|
onclick="proceed_to_purchase_invoice()">
|
|
<i class="ti ti-file-invoice me-1"></i>Proceed to Purchase Invoice
|
|
</button>
|
|
|
|
<a href="#" class="btn btn-outline-info w-100 d-none" id="btn_view_pi">
|
|
<i class="ti ti-eye me-1"></i>View Purchase Invoice
|
|
</a>
|
|
|
|
<a href="<?php echo $server_url?>po/manage_supplier_return.php?po_id=<?php echo $po_id; ?>"
|
|
class="btn btn-outline-secondary w-100 d-none" id="btn_new_return">
|
|
<i class="ti ti-arrow-back-up me-1"></i>New Supplier Return
|
|
</a>
|
|
|
|
<hr class="my-1">
|
|
|
|
<button class="btn btn-outline-warning w-100 d-none" id="btn_close_po" onclick="close_po()">
|
|
<i class="ti ti-lock me-1"></i>Close PO
|
|
</button>
|
|
|
|
<button class="btn btn-outline-danger w-100" id="btn_cancel" onclick="cancel_po()">
|
|
<i class="ti ti-x me-1"></i>Cancel PO
|
|
</button>
|
|
|
|
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="delete_po()">
|
|
<i class="ti ti-trash me-1"></i>Delete PO
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<?php require '../include_ending.php'; ?>
|
|
|
|
<script>
|
|
// custom.js globals: remove_item(), load_location_config(), init_product_search_inputs(), init_lot_search_inputs(), escape_html(), format_number()
|
|
|
|
const can_delete = ['admin', 'owner'].includes(user_role);
|
|
|
|
var po_id = <?php echo $po_id; ?>;
|
|
var po_data = null;
|
|
var warehouses = [];
|
|
var receive_warehouses = [];
|
|
var item_index = 0;
|
|
var recv_expiry_pickers = {};
|
|
|
|
// ── Location config (loaded from company settings) ────────────────────────
|
|
var advanced = false;
|
|
var label_bin = 'Location';
|
|
var label_zone = 'Zone';
|
|
var label_aisle = 'Aisle';
|
|
|
|
|
|
// ── Status helpers ────────────────────────────────────────────────────────
|
|
function po_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 receipt_status_badge(status) {
|
|
const map = {
|
|
'2': '<span class="badge bg-primary">Partial</span>',
|
|
'3': '<span class="badge bg-success">Received</span>',
|
|
};
|
|
return map[String(status)] ?? '';
|
|
}
|
|
|
|
// ── 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);
|
|
$('#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_fee, 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 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>
|
|
<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>
|
|
<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 || 0), 2)}</td>
|
|
<td>
|
|
<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();
|
|
}
|
|
|
|
|
|
|
|
// ── 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 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,
|
|
quantity: qty,
|
|
unit_price: price,
|
|
total_price: round_dp(qty * price, 4),
|
|
tax_rate: tax_rate,
|
|
tax_amount: tax_amt,
|
|
received_qty: 0,
|
|
stock_in_id: 0,
|
|
});
|
|
idx++;
|
|
});
|
|
return items;
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Cascade helpers (scoped per receive row by rowId) ─────────────────────
|
|
function recv_load_zones(rowId, callback) {
|
|
var wh_id = $(`#recv_wh_${rowId}`).val();
|
|
if (!wh_id) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>ics/api/engine/retrieve_zone.php',
|
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
|
|
action: 'read',
|
|
data: { type: 'to', warehouse_to: wh_id },
|
|
onSuccess: function(res) {
|
|
var opts = `<option value="">Select ${label_zone.toLowerCase()}...</option>`;
|
|
(res.output || []).forEach(function(z) {
|
|
opts += `<option value="${escape_html(z.zone)}">${escape_html(z.zone)}</option>`;
|
|
});
|
|
$(`#recv_zone_${rowId}`).html(opts);
|
|
$(`#recv_aisle_${rowId}`).html(`<option value="">Select ${label_aisle.toLowerCase()}...</option>`);
|
|
$(`#recv_bin_${rowId}`).html(`<option value="">Select ${label_bin.toLowerCase()}...</option>`);
|
|
if (callback) callback();
|
|
}
|
|
});
|
|
}
|
|
|
|
function recv_load_aisles(rowId, callback) {
|
|
var wh_id = $(`#recv_wh_${rowId}`).val();
|
|
var zone = $(`#recv_zone_${rowId}`).val();
|
|
if (!wh_id || !zone) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>ics/api/engine/retrieve_aisle.php',
|
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
|
|
action: 'read',
|
|
data: { type: 'to', warehouse_to: wh_id, zone_to: zone },
|
|
onSuccess: function(res) {
|
|
var opts = `<option value="">Select ${label_aisle.toLowerCase()}...</option>`;
|
|
(res.output || []).forEach(function(a) {
|
|
opts += `<option value="${escape_html(a)}">${escape_html(a)}</option>`;
|
|
});
|
|
$(`#recv_aisle_${rowId}`).html(opts);
|
|
$(`#recv_bin_${rowId}`).html(`<option value="">Select ${label_bin.toLowerCase()}...</option>`);
|
|
if (callback) callback();
|
|
}
|
|
});
|
|
}
|
|
|
|
function recv_load_bins(rowId, callback) {
|
|
var wh_id = $(`#recv_wh_${rowId}`).val();
|
|
var zone = advanced ? ($(`#recv_zone_${rowId}`).val() || '') : '';
|
|
var aisle = advanced ? ($(`#recv_aisle_${rowId}`).val() || '') : '';
|
|
if (!wh_id) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin.php',
|
|
autoPrepare: true, checkRequired: 0, noLoading: true, queueLock: false,
|
|
action: 'read',
|
|
data: { type: 'to', warehouse_to: wh_id, zone_to: zone, aisle_to: aisle, id: 0 },
|
|
onSuccess: function(res) {
|
|
var opts = `<option value="">Select ${label_bin.toLowerCase()}...</option>`;
|
|
(res.output || []).forEach(function(r) {
|
|
opts += `<option value="${escape_html(r)}">${escape_html(r)}</option>`;
|
|
});
|
|
$(`#recv_bin_${rowId}`).html(opts);
|
|
if (callback) callback();
|
|
}
|
|
});
|
|
}
|
|
|
|
function lock_recv_expiry(rowId, expiry_date) {
|
|
var picker = recv_expiry_pickers[rowId];
|
|
if (picker) {
|
|
picker.setDate(expiry_date || '', true, 'Y-m-d');
|
|
if (picker.altInput) picker.altInput.disabled = true;
|
|
}
|
|
$(`#recv_expiry_${rowId}`)
|
|
.prop('disabled', true)
|
|
.attr('title', 'Expiry date is locked for existing lots');
|
|
}
|
|
|
|
function unlock_recv_expiry(rowId) {
|
|
var picker = recv_expiry_pickers[rowId];
|
|
$(`#recv_expiry_${rowId}`).prop('disabled', false).removeAttr('title');
|
|
if (picker) {
|
|
picker.clear();
|
|
if (picker.altInput) picker.altInput.disabled = false;
|
|
}
|
|
}
|
|
|
|
$(document).on('lot:selected', '.lot_search', function(e, lot) {
|
|
var rowId = $(this).closest('[data-row]').data('row');
|
|
if (rowId) lock_recv_expiry(rowId, lot.expiry_date || '');
|
|
});
|
|
|
|
$(document).on('lot:cleared', '.lot_search', function() {
|
|
var rowId = $(this).closest('[data-row]').data('row');
|
|
if (rowId) unlock_recv_expiry(rowId);
|
|
});
|
|
|
|
// Called when warehouse select changes on a receive row
|
|
function on_recv_wh_change(rowId) {
|
|
if (advanced) {
|
|
recv_load_zones(rowId);
|
|
} else {
|
|
recv_load_bins(rowId);
|
|
}
|
|
}
|
|
|
|
|
|
// ── Build receive rows (one card per receivable item) ─────────────────────
|
|
function build_receive_rows(receivable) {
|
|
var wh_options = `<option value="">Select warehouse...</option>`;
|
|
receive_warehouses.forEach(function(w) {
|
|
wh_options += `<option value="${w.id}">${w.warehouse_name}</option>`;
|
|
});
|
|
|
|
var default_wh = po_data ? (po_data.warehouse_id || '') : '';
|
|
|
|
var html = '';
|
|
$.each(receivable, function(i, item) {
|
|
var rowId = 'r' + i; // short safe id, no special chars
|
|
|
|
var zone_aisle_html = '';
|
|
if (advanced) {
|
|
zone_aisle_html = `
|
|
<div class="col-lg-4">
|
|
<label class="form-label small text-muted">${label_zone}</label>
|
|
<select id="recv_zone_${rowId}" class="form-select form-select-sm"
|
|
onchange="recv_load_aisles('${rowId}')">
|
|
<option value="">Select ${label_zone.toLowerCase()}...</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<label class="form-label small text-muted">${label_aisle}</label>
|
|
<select id="recv_aisle_${rowId}" class="form-select form-select-sm"
|
|
onchange="recv_load_bins('${rowId}')">
|
|
<option value="">Select ${label_aisle.toLowerCase()}...</option>
|
|
</select>
|
|
</div>`;
|
|
}
|
|
|
|
var bin_col_class = advanced ? 'col-lg-4' : 'col-lg-6';
|
|
|
|
html += `
|
|
<div class="border rounded p-4 mb-4"
|
|
data-item-id="${item.item_id}"
|
|
data-sku="${item.product_sku}"
|
|
data-row="${rowId}">
|
|
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div>
|
|
<span class="fw-semibold">${item.product_sku}</span>
|
|
<span class="text-muted ms-2 small">${item.product_name || ''}</span>
|
|
</div>
|
|
<div class="d-flex gap-3 text-muted small">
|
|
<span>Ordered: <strong>${format_quantity(item.ordered_qty)}</strong></span>
|
|
<span>Received: <strong>${format_quantity(item.received_qty)}</strong></span>
|
|
<span>Remaining: <strong class="text-primary">${format_quantity(item.remaining_qty)}</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
|
|
<div class="col-lg-3">
|
|
<label class="form-label small text-muted">Warehouse <span class="text-danger">*</span></label>
|
|
<select id="recv_wh_${rowId}" class="form-select form-select-sm"
|
|
onchange="on_recv_wh_change('${rowId}')">
|
|
${wh_options}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="col-lg-3">
|
|
<label class="form-label small text-muted">Receiving Qty <span class="text-danger">*</span></label>
|
|
<input type="number" id="recv_qty_${rowId}" class="form-control form-control-sm"
|
|
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
|
|
</div>
|
|
|
|
<div class="col-lg-3">
|
|
<label class="form-label small text-muted">Lot Number</label>
|
|
<input type="text" id="recv_lot_${rowId}" class="form-control form-control-sm lot_search"
|
|
placeholder="Type to search or enter new lot..." autocomplete="off">
|
|
</div>
|
|
|
|
<div class="col-lg-3">
|
|
<label class="form-label small text-muted">Expiry Date</label>
|
|
<input type="text" id="recv_expiry_${rowId}" class="form-control form-control-sm"
|
|
placeholder="DD/MM/YYYY">
|
|
</div>
|
|
|
|
<div class="col-lg-3">
|
|
<label class="form-label small text-muted">Serial Number</label>
|
|
<input type="text" id="recv_serial_${rowId}" class="form-control form-control-sm"
|
|
placeholder="Optional...">
|
|
</div>
|
|
|
|
${zone_aisle_html}
|
|
|
|
<div class="${bin_col_class}">
|
|
<label class="form-label small text-muted">${label_bin} <span class="text-danger">*</span></label>
|
|
<select id="recv_bin_${rowId}" class="form-select form-select-sm">
|
|
<option value="">Select ${label_bin.toLowerCase()}...</option>
|
|
</select>
|
|
</div>
|
|
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
$('#receive_rows_container').html(html);
|
|
|
|
// Init flatpickr on each expiry field with ISO value, alt dd/mm/yyyy display
|
|
$.each(receivable, function(i) {
|
|
var rowId = 'r' + i;
|
|
recv_expiry_pickers[rowId] = flatpickr(`#recv_expiry_${rowId}`, {
|
|
dateFormat: 'Y-m-d',
|
|
altInput: true,
|
|
altFormat: 'd/m/Y',
|
|
allowInput: true,
|
|
onReady: function(_, __, fp) {
|
|
fp.altInput.classList.add('form-control', 'form-control-sm');
|
|
}
|
|
});
|
|
if (window.init_lot_search_inputs) {
|
|
window.init_lot_search_inputs(`#recv_lot_${rowId}`);
|
|
}
|
|
});
|
|
|
|
// Pre-select default warehouse and trigger cascade for each row
|
|
$.each(receivable, function(i) {
|
|
var rowId = 'r' + i;
|
|
if (default_wh) {
|
|
$(`#recv_wh_${rowId}`).val(default_wh);
|
|
on_recv_wh_change(rowId);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Set view/edit mode ────────────────────────────────────────────────────
|
|
function set_mode(status, receivable_count, po_res, closed_at) {
|
|
status = parseInt(status);
|
|
receivable_count = receivable_count ?? 0;
|
|
po_res = po_res || {};
|
|
var is_closed = !!closed_at;
|
|
var is_draft = status === 0;
|
|
var is_pending = status === -2;
|
|
var is_editable = is_pending || is_draft;
|
|
var can_confirm = status === 0;
|
|
var can_cancel = status >= -2 && status <= 1;
|
|
var can_receive = status === 1 && receivable_count > 0 && !is_closed;
|
|
var can_close = status === 1 && !is_closed;
|
|
var is_cancelled = status === -1;
|
|
var has_pi = !!po_res.has_active_pi;
|
|
var pi_id = po_res.active_pi_id || 0;
|
|
var has_received = (po_res.returnable_items || []).length > 0;
|
|
|
|
$('#btn_add_item, #contact, #po_date, #expected_date, #warehouse_id, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee')
|
|
.prop('disabled', !is_editable);
|
|
$('.item_sku, .item_qty, .item_price, .item_tax_rate').prop('disabled', !is_editable);
|
|
$('.remove_item_btn').toggleClass('d-none', !is_editable);
|
|
|
|
set_btn_state('#btn_save', is_editable, 'Only pending or draft POs 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 a default warehouse and save first. The PO will move to Draft.' : 'PO must be in draft to confirm');
|
|
set_btn_state('#btn_cancel', can_cancel && !is_cancelled,
|
|
is_cancelled ? 'PO is already cancelled' : 'Only draft or confirmed POs can be cancelled');
|
|
$('#btn_delete').toggleClass('d-none', !can_delete || !po_id);
|
|
|
|
$('#receive_card').toggleClass('d-none', !can_receive);
|
|
$('#btn_close_po').toggleClass('d-none', !can_close);
|
|
|
|
// Purchase Invoice button
|
|
$('#btn_proceed_pi').toggleClass('d-none', has_pi || status < 1 || is_cancelled);
|
|
if (has_pi && pi_id) {
|
|
$('#btn_view_pi')
|
|
.attr('href', '<?php echo $server_url?>po/manage_purchase_invoice.php?id=' + pi_id);
|
|
}
|
|
$('#btn_view_pi').toggleClass('d-none', !has_pi);
|
|
|
|
// New Supplier Return — only when goods have been received (status 2 or 3)
|
|
$('#btn_new_return').toggleClass('d-none', !has_received || is_cancelled);
|
|
}
|
|
|
|
|
|
// ── Load existing PO ──────────────────────────────────────────────────────
|
|
function retrieve_po() {
|
|
if (!po_id) return Promise.resolve();
|
|
return ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/retrieve_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'read',
|
|
data: { id: po_id },
|
|
onSuccess: function(res) {
|
|
po_data = res.output;
|
|
var o = po_data;
|
|
|
|
$('#page_title').text(o.po_number);
|
|
$('#page_subtitle').text('');
|
|
$('#status_card').removeClass('d-none');
|
|
$('#badge_status').html(po_status_badge(o.status));
|
|
$('#po_number_display').text(o.po_number);
|
|
$('#expected_date_display')
|
|
.toggleClass('d-none', !o.expected_date)
|
|
.text(o.expected_date ? 'Expected: ' + format_date(o.expected_date) : '');
|
|
|
|
$('#contact').val(o.contact_name || '');
|
|
$('#contact_id').val(o.contact_id || '');
|
|
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
|
|
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
|
|
$('#warehouse_id').val(o.warehouse_id || '');
|
|
$('#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);
|
|
$('#item_tbody').empty();
|
|
(o.items || []).forEach(function(item) { add_item_row(item); });
|
|
recalc_totals();
|
|
|
|
var receivable = res.receivable_items || [];
|
|
set_mode(o.status, receivable.length, res, o.closed_at);
|
|
if (receivable.length > 0) {
|
|
build_receive_rows(receivable);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Save draft ────────────────────────────────────────────────────────────
|
|
function save_po() {
|
|
var items = collect_items();
|
|
if (!items.length) { bootbox.alert('Please add at least one item.'); return; }
|
|
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/manage_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: po_id ? 'update' : 'create',
|
|
data: {
|
|
id: po_id,
|
|
contact_id: $('#contact_id').val() || 0,
|
|
department_id: $('#department_id').val() || 0,
|
|
po_date: to_iso_date($('#po_date').val()),
|
|
expected_date: to_iso_date($('#expected_date').val()) || '',
|
|
warehouse_id: $('#warehouse_id').val() || 0,
|
|
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 (!po_id && res.new_id) {
|
|
window.location.href = '<?php echo $server_url?>po/manage_po.php?id=' + res.new_id;
|
|
} else {
|
|
retrieve_po();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Confirm PO ────────────────────────────────────────────────────────────
|
|
function confirm_po() {
|
|
bootbox.confirm({
|
|
message: 'Confirm this purchase order? It will be locked for editing.',
|
|
buttons: {
|
|
confirm: { label: 'Confirm PO', className: 'btn-success' },
|
|
cancel: { label: 'Back', className: 'btn-secondary' }
|
|
},
|
|
callback: function(result) {
|
|
if (!result) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/confirm_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'update',
|
|
data: { id: po_id },
|
|
onSuccess: function() { retrieve_po(); }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Receive goods ─────────────────────────────────────────────────────────
|
|
function receive_goods() {
|
|
var receive_items = [];
|
|
var errors = [];
|
|
|
|
$('#receive_rows_container [data-row]').each(function() {
|
|
var $card = $(this);
|
|
var rowId = $card.data('row');
|
|
var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0;
|
|
if (qty <= 0) return;
|
|
|
|
// Received quantities are stored as decimal(18,4). A value finer than
|
|
// that is rounded away on insert, so 0.0000001 was accepted, created a
|
|
// stock movement of 0.0000, and still left the PO showing "Partial".
|
|
if (round_dp(qty, 4) < 0.0001) {
|
|
errors.push(`${$card.data('sku')}: receiving quantity ${qty} is smaller than the minimum the system records (0.0001).`);
|
|
return;
|
|
}
|
|
qty = round_dp(qty, 4);
|
|
|
|
var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0;
|
|
var bin = $(`#recv_bin_${rowId}`).val() || '';
|
|
|
|
if (!wh_id) {
|
|
errors.push(`${$card.data('sku')}: please select a warehouse.`);
|
|
return;
|
|
}
|
|
if (!bin) {
|
|
errors.push(`${$card.data('sku')}: please select a ${label_bin.toLowerCase()}.`);
|
|
return;
|
|
}
|
|
|
|
var zone = advanced ? ($(`#recv_zone_${rowId}`).val() || '') : '';
|
|
var aisle = advanced ? ($(`#recv_aisle_${rowId}`).val() || '') : '';
|
|
if (!advanced) {
|
|
zone = bin;
|
|
aisle = bin;
|
|
}
|
|
|
|
// flatpickr with altInput: the real (hidden) input holds the ISO value
|
|
var expiry_val = $(`#recv_expiry_${rowId}`).val() || '';
|
|
var lot_val = $(`#recv_lot_${rowId}`).val().trim();
|
|
|
|
// Expiry dates are stored on md_lot, keyed by lot number — one entered
|
|
// without a lot has nowhere to go and was dropped without warning, so
|
|
// the received stock then showed no expiry at all.
|
|
if (expiry_val && !lot_val) {
|
|
errors.push(`${$card.data('sku')}: enter a lot number — expiry dates are recorded against a lot.`);
|
|
return;
|
|
}
|
|
|
|
receive_items.push({
|
|
item_id: parseInt($card.data('item-id')),
|
|
product_sku: $card.data('sku'),
|
|
warehouse_id: wh_id,
|
|
quantity: qty,
|
|
lot_number: lot_val,
|
|
expiry_date: expiry_val,
|
|
serial_number: $(`#recv_serial_${rowId}`).val().trim(),
|
|
zone: zone,
|
|
aisle: aisle,
|
|
bin: bin,
|
|
});
|
|
});
|
|
|
|
if (errors.length) {
|
|
bootbox.alert('<ul class="mb-0 ps-3">' + errors.map(e => `<li>${e}</li>`).join('') + '</ul>');
|
|
return;
|
|
}
|
|
if (!receive_items.length) {
|
|
bootbox.alert('Please enter at least one quantity to receive.');
|
|
return;
|
|
}
|
|
|
|
bootbox.confirm({
|
|
message: `Receive ${receive_items.length} item line(s)? Stock-in rows will be created.`,
|
|
buttons: {
|
|
confirm: { label: 'Receive Goods', className: 'btn-success' },
|
|
cancel: { label: 'Back', className: 'btn-secondary' }
|
|
},
|
|
callback: function(result) {
|
|
if (!result) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/receive_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'create',
|
|
data: {
|
|
id: po_id,
|
|
receive_items: JSON.stringify(receive_items),
|
|
},
|
|
onSuccess: function(res) {
|
|
bootbox.alert('Goods received successfully.', function() { retrieve_po(); });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Proceed to Purchase Invoice ───────────────────────────────────────────
|
|
function proceed_to_purchase_invoice() {
|
|
bootbox.confirm({
|
|
message: 'Create a purchase invoice from this PO?',
|
|
buttons: {
|
|
confirm: { label: 'Create Purchase Invoice', className: 'btn-primary' },
|
|
cancel: { label: 'Back', className: 'btn-secondary' }
|
|
},
|
|
callback: function(result) {
|
|
if (!result) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/proceed_to_purchase_invoice.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'create',
|
|
data: { po_id: po_id },
|
|
onSuccess: function(res) {
|
|
window.location.href = '<?php echo $server_url?>po/manage_purchase_invoice.php?id=' + res.new_id;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Cancel PO ────────────────────────────────────────────────────────────
|
|
function close_po() {
|
|
bootbox.confirm({
|
|
message: 'Close this PO? No further goods receipts will be accepted. This cannot be undone.',
|
|
buttons: {
|
|
confirm: { label: 'Yes, Close PO', className: 'btn-warning' },
|
|
cancel: { label: 'Back', className: 'btn-secondary' }
|
|
},
|
|
callback: function(result) {
|
|
if (!result) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/close_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'update',
|
|
data: { id: po_id },
|
|
onSuccess: function() { retrieve_po(); }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function delete_po() {
|
|
bootbox.confirm({
|
|
message: 'Delete this PO? Unconfirmed stock-in reservations will be released.',
|
|
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?>po/api/engine/delete_po.php',
|
|
autoPrepare: false,
|
|
checkRequired: 0,
|
|
action: 'delete',
|
|
data: { id: po_id },
|
|
onSuccess: function() { window.location.href = '<?php echo $server_url?>po/po.php'; }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function cancel_po() {
|
|
bootbox.confirm({
|
|
message: 'Cancel this purchase order? Any pending (unapproved) stock-in rows will be cancelled.',
|
|
buttons: {
|
|
confirm: { label: 'Yes, Cancel PO', className: 'btn-danger' },
|
|
cancel: { label: 'Back', className: 'btn-secondary' }
|
|
},
|
|
callback: function(result) {
|
|
if (!result) return;
|
|
ajax_request({
|
|
url: '<?php echo $server_url?>po/api/engine/cancel_po.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
action: 'update',
|
|
data: { id: po_id },
|
|
onSuccess: function() { retrieve_po(); }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ── Retrieve warehouses ───────────────────────────────────────────────────
|
|
function retrieve_warehouses() {
|
|
var all_warehouses = ajax_request({
|
|
url: '<?php echo $server_url?>inventory/api/engine/warehouse.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
queueLock: false,
|
|
action: 'read',
|
|
onSuccess: function(res) {
|
|
warehouses = res.output || [];
|
|
var opts = '<option value="">Select warehouse...</option>';
|
|
warehouses.forEach(function(w) {
|
|
opts += `<option value="${w.id}">${escape_html(w.warehouse_name)}</option>`;
|
|
});
|
|
$('#warehouse_id').html(opts);
|
|
}
|
|
});
|
|
|
|
var receivable_warehouses = ajax_request({
|
|
url: '<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php',
|
|
autoPrepare: true, checkRequired: 0,
|
|
queueLock: false,
|
|
action: 'read',
|
|
data: { type: 'to' },
|
|
onSuccess: function(res) {
|
|
receive_warehouses = res.output || [];
|
|
}
|
|
});
|
|
|
|
return $.when(all_warehouses, receivable_warehouses);
|
|
}
|
|
|
|
|
|
$(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_display').text(product.product_name);
|
|
$row.find('.item_name').val(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 load_location_config();
|
|
await retrieve_warehouses();
|
|
|
|
if (po_id) {
|
|
await retrieve_po();
|
|
} else {
|
|
$('#po_date').val(flatpickr.formatDate(new Date(), 'd/m/Y'));
|
|
add_item_row();
|
|
set_mode(0);
|
|
}
|
|
|
|
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
|
|
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
|
|
|
|
} catch(e) {
|
|
console.log(e);
|
|
}
|
|
});
|
|
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|