Files
wms-app/app/ics/sku_barcode_label.php
T

544 lines
19 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 id="content" class="content py-15">
<div class="container-fluid">
<div class="row mb-6">
<div class="col-12 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">SKU Barcode Labels</h1>
<p class="mb-0 text-muted">Print or reprint product identity labels from the lot master list.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-success" data-min-role="staff" onclick="open_create_label_modal()">
<i class="ti ti-plus me-1"></i>New Barcode
</button>
<button id="btn_print" class="btn btn-outline-success" data-min-role="staff" onclick="print_selected_label()" disabled>
<i class="ti ti-printer me-1"></i>Print
</button>
<button id="btn_disable" class="btn btn-outline-danger" data-min-role="staff" onclick="disable_selected_label()" disabled>
<i class="ti ti-ban me-1"></i>Disable
</button>
</div>
</div>
</div>
<div class="row g-5">
<div class="col-lg-7">
<div class="card">
<div class="card-body p-5">
<div class="position-relative mb-4">
<input type="search" id="search_lot" class="form-control ps-9"
placeholder="Search SKU, product, lot, expiry, or description...">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
<div class="table-responsive table-scroll">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>SKU</th>
<th>Product</th>
<th>Lot No</th>
<th>Expiry</th>
<th>Source</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody id="lot_tbody">
<tr><td colspan="7" class="text-center text-muted py-4">Loading lots...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card">
<div class="card-body p-5">
<div id="label_preview" class="border rounded p-4 text-center text-muted">
Select a lot to preview its label.
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Manage Labels Modal (view existing + add new) -->
<div class="modal fade" id="skuLabelModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-0">Barcode Labels</h5>
<p class="text-muted small mb-0 mt-1" id="modal_lot_info"></p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="modal_lot_index">
<p class="fw-semibold small text-uppercase text-muted mb-2">Existing Labels</p>
<div id="existing_label_list" class="small text-muted mb-4">Loading...</div>
<hr class="my-3">
<p class="fw-semibold small text-uppercase text-muted mb-2">Add New Label</p>
<div class="input-group">
<input type="text" id="modal_serial_number" class="form-control" placeholder="Serial No"
autocomplete="off">
<button class="btn btn-primary" type="button" onclick="generate_label_from_modal()">
<i class="ti ti-barcode me-1"></i>Generate
</button>
</div>
<small class="text-muted">Enter a new serial to create a label, or enter an existing serial to reprint.</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- New Barcode Modal (free-form, lot need not exist yet) -->
<div class="modal fade" id="createSkuLabelModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Barcode</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">SKU</label>
<select id="create_sku" class="form-select" onchange="sync_create_product_name()"></select>
</div>
<div class="mb-3">
<label class="form-label">Product</label>
<input type="text" id="create_product_name" class="form-control" readonly>
</div>
<div class="mb-3">
<label class="form-label">Lot No</label>
<input type="text" id="create_lot_number" class="form-control" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label">Serial No</label>
<input type="text" id="create_serial_number" class="form-control" autocomplete="off">
</div>
<div class="alert alert-light border mb-0 small">
Use this to prepare a label before the lot has been received into the system.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="create_custom_label()">
<i class="ti ti-barcode me-1"></i>Create & Preview
</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script>
var lots = [];
var products = [];
var active_labels = [];
var selected_payload = '';
var selected_label_data = null;
var selected_lot_index = -1;
var sku_label_modal = null;
var create_label_modal = null;
function escape_html(value) {
return String(value ?? '').replace(/[&<>"']/g, function(char) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[char];
});
}
function refresh_header_buttons() {
var has = !!selected_payload;
$('#btn_print').prop('disabled', !has);
$('#btn_disable').prop('disabled', !has);
}
function highlight_selected_row() {
$('#lot_tbody tr').removeClass('table-active');
if (selected_lot_index >= 0) {
$('#lot_tbody tr[data-lot-index="' + selected_lot_index + '"]').addClass('table-active');
}
}
function load_lots() {
return ajax_request({
url: server_url + 'ics/api/engine/sku_label_lots.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
onSuccess: function(res) {
lots = res.output || [];
render_lots();
highlight_selected_row();
}
});
}
function load_products() {
return ajax_request({
url: server_url + 'ics/api/engine/sku_label_products.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
onSuccess: function(res) {
products = res.output || [];
var opts = '<option value="">Select SKU...</option>';
products.forEach(function(item) {
opts += `<option value="${escape_html(item.sku)}">${escape_html(item.sku)} - ${escape_html(item.product_name)}</option>`;
});
$('#create_sku').html(opts);
}
});
}
function selected_create_product() {
var sku = $('#create_sku').val();
return products.find(function(item) { return item.sku === sku; }) || {};
}
function sync_create_product_name() {
var item = selected_create_product();
$('#create_product_name').val(item.product_name || '');
}
function open_create_label_modal() {
$('#create_sku').val('');
$('#create_product_name').val('');
$('#create_lot_number').val('');
$('#create_serial_number').val('');
create_label_modal.show();
}
function render_lots() {
var keyword = $('#search_lot').val().toLowerCase();
var rows = lots.filter(function(item) {
if (!keyword) return true;
return [
item.product_sku,
item.product_name,
item.lot_number,
item.expiry_date,
item.description
].join(' ').toLowerCase().includes(keyword);
});
if (!rows.length) {
$('#lot_tbody').html('<tr><td colspan="7" class="text-center text-muted py-4">No lots found.</td></tr>');
return;
}
var body = '';
rows.forEach(function(item) {
var idx = lots.indexOf(item);
var source = '<span class="badge bg-success">Lot master</span>';
if (item.source === 'active_stock') {
source = '<span class="badge bg-warning text-dark">Stock only</span>';
}
if (item.source === 'md_barcode') {
source = '<span class="badge bg-info text-dark">Barcode only</span>';
}
body += `<tr data-lot-index="${idx}">
<td class="fw-semibold">${escape_html(item.product_sku)}</td>
<td>${escape_html(item.product_name)}</td>
<td>${escape_html(item.lot_number)}</td>
<td>${item.expiry_date ? escape_html(item.expiry_date) : '<span class="text-muted">—</span>'}</td>
<td>${source}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="open_label_modal(${idx})">
<i class="ti ti-list me-1"></i>Labels
</button>
</td>
</tr>`;
});
$('#lot_tbody').html(body);
}
function open_label_modal(index) {
var item = lots[index];
if (!item) return;
$('#modal_lot_index').val(index);
var info = escape_html(item.product_sku) + ' &middot; ' + escape_html(item.lot_number);
if (item.expiry_date) info += ' &middot; Exp ' + escape_html(item.expiry_date);
$('#modal_lot_info').html(info);
$('#modal_serial_number').val('');
$('#existing_label_list').html('Loading...');
sku_label_modal.show();
load_existing_labels(item);
setTimeout(function() { $('#modal_serial_number').trigger('focus'); }, 300);
}
function load_existing_labels(item) {
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: {
product_sku: item.product_sku,
lot_number: item.lot_number
},
onSuccess: function(res) {
active_labels = res.output || [];
if (!active_labels.length) {
$('#existing_label_list').html('<span class="text-muted">No labels prepared for this lot yet.</span>');
return;
}
var html = '<div class="list-group list-group-flush">';
active_labels.forEach(function(label, index) {
var statusBadge = parseInt(label.status) === 1
? '<span class="badge bg-success ms-2">Active</span>'
: '<span class="badge bg-danger ms-2">Disabled</span>';
var actionButtons = parseInt(label.status) === 1
? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="preview_existing_label(${index})">
<i class="ti ti-eye me-1"></i>Select
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="disable_existing_label(${index})">
<i class="ti ti-ban me-1"></i>Disable
</button>`
: `<button type="button" class="btn btn-sm btn-outline-success" onclick="reenable_existing_label(${index})">
<i class="ti ti-refresh me-1"></i>Re-enable
</button>`;
html += `<div class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="small">${escape_html(label.serial_number)} ${statusBadge}</span>
<div class="d-flex gap-2">${actionButtons}</div>
</div>`;
});
html += '</div>';
$('#existing_label_list').html(html);
}
});
}
function preview_label(data, lot_index) {
selected_payload = data.barcode;
selected_label_data = data;
if (lot_index !== undefined) selected_lot_index = lot_index;
$('#label_preview').html(`
<div class="barcode-label mx-auto">
<div class="fw-bold fs-5">${escape_html(data.product_sku)}</div>
<div class="small text-muted">${escape_html(data.product_name)}</div>
<svg id="barcode_svg"></svg>
<div class="small">Lot: ${escape_html(data.lot_number)} &nbsp; Serial: ${escape_html(data.serial_number)}</div>
<div class="small text-muted">${escape_html(data.barcode)}</div>
</div>`);
JsBarcode('#barcode_svg', data.barcode, { format: 'CODE128', width: 1.6, height: 70, displayValue: false });
sku_label_modal.hide();
refresh_header_buttons();
highlight_selected_row();
}
function preview_existing_label(index) {
var label = active_labels[index];
if (!label) return;
var lot_index = parseInt($('#modal_lot_index').val(), 10);
preview_label(label, lot_index);
}
function clear_selected_label() {
selected_payload = '';
selected_label_data = null;
selected_lot_index = -1;
$('#label_preview').html('Select a lot to preview its label.');
refresh_header_buttons();
highlight_selected_row();
}
function disable_barcode_label(barcode, onDone) {
bootbox.confirm({
message: 'Disable this SKU barcode label? It will no longer be accepted by stock scans.',
buttons: {
confirm: { label: 'Disable', className: 'btn-danger' },
cancel: { label: 'Back', className: 'btn-secondary' }
},
callback: function(result) {
if (!result) return;
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: { label_action: 'disable', barcode: barcode },
onSuccess: function() {
clear_selected_label();
load_lots();
onDone?.();
}
});
}
});
}
function disable_existing_label(index) {
var label = active_labels[index];
if (!label) return;
disable_barcode_label(label.barcode, function() {
var item = lots[parseInt($('#modal_lot_index').val(), 10)];
if (item) load_existing_labels(item);
});
}
function reenable_existing_label(index) {
var label = active_labels[index];
if (!label) return;
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: {
label_action: 'create',
allow_unlisted: 1,
product_sku: label.product_sku,
lot_number: label.lot_number,
serial_number: label.serial_number
},
onSuccess: function() {
var item = lots[parseInt($('#modal_lot_index').val(), 10)];
if (item) load_existing_labels(item);
load_lots();
}
});
}
function disable_selected_label() {
disable_barcode_label(selected_payload);
}
function generate_label_from_modal() {
var item = lots[parseInt($('#modal_lot_index').val(), 10)];
var serial = $('#modal_serial_number').val().trim();
if (!item || !serial) {
bootbox.alert('Serial no is required.');
return;
}
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: {
label_action: 'create',
product_sku: item.product_sku,
lot_number: item.lot_number,
serial_number: serial
},
onSuccess: function(res) {
var lot_index = parseInt($('#modal_lot_index').val(), 10);
preview_label(res.output, lot_index);
load_lots();
}
});
}
function create_custom_label() {
var sku = $('#create_sku').val();
var lot = $('#create_lot_number').val().trim();
var serial = $('#create_serial_number').val().trim();
if (!sku || !lot || !serial) {
bootbox.alert('SKU, lot no, and serial no are required.');
return;
}
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: {
label_action: 'create',
allow_unlisted: 1,
product_sku: sku,
lot_number: lot,
serial_number: serial
},
onSuccess: function(res) {
selected_lot_index = -1;
create_label_modal.hide();
preview_label(res.output);
load_lots();
}
});
}
function print_selected_label() {
if (!selected_payload) return;
ajax_request({
url: server_url + 'ics/api/engine/sku_barcode_label.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
data: { label_action: 'print', barcode: selected_payload },
onSuccess: function() { window.print(); }
});
}
$('#search_lot').on('input', render_lots);
$(function() {
sku_label_modal = new bootstrap.Modal(document.getElementById('skuLabelModal'));
create_label_modal = new bootstrap.Modal(document.getElementById('createSkuLabelModal'));
load_lots();
load_products();
});
</script>
<style>
.barcode-label { max-width: 420px; background: #fff; color: #111; }
.barcode-label svg { max-width: 100%; height: auto; }
.table-scroll {
max-height: calc(100vh - 320px);
overflow-y: auto;
}
.table-scroll thead th {
position: sticky;
top: 0;
z-index: 10;
background: #f8f9fa !important;
box-shadow: inset 0 -1px 0 #dee2e6;
}
@media print {
body * { visibility: hidden; }
#label_preview, #label_preview * { visibility: visible; }
#label_preview { position: absolute; left: 0; top: 0; width: 100%; border: 0 !important; }
}
</style>
</body>
</html>