diff --git a/frontend/app.js b/frontend/app.js
index 3227e9c..2b6292c 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -19,6 +19,9 @@ let _gtinMappingPendingRefresh = false;
let _gtinMappingPendingVariantId = null;
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 _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" */
function packLabel(packOrUnitName, packSize) {
@@ -521,6 +524,9 @@ function setupEventListeners() {
closeModal(e.target);
}
});
+
+ // Main-screen barcode scan
+ document.addEventListener('keydown', _onMainScreenKeydown);
}
// Load drugs from API
@@ -863,11 +869,22 @@ function populateDispensePackSelect(variant) {
});
if (packCount) packCount.value = '';
+
+ // Auto-select the first available pack
+ if (activePacks.length > 0) {
+ packSelect.value = String(activePacks[0].id);
+ }
+
if (packPreview) {
packPreview.textContent = activePacks.length > 0
? `Select a pack and whole-number count (${variant.unit} base unit).`
: `No active packs for this variant.`;
}
+
+ // Update preview to reflect the auto-selected pack
+ if (activePacks.length > 0) {
+ updateDispenseQuantityFromPack();
+ }
}
function updateDispenseModeUi() {
@@ -999,13 +1016,16 @@ function renderVariantInventoryDetails(variant, batches) {
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.quantity)} ${escapeHtml(variant.unit)}`;
- const batchCardStyles = 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 isHighlighted = _highlightedBatchLot === batch.batch_number && _highlightedVariantId === variant.id;
+ const batchCardStyles = isHighlighted
+ ? '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;';
return `
-
+
${escapeHtml(batch.batch_number)}
@@ -3341,6 +3361,68 @@ let _preScanFocusedValue = null; // its value before any scan chars were typed
const SCAN_MAX_GAP_MS = 50;
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) {
// Only act when the receive delivery modal is open
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) {
const parsed = parseGS1(raw);
if (!parsed) {