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) {