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 @@
+
+
+
+
×
+
Unknown Barcode — Map GTIN
+
This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
}