diff --git a/backend/app/main.py b/backend/app/main.py index b580382..5c515cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,11 +18,13 @@ from .models import ( Batch, AuditLog, User, + GtinMapping, ) from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES from .mqtt_service import publish_label_print_with_response from .migrate_to_roles import migrate_users_table from .migrate_compliance import migrate_compliance_schema +from .migrate_gtin import migrate_gtin_schema from pydantic import BaseModel # Run migration to convert is_admin to role @@ -39,6 +41,11 @@ except Exception as e: # Create tables Base.metadata.create_all(bind=engine) +try: + migrate_gtin_schema() +except Exception as e: + print(f"Warning: GTIN migration failed: {e}. Continuing anyway...") + # Seed default locations after table creation. try: migrate_compliance_schema() @@ -138,14 +145,37 @@ class BatchDisposeRequest(BaseModel): notes: Optional[str] = None +class GtinMappingCreate(BaseModel): + gtin: str + drug_variant_id: int + variant_pack_id: int + + +class GtinMappingResponse(BaseModel): + id: int + gtin: str + drug_variant_id: int + variant_pack_id: int + drug_id: int + drug_name: str + variant_strength: str + variant_unit: str + pack_label: str + pack_size_in_base_units: float + pack_unit_name: str + + class Config: + from_attributes = True + + + class BatchResponse(BaseModel): id: int drug_variant_id: int batch_number: str quantity: float received_pack_id: Optional[int] = None - received_pack_label: Optional[str] = None - received_pack_count: Optional[float] = None + received_pack_unit_name: Optional[str] = None received_pack_size_snapshot: Optional[float] = None current_full_pack_count: Optional[float] = None current_loose_base_units: Optional[float] = None @@ -187,14 +217,12 @@ class DrugVariantUpdate(BaseModel): class VariantPackCreate(BaseModel): - label: str pack_unit_name: str pack_size_in_base_units: float is_active: bool = True class VariantPackUpdate(BaseModel): - label: Optional[str] = None pack_unit_name: Optional[str] = None pack_size_in_base_units: Optional[float] = None is_active: Optional[bool] = None @@ -203,7 +231,6 @@ class VariantPackUpdate(BaseModel): class VariantPackResponse(BaseModel): id: int drug_variant_id: int - label: str pack_unit_name: str pack_size_in_base_units: float is_active: bool @@ -383,7 +410,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]: "batch_number": batch.batch_number, "quantity": batch.quantity, "received_pack_id": batch.received_pack_id, - "received_pack_label": pack.label if pack else None, + "received_pack_unit_name": pack.pack_unit_name if pack else None, "received_pack_count": batch.received_pack_count, "received_pack_size_snapshot": batch.received_pack_size_snapshot, "current_full_pack_count": batch.current_full_pack_count, @@ -429,7 +456,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]: return { "id": pack.id, "drug_variant_id": pack.drug_variant_id, - "label": pack.label, "pack_unit_name": pack.pack_unit_name, "pack_size_in_base_units": pack.pack_size_in_base_units, "is_active": pack.is_active, @@ -1050,15 +1076,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = db.flush() # Ensure each variant has at least one active default 1:1 pack representation. - db.add( - VariantPack( + db.add(VariantPack( drug_variant_id=db_variant.id, - label=f"1 {base_unit}", pack_unit_name=base_unit, pack_size_in_base_units=1, is_active=True, - ) - ) + )) write_audit_log( db, @@ -1232,10 +1255,7 @@ def create_variant_pack( if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") - label = payload.label.strip() pack_unit_name = payload.pack_unit_name.strip() - if not label: - raise HTTPException(status_code=400, detail="Pack label cannot be empty") if not pack_unit_name: raise HTTPException(status_code=400, detail="Pack unit name cannot be empty") if payload.pack_size_in_base_units <= 0: @@ -1243,7 +1263,6 @@ def create_variant_pack( row = VariantPack( drug_variant_id=variant_id, - label=label, pack_unit_name=pack_unit_name, pack_size_in_base_units=payload.pack_size_in_base_units, is_active=payload.is_active, @@ -1257,7 +1276,6 @@ def create_variant_pack( actor=current_user, details={ "variant_id": variant_id, - "label": label, "pack_unit_name": pack_unit_name, "pack_size_in_base_units": payload.pack_size_in_base_units, "is_active": payload.is_active, @@ -1281,12 +1299,6 @@ def update_variant_pack( before = serialize_variant_pack(row) - if payload.label is not None: - cleaned = payload.label.strip() - if not cleaned: - raise HTTPException(status_code=400, detail="Pack label cannot be empty") - row.label = cleaned - if payload.pack_unit_name is not None: cleaned = payload.pack_unit_name.strip() if not cleaned: @@ -2200,7 +2212,7 @@ def report_batch_attention( "location": location.name, "expiry_date": batch.expiry_date, "status": "expired", - "received_pack_label": None, + "received_pack_unit_name": None, "current_full_pack_count": batch.current_full_pack_count, "current_loose_base_units": batch.current_loose_base_units, "is_controlled": bool(drug.is_controlled), @@ -2450,5 +2462,88 @@ def print_notes(notes_request: NotesPrintRequest, current_user: User = Depends(g detail=f"Error sending notes print request: {str(e)}" ) +@router.get("/gtin/{gtin}", response_model=GtinMappingResponse) +def get_gtin_mapping( + gtin: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + mapping = db.query(GtinMapping).filter(GtinMapping.gtin == gtin).first() + if not mapping: + raise HTTPException(status_code=404, detail="GTIN not found") + + variant = db.query(DrugVariant).filter(DrugVariant.id == mapping.drug_variant_id).first() + drug = db.query(Drug).filter(Drug.id == variant.drug_id).first() if variant else None + pack = db.query(VariantPack).filter(VariantPack.id == mapping.variant_pack_id).first() + + if not variant or not drug or not pack: + # Referenced records no longer exist — delete the stale mapping and treat as unknown + db.delete(mapping) + db.commit() + raise HTTPException(status_code=404, detail="GTIN not found") + + return GtinMappingResponse( + id=mapping.id, + gtin=mapping.gtin, + drug_variant_id=mapping.drug_variant_id, + variant_pack_id=mapping.variant_pack_id, + drug_id=drug.id, + drug_name=drug.name, + variant_strength=variant.strength, + variant_unit=variant.unit, + pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}", + pack_size_in_base_units=pack.pack_size_in_base_units, + pack_unit_name=pack.pack_unit_name, + ) + + +@router.post("/gtin", response_model=GtinMappingResponse, status_code=201) +def create_gtin_mapping( + body: GtinMappingCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_non_readonly_user), +): + existing = db.query(GtinMapping).filter(GtinMapping.gtin == body.gtin).first() + if existing: + raise HTTPException(status_code=409, detail="GTIN mapping already exists") + + variant = db.query(DrugVariant).filter(DrugVariant.id == body.drug_variant_id).first() + if not variant: + raise HTTPException(status_code=404, detail="Drug variant not found") + + pack = db.query(VariantPack).filter( + VariantPack.id == body.variant_pack_id, + VariantPack.drug_variant_id == body.drug_variant_id, + ).first() + if not pack: + raise HTTPException(status_code=404, detail="Pack not found for this variant") + + drug = db.query(Drug).filter(Drug.id == variant.drug_id).first() + + mapping = GtinMapping( + gtin=body.gtin, + drug_variant_id=body.drug_variant_id, + variant_pack_id=body.variant_pack_id, + created_by_user_id=current_user.id, + ) + db.add(mapping) + db.commit() + db.refresh(mapping) + + return GtinMappingResponse( + id=mapping.id, + gtin=mapping.gtin, + drug_variant_id=mapping.drug_variant_id, + variant_pack_id=mapping.variant_pack_id, + drug_id=drug.id, + drug_name=drug.name, + variant_strength=variant.strength, + variant_unit=variant.unit, + pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}", + pack_size_in_base_units=pack.pack_size_in_base_units, + pack_unit_name=pack.pack_unit_name, + ) + + # Include router with /api prefix app.include_router(router) diff --git a/backend/app/migrate_gtin.py b/backend/app/migrate_gtin.py new file mode 100644 index 0000000..f7d01fc --- /dev/null +++ b/backend/app/migrate_gtin.py @@ -0,0 +1,103 @@ +""" +GTIN mapping table migration. + +Creates the gtin_mappings table if it does not already exist. +Idempotent and safe to run on every startup. +""" + +import os +import sqlite3 +from pathlib import Path + +DEFAULT_DB_URL = "sqlite:///./data/drugs.db" + + +def _resolve_sqlite_path(db_url: str) -> Path | None: + if not db_url.startswith("sqlite:///"): + print(f"Unsupported database URL for GTIN migration: {db_url}") + return None + raw_path = db_url.replace("sqlite:///", "") + if raw_path.startswith("/"): + return Path(raw_path) + return Path(raw_path) + + +def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + return cursor.fetchone() is not None + + +def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool: + cursor.execute(f"PRAGMA table_info({table_name})") + return any(row[1] == column_name for row in cursor.fetchall()) + + +def migrate_gtin_schema() -> None: + """Create gtin_mappings table if it does not exist, and drop label from variant_packs.""" + db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL) + db_path = _resolve_sqlite_path(db_url) + if db_path is None: + return + + if not db_path.exists(): + print(f"Database does not exist at {db_path}, skipping GTIN migration") + return + + print(f"Running GTIN migration on {db_path}") + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + try: + if not _table_exists(cursor, "gtin_mappings"): + cursor.execute(""" + CREATE TABLE gtin_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gtin VARCHAR(14) NOT NULL, + drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id), + variant_pack_id INTEGER NOT NULL REFERENCES variant_packs(id), + created_by_user_id INTEGER REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute("CREATE UNIQUE INDEX ix_gtin_mappings_gtin ON gtin_mappings (gtin)") + cursor.execute("CREATE INDEX ix_gtin_mappings_drug_variant_id ON gtin_mappings (drug_variant_id)") + cursor.execute("CREATE INDEX ix_gtin_mappings_variant_pack_id ON gtin_mappings (variant_pack_id)") + print("Created gtin_mappings table") + else: + print("gtin_mappings table already exists, skipping") + + # Drop label column from variant_packs if it still exists + if _table_exists(cursor, "variant_packs") and _column_exists(cursor, "variant_packs", "label"): + print("Dropping label column from variant_packs") + cursor.execute(""" + CREATE TABLE variant_packs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id), + pack_unit_name VARCHAR NOT NULL DEFAULT 'pack', + pack_size_in_base_units FLOAT NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + INSERT INTO variant_packs_new (id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at) + SELECT id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at + FROM variant_packs + """) + # Re-create indexes + cursor.execute("DROP INDEX IF EXISTS ix_variant_packs_drug_variant_id") + cursor.execute("DROP TABLE variant_packs") + cursor.execute("ALTER TABLE variant_packs_new RENAME TO variant_packs") + cursor.execute("CREATE INDEX ix_variant_packs_drug_variant_id ON variant_packs (drug_variant_id)") + print("Dropped label column from variant_packs") + else: + print("variant_packs.label already absent, skipping") + + conn.commit() + finally: + conn.close() diff --git a/backend/app/models.py b/backend/app/models.py index c777f51..9a0f08d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,7 +41,6 @@ class VariantPack(Base): id = Column(Integer, primary_key=True, index=True) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True) - label = Column(String, nullable=False) pack_unit_name = Column(String, nullable=False, default="pack") pack_size_in_base_units = Column(Float, nullable=False, default=1) is_active = Column(Boolean, nullable=False, default=True) @@ -109,6 +108,17 @@ class DispensingAllocation(Base): quantity = Column(Float, nullable=False) +class GtinMapping(Base): + __tablename__ = "gtin_mappings" + + id = Column(Integer, primary_key=True, index=True) + gtin = Column(String(14), unique=True, index=True, nullable=False) + drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True) + variant_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=False, index=True) + created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + class AuditLog(Base): __tablename__ = "audit_logs" diff --git a/frontend/app.js b/frontend/app.js index 2ac8bf3..133a82e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 => `
- ${escapeHtml(pack.label)} + ${escapeHtml(packLabel(pack))} (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})
`).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) {
- ${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 ? `
${batchAvailabilityNote}
` : ''}
@@ -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() {
${!isReadOnly ? ` - ` : ''} @@ -2987,7 +3063,7 @@ function buildDeliveryPackOptions(variant, selectedPackId = '') { return [``, ...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 ``; })].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 */ +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 = '' + + allDrugs.map(d => ``).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 = ''; + packSelect.innerHTML = ''; + return; + } + + drugSelect.value = String(drugId); + const drug = allDrugs.find(d => d.id === drugId); + + // Rebuild variant list + variantSelect.innerHTML = ''; + if (drug) { + variantSelect.innerHTML += drug.variants.map(v => + `` + ).join(''); + } + + if (!variantId) { + packSelect.innerHTML = ''; + return; + } + + variantSelect.value = String(variantId); + const variant = drug?.variants?.find(v => v.id === variantId); + + // Rebuild pack list + packSelect.innerHTML = ''; + if (variant) { + const packs = getActivePacksForVariant(variant); + packSelect.innerHTML += packs.map(p => + `` + ).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 = '' + + allDrugs.map(d => ``).join(''); + + document.getElementById('gtinMappingVariantSelect').innerHTML = ''; + document.getElementById('gtinMappingPackSelect').innerHTML = ''; + + 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 = ''; + packSelect.innerHTML = ''; + + if (!drug) return; + variantSelect.innerHTML += drug.variants.map(v => + `` + ).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 = ''; + if (!variant) return; + + const packs = getActivePacksForVariant(variant); + packSelect.innerHTML += packs.map(p => + `` + ).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 [ + '', + ...allDrugs.map(d => { + const sel = String(d.id) === String(selectedDrugId) ? ' selected' : ''; + return ``; + }) + ].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 = `
+
+ + +
@@ -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) { diff --git a/frontend/index.html b/frontend/index.html index 2255b78..7dad61f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -59,6 +59,7 @@
+
+ + +
diff --git a/frontend/styles.css b/frontend/styles.css index 3c0a544..c112d09 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -913,7 +913,7 @@ footer { .delivery-line-grid { display: grid; - grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto; + grid-template-columns: 1.9fr 1.8fr 1.5fr 0.8fr 1.2fr 1.3fr auto; gap: 12px; align-items: end; }