Files
wms-app/app/ics/manage_stock_in.php

672 lines
22 KiB
PHP

<?php
require_once __DIR__ . '/../session.php';
require '../config.php';
if (!in_array($_SESSION['login_role'] ?? 'viewer', ['owner', 'admin', 'staff'], true)) {
http_response_code(403);
exit('Access denied.');
}
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php';?>
<?php require '../include_sidebar.php';?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row ">
<div class="col-12">
<div class="mb-6">
<h1 class="fs-3 mb-0">Stock In</h1>
</div>
</div>
</div>
<div class="row g">
<div class="col-lg-8 col-12">
<div class="card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Add Stock</h2>
<form class="row gx-5">
<!-- Product Name -->
<div class="mb-3 col-lg-3">
<label for="product_sku" class="form-label">SKU</label>
<div class="input-group">
<input type="text" id="product_sku" class="form-control product_search"
placeholder="Type product sku..." autocomplete="off" required>
<button class="btn btn-outline-secondary" type="button" onclick="scanner.open_camera()" title="Scan barcode label">
<i class="ti ti-scan"></i>
</button>
</div>
</div>
<div class="mb-3 col-lg-3">
<label for="product_name" class="form-label">Name</label>
<input type="text" id="product_name" class="form-control" autocomplete="off" disabled>
</div>
<!-- SKU or Product Code -->
<div class="mb-3 col-lg-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" placeholder="Enter quantity" required>
</div>
<!-- Unit Price / COGS -->
<div class="mb-3 col-lg-3">
<label for="price" class="form-label">Unit Price <span class="text-muted fw-normal">(optional)</span></label>
<input type="number" class="form-control" id="price" placeholder="0.00" step="0.01" min="0">
</div>
<!-- Lot Number -->
<div class="mb-3 col-lg-6">
<label for="lot_number" class="form-label">Lot Number <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control lot_search" id="lot_number" placeholder="Type to search or enter new lot..." autocomplete="off">
</div>
<!-- Expiry Date (only relevant when lot_number is set) -->
<div class="mb-3 col-lg-6">
<label for="expiry_date" class="form-label">Expiry Date <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" id="expiry_date" placeholder="DD/MM/YYYY" autocomplete="off">
</div>
<!-- Serial Number -->
<div class="mb-3 col-lg-6">
<label for="serial_number" class="form-label">Serial Number <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" id="serial_number" placeholder="Enter serial number">
</div>
<!-- Warehouse -->
<div class="mb-3 col-lg-6">
<label for="warehouse" class="form-label">Warehouse</label>
<select name="warehouse" id="warehouse" class="form-select" required></select>
</div>
<div class="mb-3 col-lg-6" id="group_zone">
<label for="zone" class="form-label" id="label_zone">Zone</label>
<select name="zone" id="zone" class="form-select">
<option value="">Please select zone</option>
</select>
</div>
<div class="mb-3 col-lg-6" id="group_aisle">
<label for="aisle" class="form-label" id="label_aisle">Aisle</label>
<select name="aisle" id="aisle" class="form-select">
<option value="">Please select aisle</option>
</select>
</div>
<div class="mb-3 col-lg-6" id="group_bin">
<label for="bin" class="form-label" id="label_bin">Bin</label>
<select name="bin" id="bin" class="form-select" required>
<option value="">Please select bin</option>
</select>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
<input type="text" class="form-control contact_search" id="contact" placeholder="Enter contact name">
</div>
<!-- Notes / Description -->
<div class="mb-3 col-lg-12">
<label for="description" class="form-label">Notes / Description</label>
<textarea class="form-control" id="description" rows="3" placeholder="Enter any notes"></textarea>
</div>
<!-- Submit Button -->
<div class="mb-3 col-lg-12">
<button type="submit" class="btn btn-primary" data-min-role="staff" onclick="manage_stock_in();">Add Stock</button>
<button type="button" class="btn btn-success ms-2 d-none" id="approve_stock_btn" onclick="approve_current_stock();">Approve</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require "../include_ending.php";?>
<script>
// custom.js globals: set_select_value(), load_location_config(), init_lot_search_inputs()
var advanced = false;
var label_bin = 'Location';
var label_zone = 'Zone';
var label_aisle = 'Aisle';
var scan_sku_ready = false;
var scan_location_ready = false;
function apply_location_labels() {
if (advanced) {
$('#group_zone').show();
$('#group_aisle').show();
$('#label_zone').text(label_zone);
$('#label_aisle').text(label_aisle);
$('#label_bin').text(label_bin);
$('#zone').attr('placeholder', 'Please select ' + label_zone.toLowerCase());
$('#aisle').attr('placeholder', 'Please select ' + label_aisle.toLowerCase());
$('#bin').attr('placeholder', 'Please select ' + label_bin.toLowerCase());
} else {
$('#group_zone').hide();
$('#group_aisle').hide();
$('#label_bin').text(label_bin);
$('#bin').attr('placeholder', 'Please select ' + label_bin.toLowerCase());
}
}
function apply_sku_label_scan(data) {
$('#product_sku').val(data.sku).attr('secondary', data.sku);
$('#product_name').val(data.product_name || '').attr('secondary', data.product_name || '');
$('#lot_number').val(data.lot_number || '');
$('#serial_number').val(data.serial_number || '');
$('#quantity').val($('#quantity').val() || 1);
scan_sku_ready = true;
scan_location_ready = false;
$('#warehouse').val('');
$('#zone').val('');
$('#aisle').val('');
$('#bin').val('');
$('#product_sku').trigger('blur');
}
function apply_location_label_scan(data) {
if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) {
bootbox.alert('Scan the SKU label first, then scan the location label.');
return;
}
validate_scanned_location('in', data, function() {
set_select_value('#warehouse', data.warehouse_id, data.warehouse_id);
if (advanced) {
set_select_value('#zone', data.zone, data.zone);
set_select_value('#aisle', data.aisle, data.aisle);
}
set_select_value('#bin', data.bin, data.bin);
scan_location_ready = true;
});
}
function validate_scanned_location(mode, data, onSuccess) {
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/validate_scan_location.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
data: {
mode: mode,
warehouse_id: data.warehouse_id,
zone: data.zone,
aisle: data.aisle,
bin: data.bin,
product_sku: $('#product_sku').attr('secondary') || $('#product_sku').val(),
lot_number: $('#lot_number').val(),
serial_number: $('#serial_number').val()
},
onSuccess: onSuccess
});
}
function reset_stock_in_inputs() {
$('form')[0].reset();
$('#product_sku').removeAttr('secondary').prop('disabled', false);
$('#product_name').removeAttr('secondary').val('');
$('#lot_number, #serial_number, #expiry_date').prop('disabled', false);
$('#contact').removeAttr('secondary data-id');
if (expiryPicker) expiryPicker.clear();
if (!advanced) {
$('#zone').val('');
$('#aisle').val('');
}
$('#warehouse').val('');
$('#zone').val('');
$('#aisle').val('');
$('#bin').val('');
scan_sku_ready = false;
scan_location_ready = false;
$('#product_sku').focus();
}
function save_scanned_stock_in() {
if (!scan_sku_ready && !($('#product_sku').attr('secondary') || $('#product_sku').val())) {
bootbox.alert('Scan the SKU label first.');
return;
}
if (!scan_location_ready && !$('#bin').val()) {
bootbox.alert('Scan the location label before saving.');
return;
}
manage_stock_in();
}
function manage_stock_in() {
var bin_val = $('#bin').val();
var zone_val = advanced ? $('#zone').val() : bin_val;
var aisle_val = advanced ? $('#aisle').val() : bin_val;
var warehouse_val = $('#warehouse').val();
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val();
// Quantities are stored as decimal(18,4). Anything finer is rounded away
// on insert, so 0.0000001 used to become a movement of 0.0000 that still
// looked like a successful stock-in. Reject it here with a message that
// names the limit; StockManager enforces the same rule server-side.
<?php if (empty($_GET["id"])) { ?>
var quantity_num = parseFloat(quantity_val);
if (!(quantity_num > 0)) {
bootbox.alert('Please enter a quantity greater than zero.');
return Promise.resolve();
}
if (round_dp(quantity_num, 4) < 0.0001) {
bootbox.alert('Quantity ' + quantity_num + ' is smaller than the minimum the system records (0.0001).');
return Promise.resolve();
}
quantity_val = round_dp(quantity_num, 4);
// Expiry dates are stored on the lot, so one entered without a lot number
// has nowhere to go and would be dropped without warning.
if ($('#expiry_date').val() && !$('#lot_number').val().trim()) {
bootbox.alert('Enter a lot number — expiry dates are recorded against a lot.');
return Promise.resolve();
}
<?php } ?>
// Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) {
$('#zone').val(bin_val);
$('#aisle').val(bin_val);
}
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/manage_stock_in.php',
autoPrepare: true,
checkRequired: 1,
debugMode: 0,
data: {
'contact_id': $('#contact').attr('data-id'),
'lot_number': $('#lot_number').val(),
'expiry_date': $('#expiry_date').val(),
'serial_number': $('#serial_number').val(),
'zone': zone_val,
'aisle': aisle_val,
'bin': bin_val,
'product_sku': sku_val,
'quantity': quantity_val,
'warehouse': warehouse_val,
},
action: 'manage',
onSuccess: function() {
<?php if(empty($_GET["id"])){ ?>
reset_stock_in_inputs();
<?php } else { ?>
window.location.href = '<?php echo $server_url?>ics/stock_in.php';
<?php } ?>
}
});
}
function approve_current_stock() {
bootbox.confirm({
message: 'Approve this stock-in record?',
buttons: { confirm: { label: 'Approve', className: 'btn-success' }, cancel: { label: 'Cancel', className: 'btn-secondary' } },
callback: function(result) {
if (!result) return;
ajax_request({
url: '<?php echo $server_url?>ics/api/engine/approve_stock.php',
autoPrepare: true,
checkRequired: 0,
action: 'update',
data: {
id: '<?php echo $_GET["id"] ?? "";?>',
warehouse: '<?php echo $_GET["wh"] ?? "";?>',
type: 'in'
},
onSuccess: function() {
window.location.reload();
}
});
}
});
}
function retrieve_warehouse(type) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_warehouse.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"type": type
},
action: 'read',
onSuccess: function(res) {
var option = `<option value="">Please select warehouse</option>`;
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
})
$(`select#warehouse`).html(option);
}
});
}
function retrieve_zone(type, callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_zone.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data : {
"type": type
},
debugMode: 0,
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_zone.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += '<option value="' + item.zone + '">' + item.zone + '</option>';
});
$('select#zone').html(option);
if (callback) callback(); // ← add this
}
});
}
function retrieve_aisle(type, callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_aisle.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"type": type
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_aisle.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += '<option value="' + item + '">' + item + '</option>';
});
$('select#aisle').html(option);
if (callback) callback(); // ← add this
}
});
}
function retrieve_bin(type, callback) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_bin.php",
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
data: {
"type": type
},
action: 'read',
onSuccess: function(res) {
var option = '<option value="">Please select ' + label_bin.toLowerCase() + '</option>';
$.each(res.output, function(key, item) {
option += '<option value="' + item + '">' + item + '</option>';
});
$('select#bin').html(option);
if (callback) callback(); // ← add this
}
});
}
function retrieve_for_edit() {
<?php if(empty($_GET["id"])){ ?>
return false;
<?php } ?>
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/retrieve_stock_in.php",
autoPrepare: true,
checkRequired: 0,
debugMode: 0,
action: 'read',
onSuccess: function(res) {
var data = res.output;
$('#warehouse').val('<?php echo $_GET["wh"] ?? "";?>').attr('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
if (!['warehouse', 'zone', 'aisle', 'bin'].includes(key)) {
$('#' + key).val(item);
}
});
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#lot_number').val(data.lot_number || '').prop('disabled', true);
$('#serial_number').val(data.serial_number || '').prop('disabled', true);
if (data.lot_number) {
if (expiryPicker) { expiryPicker.setDate(data.expiry_date || '', true, 'Y-m-d'); }
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry date is locked for existing lots')
.val(data.expiry_date);
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
} else {
// No lot number, so there is nothing for an expiry date to hang
// off: expiry lives on md_lot, keyed by lot. Leaving the field
// empty but editable invited entering one that would be silently
// discarded on update, so say why it is unavailable instead.
if (expiryPicker) { expiryPicker.clear(); }
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry dates are recorded against a lot — this movement has no lot number')
.attr('placeholder', 'Not tracked — no lot number');
if (expiryPicker && expiryPicker.altInput) {
expiryPicker.altInput.disabled = true;
expiryPicker.altInput.placeholder = 'Not tracked — no lot number';
}
}
$('#product_sku').attr('secondary', data.product_sku);
$('#product_sku, #quantity, #price').prop('disabled', true);
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
$('#approve_stock_btn').removeClass('d-none');
}
}
if (advanced) {
retrieve_zone('to', function() {
$('select#zone').val(data.zone).attr('disabled', true);
retrieve_aisle('to', function() {
$('#aisle').val(data.aisle).attr('disabled', true);
retrieve_bin('to', function() {
$('#bin').val(data.bin).attr('disabled', true);
populate_fields();
});
});
});
} else {
// Simple mode — skip zone/aisle cascade
retrieve_bin('to', function() {
$('#bin').val(data.bin).attr('disabled', true);
populate_fields();
});
}
}
});
}
// ── Prefill for restock flow (coming from low-stock-products.php) ────
function prefill_from_restock() {
var params = new URLSearchParams(window.location.search);
var sku = params.get('sku');
var name = params.get('name');
var qty = params.get('qty');
var wh = params.get('wh');
// Not a restock flow — nothing to prefill
if (!sku) return false;
// Product fields (locked — the point of restock is this specific SKU)
$('#product_sku')
.val(sku)
.attr('secondary', sku)
.prop('disabled', true);
$('#product_name')
.val(name || '')
.attr('secondary', name || '');
// already disabled in markup
// Suggested quantity (prefilled value only; user can still edit)
if (qty) {
$('#quantity').val(qty);
}
// Warehouse: lock it (can't restock to a different warehouse than the one that's low)
if (wh) {
$('#warehouse').val(wh).prop('disabled', true);
// Chain-load location selects — skip zone/aisle in simple mode
if (advanced) {
retrieve_zone('to', function() {
retrieve_aisle('to', function() {
retrieve_bin('to');
});
});
} else {
retrieve_bin('to');
}
}
return true;
}
$(async function() {
try {
await load_location_config(apply_location_labels);
await retrieve_warehouse('to');
await retrieve_for_edit();
scanner.init({
allowRaw: false,
allowedTypes: ['sku', 'loc'],
onScan: function(sku, product_name, uom, data) {
if (data.type === 'sku') apply_sku_label_scan(data);
if (data.type === 'loc') apply_location_label_scan(data);
}
});
<?php if(!empty($_GET["sku"])){ ?>
prefill_from_restock();
<?php } ?>
} catch (e) {
console.log(e);
}
});
$(document).on('lot:selected', '#lot_number', function(e, lot) {
if (expiryPicker) {
expiryPicker.setDate(lot.expiry_date || '', true, 'Y-m-d');
if (expiryPicker.altInput) expiryPicker.altInput.disabled = true;
}
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry date is locked for existing lots');
});
$(document).on('lot:cleared', '#lot_number', function() {
$('#expiry_date').prop('disabled', false).removeAttr('title');
if (expiryPicker) {
expiryPicker.clear();
if (expiryPicker.altInput) expiryPicker.altInput.disabled = false;
}
});
// ── Flatpickr: display dd/mm/yyyy, send yyyy-mm-dd to API ──────────
var expiryPicker = flatpickr("#expiry_date", {
dateFormat: "Y-m-d", // actual value stored in input (sent to API)
altInput: true, // show a separate visible input
altFormat: "d/m/Y", // visible format: 28/04/2026
allowInput: true,
onReady: function(_, __, fp) {
// Apply Bootstrap form-control class to the alt input
fp.altInput.classList.add("form-control");
},
onChange: function(_, dateStr) {
// If lot is new (not existing), keep expiry_date unlocked
}
});
<?php if(empty($_GET["id"])){ ?>
$(document).on('keydown', function(e) {
if (e.key === 'F2') {
e.preventDefault();
save_scanned_stock_in();
}
});
$("select#warehouse").change(function() {
if (advanced) {
retrieve_zone('to');
} else {
retrieve_bin('to');
}
});
$("select#zone").change(function() {
if (advanced) retrieve_aisle('to');
});
$("select#aisle").change(function() {
if (advanced) retrieve_bin('to');
});
<?php } ?>
</script>
</body>
</html>