Scan shortcut on main screen

This commit is contained in:
2026-04-20 14:28:21 -04:00
parent 36634dc2bf
commit 9ec27e245a
+162 -4
View File
@@ -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) {