Barcode scanning and GTIN mapping

This commit is contained in:
2026-04-16 15:32:36 -04:00
parent 2aeba2f563
commit cfb08bd288
6 changed files with 938 additions and 75 deletions
+659 -49
View File
@@ -13,6 +13,26 @@ let deliveryLineCounter = 0;
let deliveryLocations = [];
let currentDispenseBatches = [];
let currentDispenseLegacyQuantity = 0;
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
/** Build a human-readable pack label from pack fields, e.g. "Box of 28" */
function packLabel(packOrUnitName, packSize) {
// Accept either (pack object) or (unit_name string, size number)
let unitName, size;
if (typeof packOrUnitName === 'object' && packOrUnitName !== null) {
unitName = packOrUnitName.pack_unit_name;
size = packOrUnitName.pack_size_in_base_units;
} else {
unitName = packOrUnitName;
size = packSize;
}
const displaySize = size === Math.floor(size) ? Math.floor(size) : size;
const unit = String(unitName || 'pack');
return `${unit.charAt(0).toUpperCase()}${unit.slice(1)} of ${displaySize}`;
}
function resetDisposeBatchModal() {
const form = document.getElementById('disposeBatchForm');
@@ -132,10 +152,12 @@ function showMainApp() {
const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn');
const printNotesBtn = document.getElementById('printNotesBtn');
const receiveDeliveryBtn = document.getElementById('receiveDeliveryBtn');
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
if (receiveDeliveryBtn) receiveDeliveryBtn.style.display = isReadOnly ? 'none' : 'block';
setupEventListeners();
loadDrugs();
@@ -287,7 +309,10 @@ function setupEventListeners() {
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => closeModal(receiveDeliveryModal));
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => {
_detachDeliveryBarcodeListener();
closeModal(receiveDeliveryModal);
});
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal);
@@ -337,6 +362,7 @@ function setupEventListeners() {
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
if (receiveDeliveryBtn) receiveDeliveryBtn.addEventListener('click', openReceiveDeliveryModal);
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
updateDispenseDrugSelect();
updateDispenseModeUi();
@@ -382,6 +408,9 @@ function setupEventListeners() {
if (modal?.id === 'disposeBatchModal') {
resetDisposeBatchModal();
}
if (modal?.id === 'receiveDeliveryModal') {
_detachDeliveryBarcodeListener();
}
closeModal(modal);
}));
@@ -470,6 +499,54 @@ async function loadDrugs() {
updateLocationFilterOptions();
renderDrugs();
updateDispenseDrugSelect();
if (_gtinMappingPendingRefresh) {
_gtinMappingPendingRefresh = false;
const restore = _gtinMappingPendingRestore || {};
_gtinMappingPendingRestore = null;
_gtinMappingPendingVariantId = null;
// Resolve new variant by diffing (add-variant flow)
if (restore._existingVariantIds && restore.drugId) {
const drug = allDrugs.find(d => d.id === restore.drugId);
const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id));
if (newVariant) {
restore.variantId = newVariant.id;
// If no pack snapshot, all packs are new — pick the first active one
if (!restore._existingPackIds) {
const firstPack = getActivePacksForVariant(newVariant)?.[0];
if (firstPack) restore.packId = firstPack.id;
}
}
}
// Resolve new pack by diffing (add-pack flow)
if (restore._existingPackIds && restore.drugId && restore.variantId) {
const drug = allDrugs.find(d => d.id === restore.drugId);
const variant = drug?.variants?.find(v => v.id === restore.variantId);
const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.id));
if (newPack) restore.packId = newPack.id;
}
_reinitGtinMappingModal(restore);
}
// After handleAddDrug's loadDrugs fires: find the newly created drug and set up
// _gtinMappingPendingRefresh so that when handleAddVariant calls loadDrugs next,
// we auto-select drug + new variant in the GTIN modal.
if (_gtinMappingWaitingForNewDrug) {
const newDrug = allDrugs.find(d => !_gtinMappingWaitingForNewDrug.has(d.id));
_gtinMappingWaitingForNewDrug = null;
if (newDrug) {
// handleAddDrug will now open addVariantModal — prepare to catch that save
_gtinMappingPendingRestore = {
drugId: newDrug.id,
variantId: null,
packId: null,
_existingVariantIds: new Set((newDrug.variants || []).map(v => v.id))
};
_gtinMappingPendingRefresh = true;
}
}
} catch (error) {
console.error('Error loading drugs:', error);
document.getElementById('drugsList').innerHTML =
@@ -745,7 +822,7 @@ function populateDispensePackSelect(variant) {
activePacks.forEach(pack => {
const option = document.createElement('option');
option.value = String(pack.id);
option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
option.textContent = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
packSelect.appendChild(option);
});
@@ -872,7 +949,7 @@ function renderVariantInventoryDetails(variant) {
const packsHtml = activePacks.length > 0
? activePacks.map(pack => `
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
<strong>${escapeHtml(pack.label)}</strong>
<strong>${escapeHtml(packLabel(pack))}</strong>
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
</div>
`).join('')
@@ -882,9 +959,9 @@ function renderVariantInventoryDetails(variant) {
? batches.map(batch => {
const locationLabel = getBatchLocationLabel(batch);
const expired = isBatchExpired(batch);
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label;
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name;
const stocktakeLabel = hasPackState
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${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)}`;
const batchCardStyles = expired
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
@@ -1043,8 +1120,8 @@ function batchMatchesSelectedPack(batch, selectedPack) {
return true;
}
const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase();
const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase();
const batchPackLabel = String(batch.received_pack_unit_name || '').trim().toLowerCase();
const selectedPackLabel = String(selectedPack.pack_unit_name || '').trim().toLowerCase();
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
return true;
}
@@ -1152,8 +1229,8 @@ function renderDispenseBatchAllocationRows(activeBatches) {
</div>
</div>
<div style="font-size: 0.9em; color: #374151;">
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
? `Stock: ${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(unitLabel)} loose`
: ''}
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
</div>
@@ -1204,8 +1281,8 @@ function renderExpiredDispenseBatches(expiredBatches) {
expiredContent.innerHTML = expiredBatches.map(batch => {
const locationLabel = getBatchLocationLabel(batch);
const expiryLabel = formatDisplayDate(batch.expiry_date);
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
? `${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(unitLabel)} loose`
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
return `
@@ -1608,7 +1685,6 @@ function renderDrugs() {
</div>
<div class="drug-actions">
${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button>
` : ''}
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
@@ -2987,7 +3063,7 @@ function buildDeliveryPackOptions(variant, selectedPackId = '') {
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
const label = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
})].join('');
}
@@ -3014,10 +3090,22 @@ function updateDeliveryLineQuantityDisplay(line) {
}
function wireDeliveryLineEvents(line) {
const drugSelect = line.querySelector('.delivery-drug-select');
const variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
const packCountInput = line.querySelector('.delivery-pack-count');
if (drugSelect && variantSelect) {
drugSelect.addEventListener('change', () => {
const drugId = parseInt(drugSelect.value || '', 10);
const drug = allDrugs.find(d => d.id === drugId) || null;
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, '');
if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, '');
if (packCountInput) packCountInput.value = '';
updateDeliveryLineQuantityDisplay(line);
});
}
if (variantSelect && packSelect) {
variantSelect.addEventListener('change', () => {
const variantId = parseInt(variantSelect.value || '', 10);
@@ -3043,10 +3131,507 @@ function wireDeliveryLineEvents(line) {
}
}
// ─── GS1 barcode scanning ──────────────────────────────────────────────────
/**
* Parse a GS1-128 / DataMatrix scan string.
* Handles fixed-length AIs: 01 (GTIN-14), 17 (expiry YYMMDD), then 10 (lot).
* Returns { gtin, expiry (Date), lot } or null if the string doesn't match.
*/
// GS1 AI fixed-length lookup (number of data digits after the AI prefix).
// AIs not listed here are treated as variable-length (terminated by GS/FNC1 or end of string).
const GS1_FIXED_LENGTHS = {
'00': 18, '01': 14, '02': 14,
'11': 6, '12': 6, '13': 6, '14': 6, '15': 6, '16': 6, '17': 6, '18': 6, '19': 6,
'20': 2,
'31': 6, '32': 6, '33': 6, '34': 6, '35': 6, '36': 6,
'41': 13,
};
// 2-digit AI prefixes we recognise enough to skip over.
const GS1_KNOWN_AI_PREFIXES = new Set([
'00','01','02','10','11','12','13','14','15','16','17','18','19',
'20','21','22','23','24','25','26',
'30','31','32','33','34','35','36','37',
'40','41','42','43',
'70','71','72','73','74','75','76','77','78','79',
'80','81','82','83','84','85','86','87','88','89',
'90','91','92','93','94','95','96','97','98','99',
]);
/**
* Parse a GS1-128 / DataMatrix scan string.
* Fixed-length AIs are consumed exactly. Variable-length AIs are terminated
* by a GS (FNC1, \x1d) character — if no GS is present they run to end of string
* (per GS1 spec: variable-length fields must be FNC1-terminated unless last).
* Returns { gtin, expiry (Date), lot } or null if required fields not found.
*/
function parseGS1(raw) {
if (!raw || raw.length < 16) return null;
// Strip any leading AIM symbology identifier e.g. "]d2", "]Q3"
const aimPrefix = raw.match(/^\][a-zA-Z]\d/);
let data = aimPrefix ? raw.substring(3) : raw;
const GS = '\x1d'; // FNC1 separator
const hasGS = data.includes(GS);
let pos = 0;
let gtin = null, expiry = null, lot = null;
while (pos < data.length) {
if (data[pos] === GS) { pos++; continue; }
if (pos + 2 > data.length) break;
const ai = data.substring(pos, pos + 2);
if (!GS1_KNOWN_AI_PREFIXES.has(ai)) break;
pos += 2; // consume AI
if (GS1_FIXED_LENGTHS[ai] !== undefined) {
// Fixed-length: consume exactly N chars
const len = GS1_FIXED_LENGTHS[ai];
const value = data.substring(pos, pos + len);
pos += len;
if (ai === '01') {
if (value.length === 14 && /^\d{14}$/.test(value)) gtin = value;
} else if (ai === '17') {
const yy = parseInt(value.substring(0, 2), 10);
const mm = parseInt(value.substring(2, 4), 10);
const dd = parseInt(value.substring(4, 6), 10);
expiry = dd === 0
? new Date(yy + 2000, mm, 0) // last day of month
: new Date(yy + 2000, mm - 1, dd);
}
} else {
// Variable-length: terminated by GS if present, otherwise end of string
let end;
if (hasGS) {
const gsIdx = data.indexOf(GS, pos);
end = gsIdx !== -1 ? gsIdx : data.length;
} else {
end = data.length;
}
const value = data.substring(pos, end);
pos = end;
if (ai === '10') lot = value;
// ai 21 (serial), 22, etc. ignored
}
}
if (!gtin || !expiry || !lot) return null;
return { gtin, expiry, lot };
}
/** Format a Date as YYYY-MM-DD for use in <input type="date"> */
function formatDateForInput(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
// Buffer for keyboard-wedge barcode detection
let _scanBuffer = [];
let _scanBufferTimer = null;
let _activeScanLineEl = null; // last delivery line that received focus
let _preScanFocusedInput = null; // input that had focus when scan started
let _preScanFocusedValue = null; // its value before any scan chars were typed
const SCAN_MAX_GAP_MS = 50;
const SCAN_MIN_LENGTH = 20;
function _onDeliveryModalKeydown(e) {
// Only act when the receive delivery modal is open
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
// Track which delivery line last had focus
const focusedLine = document.activeElement?.closest('.delivery-line');
if (focusedLine) _activeScanLineEl = focusedLine;
const now = Date.now();
if (e.key === 'Enter') {
const raw = _scanBuffer.map(x => x.char).join('');
console.log('[barcode] Enter received. Buffer length:', raw.length, 'Content:', raw);
_scanBuffer = [];
if (_scanBufferTimer) { clearTimeout(_scanBufferTimer); _scanBufferTimer = null; }
// Only treat as a scan if it arrived very fast
if (raw.length >= SCAN_MIN_LENGTH) {
e.preventDefault();
// Restore the focused input to its pre-scan value (remove the 1 char that slipped in)
if (_preScanFocusedInput) {
_preScanFocusedInput.value = _preScanFocusedValue || '';
}
_preScanFocusedInput = null;
_preScanFocusedValue = null;
console.log('[barcode] Treating as scan, calling handleBarcodeScan');
handleBarcodeScan(raw);
} else {
console.log('[barcode] Buffer too short for scan, ignoring');
}
return;
}
// Single printable character
if (e.key.length === 1) {
const gap = _scanBuffer.length > 0 ? now - _scanBuffer[_scanBuffer.length - 1].time : 0;
// If gap is too large, start fresh (human typed slowly)
if (_scanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
console.log('[barcode] Gap too large (' + gap + 'ms), resetting buffer');
_scanBuffer = [];
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}
// Save the focused input + its value before the first scan char lands
if (_scanBuffer.length === 0) {
const active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
_preScanFocusedInput = active;
_preScanFocusedValue = active.value;
} else {
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}
} else {
// Subsequent rapid chars — suppress them from going into the focused input
e.preventDefault();
}
_scanBuffer.push({ char: e.key, time: now });
console.log('[barcode] Buffered char:', e.key, '| gap:', gap + 'ms | buffer length:', _scanBuffer.length);
// Auto-clear buffer if Enter never comes
if (_scanBufferTimer) clearTimeout(_scanBufferTimer);
_scanBufferTimer = setTimeout(() => {
console.log('[barcode] Buffer auto-cleared (no Enter)');
_scanBuffer = [];
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}, 500);
}
}
async function handleBarcodeScan(raw) {
const parsed = parseGS1(raw);
if (!parsed) {
showToast('Barcode not recognised as a GS1 code', 'warning');
return;
}
const { gtin, expiry, lot } = parsed;
const expiryStr = formatDateForInput(expiry);
// Look up GTIN mapping
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) {
// Unknown GTIN — open mapping modal then re-process
openGtinMappingModal(gtin, expiryStr, lot);
return;
}
_applyBarcodeScanToLines(mapping, lot, expiryStr);
}
function _applyBarcodeScanToLines(mapping, lot, expiryStr) {
const container = document.getElementById('deliveryLinesContainer');
if (!container) return;
const lines = Array.from(container.querySelectorAll('.delivery-line'));
// 1. Find an existing line with the same variant + lot + expiry → increment pack count
for (const line of lines) {
const variantId = line.querySelector('.delivery-variant-select')?.value;
const batchVal = line.querySelector('.delivery-batch-number')?.value?.trim();
const expiryVal = line.querySelector('.delivery-expiry-date')?.value;
if (
String(variantId) === String(mapping.drug_variant_id) &&
batchVal === lot &&
expiryVal === expiryStr
) {
const countInput = line.querySelector('.delivery-pack-count');
const current = parseFloat(countInput.value) || 0;
countInput.value = current + 1;
updateDeliveryLineQuantityDisplay(line);
showToast(`Pack count incremented to ${current + 1} for lot ${lot}`, 'success');
return;
}
}
// 2. Find any existing empty line (lot and expiry both blank) — never overwrite a filled line
const emptyLine = lines.find(l => {
const batch = l.querySelector('.delivery-batch-number')?.value?.trim();
const expiry = l.querySelector('.delivery-expiry-date')?.value;
return !batch && !expiry;
}) || null;
if (emptyLine) {
_populateDeliveryLine(emptyLine, mapping, lot, expiryStr);
return;
}
// 3. Append a new line
appendDeliveryLine();
const newLine = container.querySelector('.delivery-line:last-child');
if (newLine) _populateDeliveryLine(newLine, mapping, lot, expiryStr);
}
function _populateDeliveryLine(line, mapping, lot, expiryStr) {
const drugSelect = line.querySelector('.delivery-drug-select');
const variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
const batchInput = line.querySelector('.delivery-batch-number');
const expiryInput = line.querySelector('.delivery-expiry-date');
const packCountInput = line.querySelector('.delivery-pack-count');
if (drugSelect) {
drugSelect.innerHTML = buildDeliveryDrugOptions(mapping.drug_id);
drugSelect.value = String(mapping.drug_id);
}
if (variantSelect) {
const drug = allDrugs.find(d => d.id === mapping.drug_id) || null;
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, mapping.drug_variant_id);
variantSelect.value = String(mapping.drug_variant_id);
const variant = getVariantById(mapping.drug_variant_id);
if (packSelect) {
packSelect.innerHTML = buildDeliveryPackOptions(variant, mapping.variant_pack_id);
packSelect.value = String(mapping.variant_pack_id);
}
}
if (batchInput) batchInput.value = lot;
if (expiryInput) expiryInput.value = expiryStr;
if (packCountInput && !packCountInput.value) packCountInput.value = 1;
updateDeliveryLineQuantityDisplay(line);
showToast(`Populated: ${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
}
function _detachDeliveryBarcodeListener() {
const modalEl = document.getElementById('receiveDeliveryModal');
if (modalEl?._barcodeListener) {
document.removeEventListener('keydown', modalEl._barcodeListener);
modalEl._barcodeListener = null;
}
_scanBuffer = [];
_activeScanLineEl = null;
}
function _refreshGtinMappingSelects() {
// Kept for compatibility — delegates to reinit with current selections
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect')?.value || '', 10) || null;
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect')?.value || '', 10) || null;
const packId = parseInt(document.getElementById('gtinMappingPackSelect')?.value || '', 10) || null;
_reinitGtinMappingModal({ drugId, variantId, packId });
}
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
// optionally pre-selecting specific drug/variant/pack IDs.
function _reinitGtinMappingModal(restore) {
const drugSelect = document.getElementById('gtinMappingDrugSelect');
const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect');
if (!drugSelect) return;
// Rebuild drug list
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
const drugId = restore?.drugId || null;
const variantId = restore?.variantId || null;
const packId = restore?.packId || null;
// If no drug to restore, clear cascades and stop
if (!drugId) {
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return;
}
drugSelect.value = String(drugId);
const drug = allDrugs.find(d => d.id === drugId);
// Rebuild variant list
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
if (drug) {
variantSelect.innerHTML += drug.variants.map(v =>
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
).join('');
}
if (!variantId) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return;
}
variantSelect.value = String(variantId);
const variant = drug?.variants?.find(v => v.id === variantId);
// Rebuild pack list
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (variant) {
const packs = getActivePacksForVariant(variant);
packSelect.innerHTML += packs.map(p =>
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
).join('');
if (packId) packSelect.value = String(packId);
}
}
function gtinMappingAddDrug() {
// Snapshot current drug IDs. handleAddDrug will call loadDrugs() once (we intercept it
// to find the new drug ID), then open addVariantModal. We hook the *subsequent* loadDrugs
// call (from handleAddVariant) to reinit the GTIN modal with drug+variant selected.
_gtinMappingWaitingForNewDrug = new Set(allDrugs.map(d => d.id));
openModal(document.getElementById('addModal'));
}
function gtinMappingAddVariant() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
if (!drugId) { showToast('Select a drug first', 'warning'); return; }
const drug = allDrugs.find(d => d.id === drugId);
const existingVariantIds = new Set((drug?.variants || []).map(v => v.id));
// After reload, find the new variant by diffing
_gtinMappingPendingRestore = { drugId, variantId: null, packId: null, _existingVariantIds: existingVariantIds };
_gtinMappingPendingRefresh = true;
openAddVariantModal(drugId);
}
function gtinMappingAddPack() {
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
if (!variantId) { showToast('Select a variant first', 'warning'); return; }
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
if (!drug) return;
const variant = drug.variants?.find(v => v.id === variantId);
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
_gtinMappingPendingRefresh = true;
deliveryDrugId = drugId;
_gtinMappingPendingVariantId = variantId;
openAddPackSizeFromDeliveryModal();
}
// ─── GTIN mapping modal logic ──────────────────────────────────────────────
let _pendingGtinScan = null; // { gtin, expiryStr, lot } while mapping modal is open
function openGtinMappingModal(gtin, expiryStr, lot) {
_pendingGtinScan = { gtin, expiryStr, lot };
document.getElementById('gtinMappingGtin').value = gtin;
// Populate drug selector from allDrugs
const drugSelect = document.getElementById('gtinMappingDrugSelect');
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
document.getElementById('gtinMappingVariantSelect').innerHTML = '<option value="">-- Select variant --</option>';
document.getElementById('gtinMappingPackSelect').innerHTML = '<option value="">-- Select pack --</option>';
openModal(document.getElementById('gtinMappingModal'));
}
function onGtinMappingDrugChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect');
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (!drug) return;
variantSelect.innerHTML += drug.variants.map(v =>
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
).join('');
}
function onGtinMappingVariantChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
const variant = drug?.variants?.find(v => v.id === variantId);
const packSelect = document.getElementById('gtinMappingPackSelect');
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (!variant) return;
const packs = getActivePacksForVariant(variant);
packSelect.innerHTML += packs.map(p =>
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
).join('');
}
async function handleSaveGtinMapping() {
if (!_pendingGtinScan) return;
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
const packId = parseInt(document.getElementById('gtinMappingPackSelect').value || '', 10);
if (!variantId || !packId) {
showToast('Please select a variant and pack', 'warning');
return;
}
try {
const resp = await apiCall('/gtin', {
method: 'POST',
body: JSON.stringify({
gtin: _pendingGtinScan.gtin,
drug_variant_id: variantId,
variant_pack_id: packId,
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to save GTIN mapping');
}
const mapping = await resp.json();
closeModal(document.getElementById('gtinMappingModal'));
showToast(`GTIN mapped to ${mapping.drug_name} ${mapping.variant_strength}`, 'success');
// Now apply the scan that triggered this
_applyBarcodeScanToLines(mapping, _pendingGtinScan.lot, _pendingGtinScan.expiryStr);
_pendingGtinScan = null;
} catch (err) {
showToast('Error saving GTIN: ' + err.message, 'error');
}
}
function buildDeliveryDrugOptions(selectedDrugId = '') {
return [
'<option value="">-- Select drug --</option>',
...allDrugs.map(d => {
const sel = String(d.id) === String(selectedDrugId) ? ' selected' : '';
return `<option value="${d.id}"${sel}>${escapeHtml(d.name)}</option>`;
})
].join('');
}
function appendDeliveryLine(prefill = {}) {
const container = document.getElementById('deliveryLinesContainer');
const drug = getActiveDeliveryDrug();
if (!container || !drug) return;
if (!container) return;
deliveryLineCounter += 1;
const lineId = `delivery-line-${deliveryLineCounter}`;
@@ -3055,19 +3640,27 @@ function appendDeliveryLine(prefill = {}) {
line.className = 'delivery-line';
line.dataset.lineId = lineId;
const initialVariant = prefill.variantId
? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
: drug.variants.length === 1 ? drug.variants[0] : null;
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
const initialDrug = prefill.drugId ? allDrugs.find(d => String(d.id) === String(prefill.drugId)) || null : null;
const initialDrugId = prefill.drugId || '';
const initialVariant = prefill.variantId && initialDrug
? initialDrug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
: null;
const initialVariantId = prefill.variantId || '';
const initialPackId = prefill.packId || (initialVariant && getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
const initialPackCount = prefill.packCount || '';
line.innerHTML = `
<div class="delivery-line-grid">
<div class="form-group">
<label>Drug</label>
<select class="delivery-drug-select" required>
${buildDeliveryDrugOptions(initialDrugId)}
</select>
</div>
<div class="form-group">
<label>Variant</label>
<select class="delivery-variant-select" required>
${buildDeliveryVariantOptions(drug, initialVariantId)}
${buildDeliveryVariantOptions(initialDrug, initialVariantId)}
</select>
</div>
<div class="form-group">
@@ -3116,25 +3709,24 @@ function appendDeliveryLine(prefill = {}) {
}
function refreshDeliveryVariantSelects() {
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) return;
if (!container) return;
container.querySelectorAll('.delivery-line').forEach(line => {
const select = line.querySelector('.delivery-variant-select');
const drugSelect = line.querySelector('.delivery-drug-select');
const variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
if (!select) return;
if (!variantSelect) return;
const currentVariantId = select.value;
const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : '');
select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId);
const drugId = parseInt(drugSelect?.value || '', 10);
const drug = allDrugs.find(d => d.id === drugId) || null;
const currentVariantId = variantSelect.value;
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
const variant = getVariantById(parseInt(select.value || '', 10));
const variant = getVariantById(parseInt(variantSelect.value || '', 10));
if (packSelect) {
const currentPackId = packSelect.value;
const activePacks = getActivePacksForVariant(variant);
const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : '');
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
}
updateDeliveryLineQuantityDisplay(line);
@@ -3154,13 +3746,8 @@ async function initializeDeliveryLocations() {
}
}
async function openReceiveDeliveryModal(drugId) {
deliveryDrugId = drugId;
const drug = getActiveDeliveryDrug();
if (!drug) {
showToast('Drug not found', 'error');
return;
}
async function openReceiveDeliveryModal() {
deliveryDrugId = null;
const form = document.getElementById('receiveDeliveryForm');
const container = document.getElementById('deliveryLinesContainer');
@@ -3168,17 +3755,33 @@ async function openReceiveDeliveryModal(drugId) {
if (form) form.reset();
if (container) container.innerHTML = '';
if (label) label.textContent = `Drug: ${drug.name}`;
if (label) label.textContent = 'Scan items or add lines manually';
await initializeDeliveryLocations();
appendDeliveryLine();
openModal(document.getElementById('receiveDeliveryModal'));
// Attach barcode scanner listener
_activeScanLineEl = null;
_scanBuffer = [];
const modalEl = document.getElementById('receiveDeliveryModal');
if (modalEl._barcodeListener) document.removeEventListener('keydown', modalEl._barcodeListener);
modalEl._barcodeListener = _onDeliveryModalKeydown;
document.addEventListener('keydown', modalEl._barcodeListener);
console.log('[barcode] Listener attached to receiveDeliveryModal');
openModal(modalEl);
}
function handleAddVariantFromDelivery() {
if (!deliveryDrugId) {
showToast('Select a drug first', 'warning');
const deliveryContainer = document.getElementById('deliveryLinesContainer');
const firstDrugIdStr = deliveryContainer
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
: null;
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
}
if (!deliveryDrugId) {
showToast('Select a drug on a delivery line first', 'warning');
return;
}
openAddVariantModal(deliveryDrugId);
@@ -3186,10 +3789,19 @@ function handleAddVariantFromDelivery() {
function openAddPackSizeFromDeliveryModal() {
if (!deliveryDrugId) {
showToast('Select a drug first', 'warning');
// In multi-drug mode, get drug from the first line that has one selected
const deliveryContainer = document.getElementById('deliveryLinesContainer');
const firstDrugIdStr = deliveryContainer
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
: null;
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
}
if (!deliveryDrugId) {
showToast('Select a drug on a delivery line first', 'warning');
return;
}
const drug = getActiveDeliveryDrug();
const drug = allDrugs.find(d => d.id === deliveryDrugId);
if (!drug) {
showToast('Drug not found', 'error');
return;
@@ -3243,7 +3855,6 @@ async function handleAddPackSize(e) {
const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10);
const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim();
const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || '');
const packLabel = `${packType.charAt(0).toUpperCase() + packType.slice(1)} of ${packSize}`;
if (!variantId) {
showToast('Please select a variant', 'warning');
@@ -3258,7 +3869,6 @@ async function handleAddPackSize(e) {
const response = await apiCall(`/variants/${variantId}/packs`, {
method: 'POST',
body: JSON.stringify({
label: packLabel,
pack_unit_name: packType,
pack_size_in_base_units: packSize,
is_active: true
@@ -3284,9 +3894,8 @@ async function handleAddPackSize(e) {
async function handleReceiveDelivery(e) {
e.preventDefault();
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) {
if (!container) {
showToast('Delivery context unavailable', 'error');
return;
}
@@ -3320,7 +3929,7 @@ async function handleReceiveDelivery(e) {
return;
}
const variant = drug.variants.find(v => v.id === variantId);
const variant = getVariantById(variantId);
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
if (!selectedPack) {
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
@@ -3356,6 +3965,7 @@ async function handleReceiveDelivery(e) {
}
closeModal(document.getElementById('receiveDeliveryModal'));
_detachDeliveryBarcodeListener();
await loadDrugs();
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
} catch (error) {