Scan shortcut on main screen
This commit is contained in:
+162
-4
@@ -19,6 +19,9 @@ let _gtinMappingPendingRefresh = false;
|
|||||||
let _gtinMappingPendingVariantId = null;
|
let _gtinMappingPendingVariantId = null;
|
||||||
let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload
|
let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload
|
||||||
let _gtinMappingWaitingForNewDrug = null; // Set of drug IDs before add-drug; resolved on first loadDrugs
|
let _gtinMappingWaitingForNewDrug = null; // Set of drug IDs before add-drug; resolved on first loadDrugs
|
||||||
|
let _highlightedBatchLot = null; // lot number to highlight after a main-screen scan
|
||||||
|
let _highlightedVariantId = null; // variant the highlighted batch belongs to
|
||||||
|
let _highlightClearTimer = null; // timer to auto-remove the highlight
|
||||||
|
|
||||||
/** Build a human-readable pack label from pack fields, e.g. "Box of 28" */
|
/** Build a human-readable pack label from pack fields, e.g. "Box of 28" */
|
||||||
function packLabel(packOrUnitName, packSize) {
|
function packLabel(packOrUnitName, packSize) {
|
||||||
@@ -521,6 +524,9 @@ function setupEventListeners() {
|
|||||||
closeModal(e.target);
|
closeModal(e.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Main-screen barcode scan
|
||||||
|
document.addEventListener('keydown', _onMainScreenKeydown);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load drugs from API
|
// Load drugs from API
|
||||||
@@ -863,11 +869,22 @@ function populateDispensePackSelect(variant) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (packCount) packCount.value = '';
|
if (packCount) packCount.value = '';
|
||||||
|
|
||||||
|
// Auto-select the first available pack
|
||||||
|
if (activePacks.length > 0) {
|
||||||
|
packSelect.value = String(activePacks[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
if (packPreview) {
|
if (packPreview) {
|
||||||
packPreview.textContent = activePacks.length > 0
|
packPreview.textContent = activePacks.length > 0
|
||||||
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
||||||
: `No active packs for this variant.`;
|
: `No active packs for this variant.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update preview to reflect the auto-selected pack
|
||||||
|
if (activePacks.length > 0) {
|
||||||
|
updateDispenseQuantityFromPack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDispenseModeUi() {
|
function updateDispenseModeUi() {
|
||||||
@@ -999,13 +1016,16 @@ function renderVariantInventoryDetails(variant, batches) {
|
|||||||
const stocktakeLabel = hasPackState
|
const stocktakeLabel = hasPackState
|
||||||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
||||||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
||||||
const batchCardStyles = expired
|
const isHighlighted = _highlightedBatchLot === batch.batch_number && _highlightedVariantId === variant.id;
|
||||||
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
const batchCardStyles = isHighlighted
|
||||||
: 'padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;';
|
? 'padding: 8px; background: #fef9c3; border: 2px solid #f59e0b; border-radius: 5px; font-size: 0.9em;'
|
||||||
|
: expired
|
||||||
|
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
||||||
|
: 'padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;';
|
||||||
const expiryStyles = expired ? 'color: #b91c1c; font-weight: 700;' : 'color: #4b5563;';
|
const expiryStyles = expired ? 'color: #b91c1c; font-weight: 700;' : 'color: #4b5563;';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="${batchCardStyles}">
|
<div style="${batchCardStyles}" data-batch-number="${escapeHtml(batch.batch_number)}" data-variant-id="${variant.id}">
|
||||||
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
||||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||||||
@@ -3341,6 +3361,68 @@ let _preScanFocusedValue = null; // its value before any scan chars were typed
|
|||||||
const SCAN_MAX_GAP_MS = 50;
|
const SCAN_MAX_GAP_MS = 50;
|
||||||
const SCAN_MIN_LENGTH = 20;
|
const SCAN_MIN_LENGTH = 20;
|
||||||
|
|
||||||
|
// Separate buffer for main-screen scan (does not interfere with delivery modal)
|
||||||
|
let _mainScanBuffer = [];
|
||||||
|
let _mainScanBufferTimer = null;
|
||||||
|
let _mainPreScanFocusedInput = null;
|
||||||
|
let _mainPreScanFocusedValue = null;
|
||||||
|
|
||||||
|
function _onMainScreenKeydown(e) {
|
||||||
|
// Only act when no modal is open
|
||||||
|
if (document.querySelector('.modal.show')) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const raw = _mainScanBuffer.map(x => x.char).join('');
|
||||||
|
_mainScanBuffer = [];
|
||||||
|
if (_mainScanBufferTimer) { clearTimeout(_mainScanBufferTimer); _mainScanBufferTimer = null; }
|
||||||
|
|
||||||
|
if (raw.length >= SCAN_MIN_LENGTH) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (_mainPreScanFocusedInput) {
|
||||||
|
_mainPreScanFocusedInput.value = _mainPreScanFocusedValue || '';
|
||||||
|
}
|
||||||
|
_mainPreScanFocusedInput = null;
|
||||||
|
_mainPreScanFocusedValue = null;
|
||||||
|
handleMainScreenBarcodeScan(raw);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key.length === 1) {
|
||||||
|
const gap = _mainScanBuffer.length > 0 ? now - _mainScanBuffer[_mainScanBuffer.length - 1].time : 0;
|
||||||
|
|
||||||
|
if (_mainScanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
|
||||||
|
_mainScanBuffer = [];
|
||||||
|
_mainPreScanFocusedInput = null;
|
||||||
|
_mainPreScanFocusedValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mainScanBuffer.length === 0) {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
||||||
|
_mainPreScanFocusedInput = active;
|
||||||
|
_mainPreScanFocusedValue = active.value;
|
||||||
|
} else {
|
||||||
|
_mainPreScanFocusedInput = null;
|
||||||
|
_mainPreScanFocusedValue = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
_mainScanBuffer.push({ char: e.key, time: now });
|
||||||
|
|
||||||
|
if (_mainScanBufferTimer) clearTimeout(_mainScanBufferTimer);
|
||||||
|
_mainScanBufferTimer = setTimeout(() => {
|
||||||
|
_mainScanBuffer = [];
|
||||||
|
_mainPreScanFocusedInput = null;
|
||||||
|
_mainPreScanFocusedValue = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _onDeliveryModalKeydown(e) {
|
function _onDeliveryModalKeydown(e) {
|
||||||
// Only act when the receive delivery modal is open
|
// Only act when the receive delivery modal is open
|
||||||
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
|
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
|
||||||
@@ -3412,6 +3494,82 @@ function _onDeliveryModalKeydown(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMainScreenBarcodeScan(raw) {
|
||||||
|
const parsed = parseGS1(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
showToast('Barcode not recognised as a GS1 code', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gtin, lot } = parsed;
|
||||||
|
|
||||||
|
let mapping = null;
|
||||||
|
try {
|
||||||
|
const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
mapping = await resp.json();
|
||||||
|
} else if (resp.status !== 404) {
|
||||||
|
throw new Error(`Server error ${resp.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to look up barcode: ' + err.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
showToast('Unknown barcode — scan it in a delivery first to map it to a drug', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drugId = mapping.drug_id;
|
||||||
|
const variantId = mapping.drug_variant_id;
|
||||||
|
|
||||||
|
// Expand drug + variant
|
||||||
|
expandedDrugs.add(drugId);
|
||||||
|
expandedVariants.add(variantId);
|
||||||
|
|
||||||
|
// Ensure detail + batch data are loaded
|
||||||
|
await ensureDrugDetailLoaded(drugId);
|
||||||
|
if (!loadedVariantBatches.has(variantId)) {
|
||||||
|
try {
|
||||||
|
const resp = await apiCall(`/variants/${variantId}/batches`);
|
||||||
|
if (resp.ok) loadedVariantBatches.set(variantId, await resp.json());
|
||||||
|
} catch (_) { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set highlight
|
||||||
|
if (_highlightClearTimer) { clearTimeout(_highlightClearTimer); _highlightClearTimer = null; }
|
||||||
|
_highlightedBatchLot = lot;
|
||||||
|
_highlightedVariantId = variantId;
|
||||||
|
|
||||||
|
renderDrugs();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => scrollToHighlightedBatch());
|
||||||
|
|
||||||
|
// Auto-clear highlight after 5 seconds
|
||||||
|
_highlightClearTimer = setTimeout(() => {
|
||||||
|
_highlightedBatchLot = null;
|
||||||
|
_highlightedVariantId = null;
|
||||||
|
_highlightClearTimer = null;
|
||||||
|
renderDrugs();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Confirm to user
|
||||||
|
const batches = loadedVariantBatches.get(variantId) || [];
|
||||||
|
const found = batches.some(b => b.batch_number === lot);
|
||||||
|
if (found) {
|
||||||
|
showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot} (no active stock)`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHighlightedBatch() {
|
||||||
|
if (!_highlightedBatchLot || _highlightedVariantId == null) return;
|
||||||
|
const el = document.querySelector(`[data-batch-number="${CSS.escape(_highlightedBatchLot)}"][data-variant-id="${_highlightedVariantId}"]`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBarcodeScan(raw) {
|
async function handleBarcodeScan(raw) {
|
||||||
const parsed = parseGS1(raw);
|
const parsed = parseGS1(raw);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user