Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts and use product names and supplier batches.
1001 lines
39 KiB
PHP
1001 lines
39 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="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
|
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
|
</td>
|
|
<td class="item_total text-end">${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_number(item.ordered_qty, 0)}</strong></span>
|
|
<span>Received: <strong>${format_number(item.received_qty, 0)}</strong></span>
|
|
<span>Remaining: <strong class="text-primary">${format_number(item.remaining_qty, 0)}</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="any">
|
|
</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;
|
|
|
|
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() || '';
|
|
|
|
receive_items.push({
|
|
item_id: parseInt($card.data('item-id')),
|
|
product_sku: $card.data('sku'),
|
|
warehouse_id: wh_id,
|
|
quantity: qty,
|
|
lot_number: $(`#recv_lot_${rowId}`).val().trim(),
|
|
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 {
|
|
load_departments('department_id');
|
|
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>
|