Barcode scanning and GTIN mapping

This commit is contained in:
2026-04-16 15:32:36 -04:00
parent 2aeba2f563
commit cfb08bd288
6 changed files with 938 additions and 75 deletions
+119 -24
View File
@@ -18,11 +18,13 @@ from .models import (
Batch, Batch,
AuditLog, AuditLog,
User, 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 .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 .mqtt_service import publish_label_print_with_response
from .migrate_to_roles import migrate_users_table from .migrate_to_roles import migrate_users_table
from .migrate_compliance import migrate_compliance_schema from .migrate_compliance import migrate_compliance_schema
from .migrate_gtin import migrate_gtin_schema
from pydantic import BaseModel from pydantic import BaseModel
# Run migration to convert is_admin to role # Run migration to convert is_admin to role
@@ -39,6 +41,11 @@ except Exception as e:
# Create tables # Create tables
Base.metadata.create_all(bind=engine) 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. # Seed default locations after table creation.
try: try:
migrate_compliance_schema() migrate_compliance_schema()
@@ -138,14 +145,37 @@ class BatchDisposeRequest(BaseModel):
notes: Optional[str] = None 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): class BatchResponse(BaseModel):
id: int id: int
drug_variant_id: int drug_variant_id: int
batch_number: str batch_number: str
quantity: float quantity: float
received_pack_id: Optional[int] = None received_pack_id: Optional[int] = None
received_pack_label: Optional[str] = None received_pack_unit_name: Optional[str] = None
received_pack_count: Optional[float] = None
received_pack_size_snapshot: Optional[float] = None received_pack_size_snapshot: Optional[float] = None
current_full_pack_count: Optional[float] = None current_full_pack_count: Optional[float] = None
current_loose_base_units: Optional[float] = None current_loose_base_units: Optional[float] = None
@@ -187,14 +217,12 @@ class DrugVariantUpdate(BaseModel):
class VariantPackCreate(BaseModel): class VariantPackCreate(BaseModel):
label: str
pack_unit_name: str pack_unit_name: str
pack_size_in_base_units: float pack_size_in_base_units: float
is_active: bool = True is_active: bool = True
class VariantPackUpdate(BaseModel): class VariantPackUpdate(BaseModel):
label: Optional[str] = None
pack_unit_name: Optional[str] = None pack_unit_name: Optional[str] = None
pack_size_in_base_units: Optional[float] = None pack_size_in_base_units: Optional[float] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@@ -203,7 +231,6 @@ class VariantPackUpdate(BaseModel):
class VariantPackResponse(BaseModel): class VariantPackResponse(BaseModel):
id: int id: int
drug_variant_id: int drug_variant_id: int
label: str
pack_unit_name: str pack_unit_name: str
pack_size_in_base_units: float pack_size_in_base_units: float
is_active: bool is_active: bool
@@ -383,7 +410,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
"batch_number": batch.batch_number, "batch_number": batch.batch_number,
"quantity": batch.quantity, "quantity": batch.quantity,
"received_pack_id": batch.received_pack_id, "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_count": batch.received_pack_count,
"received_pack_size_snapshot": batch.received_pack_size_snapshot, "received_pack_size_snapshot": batch.received_pack_size_snapshot,
"current_full_pack_count": batch.current_full_pack_count, "current_full_pack_count": batch.current_full_pack_count,
@@ -429,7 +456,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
return { return {
"id": pack.id, "id": pack.id,
"drug_variant_id": pack.drug_variant_id, "drug_variant_id": pack.drug_variant_id,
"label": pack.label,
"pack_unit_name": pack.pack_unit_name, "pack_unit_name": pack.pack_unit_name,
"pack_size_in_base_units": pack.pack_size_in_base_units, "pack_size_in_base_units": pack.pack_size_in_base_units,
"is_active": pack.is_active, "is_active": pack.is_active,
@@ -1050,15 +1076,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
db.flush() db.flush()
# Ensure each variant has at least one active default 1:1 pack representation. # Ensure each variant has at least one active default 1:1 pack representation.
db.add( db.add(VariantPack(
VariantPack(
drug_variant_id=db_variant.id, drug_variant_id=db_variant.id,
label=f"1 {base_unit}",
pack_unit_name=base_unit, pack_unit_name=base_unit,
pack_size_in_base_units=1, pack_size_in_base_units=1,
is_active=True, is_active=True,
) ))
)
write_audit_log( write_audit_log(
db, db,
@@ -1232,10 +1255,7 @@ def create_variant_pack(
if not variant: if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found") raise HTTPException(status_code=404, detail="Drug variant not found")
label = payload.label.strip()
pack_unit_name = payload.pack_unit_name.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: if not pack_unit_name:
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty") raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
if payload.pack_size_in_base_units <= 0: if payload.pack_size_in_base_units <= 0:
@@ -1243,7 +1263,6 @@ def create_variant_pack(
row = VariantPack( row = VariantPack(
drug_variant_id=variant_id, drug_variant_id=variant_id,
label=label,
pack_unit_name=pack_unit_name, pack_unit_name=pack_unit_name,
pack_size_in_base_units=payload.pack_size_in_base_units, pack_size_in_base_units=payload.pack_size_in_base_units,
is_active=payload.is_active, is_active=payload.is_active,
@@ -1257,7 +1276,6 @@ def create_variant_pack(
actor=current_user, actor=current_user,
details={ details={
"variant_id": variant_id, "variant_id": variant_id,
"label": label,
"pack_unit_name": pack_unit_name, "pack_unit_name": pack_unit_name,
"pack_size_in_base_units": payload.pack_size_in_base_units, "pack_size_in_base_units": payload.pack_size_in_base_units,
"is_active": payload.is_active, "is_active": payload.is_active,
@@ -1281,12 +1299,6 @@ def update_variant_pack(
before = serialize_variant_pack(row) 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: if payload.pack_unit_name is not None:
cleaned = payload.pack_unit_name.strip() cleaned = payload.pack_unit_name.strip()
if not cleaned: if not cleaned:
@@ -2200,7 +2212,7 @@ def report_batch_attention(
"location": location.name, "location": location.name,
"expiry_date": batch.expiry_date, "expiry_date": batch.expiry_date,
"status": "expired", "status": "expired",
"received_pack_label": None, "received_pack_unit_name": None,
"current_full_pack_count": batch.current_full_pack_count, "current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units, "current_loose_base_units": batch.current_loose_base_units,
"is_controlled": bool(drug.is_controlled), "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)}" 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 # Include router with /api prefix
app.include_router(router) app.include_router(router)
+103
View File
@@ -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()
+11 -1
View File
@@ -41,7 +41,6 @@ class VariantPack(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, 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_unit_name = Column(String, nullable=False, default="pack")
pack_size_in_base_units = Column(Float, nullable=False, default=1) pack_size_in_base_units = Column(Float, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
@@ -109,6 +108,17 @@ class DispensingAllocation(Base):
quantity = Column(Float, nullable=False) 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): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
+659 -49
View File
@@ -13,6 +13,26 @@ let deliveryLineCounter = 0;
let deliveryLocations = []; let deliveryLocations = [];
let currentDispenseBatches = []; let currentDispenseBatches = [];
let currentDispenseLegacyQuantity = 0; 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() { function resetDisposeBatchModal() {
const form = document.getElementById('disposeBatchForm'); const form = document.getElementById('disposeBatchForm');
@@ -132,10 +152,12 @@ function showMainApp() {
const addDrugBtn = document.getElementById('addDrugBtn'); const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn'); const dispenseBtn = document.getElementById('dispenseBtn');
const printNotesBtn = document.getElementById('printNotesBtn'); const printNotesBtn = document.getElementById('printNotesBtn');
const receiveDeliveryBtn = document.getElementById('receiveDeliveryBtn');
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block'; if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block'; if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block'; if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
if (receiveDeliveryBtn) receiveDeliveryBtn.style.display = isReadOnly ? 'none' : 'block';
setupEventListeners(); setupEventListeners();
loadDrugs(); loadDrugs();
@@ -287,7 +309,10 @@ function setupEventListeners() {
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm'); const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery); 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 (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery); if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal); if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal);
@@ -337,6 +362,7 @@ function setupEventListeners() {
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
if (receiveDeliveryBtn) receiveDeliveryBtn.addEventListener('click', openReceiveDeliveryModal);
if (dispenseBtn) dispenseBtn.addEventListener('click', () => { if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
updateDispenseDrugSelect(); updateDispenseDrugSelect();
updateDispenseModeUi(); updateDispenseModeUi();
@@ -382,6 +408,9 @@ function setupEventListeners() {
if (modal?.id === 'disposeBatchModal') { if (modal?.id === 'disposeBatchModal') {
resetDisposeBatchModal(); resetDisposeBatchModal();
} }
if (modal?.id === 'receiveDeliveryModal') {
_detachDeliveryBarcodeListener();
}
closeModal(modal); closeModal(modal);
})); }));
@@ -470,6 +499,54 @@ async function loadDrugs() {
updateLocationFilterOptions(); updateLocationFilterOptions();
renderDrugs(); renderDrugs();
updateDispenseDrugSelect(); 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) { } catch (error) {
console.error('Error loading drugs:', error); console.error('Error loading drugs:', error);
document.getElementById('drugsList').innerHTML = document.getElementById('drugsList').innerHTML =
@@ -745,7 +822,7 @@ function populateDispensePackSelect(variant) {
activePacks.forEach(pack => { activePacks.forEach(pack => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = String(pack.id); 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); packSelect.appendChild(option);
}); });
@@ -872,7 +949,7 @@ function renderVariantInventoryDetails(variant) {
const packsHtml = activePacks.length > 0 const packsHtml = activePacks.length > 0
? activePacks.map(pack => ` ? activePacks.map(pack => `
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;"> <div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
<strong>${escapeHtml(pack.label)}</strong> <strong>${escapeHtml(packLabel(pack))}</strong>
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span> <span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
</div> </div>
`).join('') `).join('')
@@ -882,9 +959,9 @@ function renderVariantInventoryDetails(variant) {
? batches.map(batch => { ? batches.map(batch => {
const locationLabel = getBatchLocationLabel(batch); const locationLabel = getBatchLocationLabel(batch);
const expired = isBatchExpired(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 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)}`; : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
const batchCardStyles = expired const batchCardStyles = expired
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;' ? '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; return true;
} }
const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase(); const batchPackLabel = String(batch.received_pack_unit_name || '').trim().toLowerCase();
const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase(); const selectedPackLabel = String(selectedPack.pack_unit_name || '').trim().toLowerCase();
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) { if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
return true; return true;
} }
@@ -1152,8 +1229,8 @@ function renderDispenseBatchAllocationRows(activeBatches) {
</div> </div>
</div> </div>
<div style="font-size: 0.9em; color: #374151;"> <div style="font-size: 0.9em; color: #374151;">
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label ${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(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose` ? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
: ''} : ''}
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''} ${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
</div> </div>
@@ -1204,8 +1281,8 @@ function renderExpiredDispenseBatches(expiredBatches) {
expiredContent.innerHTML = expiredBatches.map(batch => { expiredContent.innerHTML = expiredBatches.map(batch => {
const locationLabel = getBatchLocationLabel(batch); const locationLabel = getBatchLocationLabel(batch);
const expiryLabel = formatDisplayDate(batch.expiry_date); const expiryLabel = formatDisplayDate(batch.expiry_date);
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label 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(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} 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(unitLabel)} loose`
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`; : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
return ` return `
@@ -1608,7 +1685,6 @@ function renderDrugs() {
</div> </div>
<div class="drug-actions"> <div class="drug-actions">
${!isReadOnly ? ` ${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button> <button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button>
` : ''} ` : ''}
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button> <button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
@@ -2987,7 +3063,7 @@ function buildDeliveryPackOptions(variant, selectedPackId = '') {
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => { return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : ''; const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`; const label = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`; return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
})].join(''); })].join('');
} }
@@ -3014,10 +3090,22 @@ function updateDeliveryLineQuantityDisplay(line) {
} }
function wireDeliveryLineEvents(line) { function wireDeliveryLineEvents(line) {
const drugSelect = line.querySelector('.delivery-drug-select');
const variantSelect = line.querySelector('.delivery-variant-select'); const variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select'); const packSelect = line.querySelector('.delivery-pack-select');
const packCountInput = line.querySelector('.delivery-pack-count'); 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) { if (variantSelect && packSelect) {
variantSelect.addEventListener('change', () => { variantSelect.addEventListener('change', () => {
const variantId = parseInt(variantSelect.value || '', 10); const variantId = parseInt(variantSelect.value || '', 10);
@@ -3043,10 +3131,507 @@ function wireDeliveryLineEvents(line) {
} }
} }
// ─── GS1 barcode scanning ──────────────────────────────────────────────────
/**
* Parse a GS1-128 / DataMatrix scan string.
* Handles fixed-length AIs: 01 (GTIN-14), 17 (expiry YYMMDD), then 10 (lot).
* Returns { gtin, expiry (Date), lot } or null if the string doesn't match.
*/
// GS1 AI fixed-length lookup (number of data digits after the AI prefix).
// AIs not listed here are treated as variable-length (terminated by GS/FNC1 or end of string).
const GS1_FIXED_LENGTHS = {
'00': 18, '01': 14, '02': 14,
'11': 6, '12': 6, '13': 6, '14': 6, '15': 6, '16': 6, '17': 6, '18': 6, '19': 6,
'20': 2,
'31': 6, '32': 6, '33': 6, '34': 6, '35': 6, '36': 6,
'41': 13,
};
// 2-digit AI prefixes we recognise enough to skip over.
const GS1_KNOWN_AI_PREFIXES = new Set([
'00','01','02','10','11','12','13','14','15','16','17','18','19',
'20','21','22','23','24','25','26',
'30','31','32','33','34','35','36','37',
'40','41','42','43',
'70','71','72','73','74','75','76','77','78','79',
'80','81','82','83','84','85','86','87','88','89',
'90','91','92','93','94','95','96','97','98','99',
]);
/**
* Parse a GS1-128 / DataMatrix scan string.
* Fixed-length AIs are consumed exactly. Variable-length AIs are terminated
* by a GS (FNC1, \x1d) character — if no GS is present they run to end of string
* (per GS1 spec: variable-length fields must be FNC1-terminated unless last).
* Returns { gtin, expiry (Date), lot } or null if required fields not found.
*/
function parseGS1(raw) {
if (!raw || raw.length < 16) return null;
// Strip any leading AIM symbology identifier e.g. "]d2", "]Q3"
const aimPrefix = raw.match(/^\][a-zA-Z]\d/);
let data = aimPrefix ? raw.substring(3) : raw;
const GS = '\x1d'; // FNC1 separator
const hasGS = data.includes(GS);
let pos = 0;
let gtin = null, expiry = null, lot = null;
while (pos < data.length) {
if (data[pos] === GS) { pos++; continue; }
if (pos + 2 > data.length) break;
const ai = data.substring(pos, pos + 2);
if (!GS1_KNOWN_AI_PREFIXES.has(ai)) break;
pos += 2; // consume AI
if (GS1_FIXED_LENGTHS[ai] !== undefined) {
// Fixed-length: consume exactly N chars
const len = GS1_FIXED_LENGTHS[ai];
const value = data.substring(pos, pos + len);
pos += len;
if (ai === '01') {
if (value.length === 14 && /^\d{14}$/.test(value)) gtin = value;
} else if (ai === '17') {
const yy = parseInt(value.substring(0, 2), 10);
const mm = parseInt(value.substring(2, 4), 10);
const dd = parseInt(value.substring(4, 6), 10);
expiry = dd === 0
? new Date(yy + 2000, mm, 0) // last day of month
: new Date(yy + 2000, mm - 1, dd);
}
} else {
// Variable-length: terminated by GS if present, otherwise end of string
let end;
if (hasGS) {
const gsIdx = data.indexOf(GS, pos);
end = gsIdx !== -1 ? gsIdx : data.length;
} else {
end = data.length;
}
const value = data.substring(pos, end);
pos = end;
if (ai === '10') lot = value;
// ai 21 (serial), 22, etc. ignored
}
}
if (!gtin || !expiry || !lot) return null;
return { gtin, expiry, lot };
}
/** Format a Date as YYYY-MM-DD for use in <input type="date"> */
function formatDateForInput(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
// Buffer for keyboard-wedge barcode detection
let _scanBuffer = [];
let _scanBufferTimer = null;
let _activeScanLineEl = null; // last delivery line that received focus
let _preScanFocusedInput = null; // input that had focus when scan started
let _preScanFocusedValue = null; // its value before any scan chars were typed
const SCAN_MAX_GAP_MS = 50;
const SCAN_MIN_LENGTH = 20;
function _onDeliveryModalKeydown(e) {
// Only act when the receive delivery modal is open
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
// Track which delivery line last had focus
const focusedLine = document.activeElement?.closest('.delivery-line');
if (focusedLine) _activeScanLineEl = focusedLine;
const now = Date.now();
if (e.key === 'Enter') {
const raw = _scanBuffer.map(x => x.char).join('');
console.log('[barcode] Enter received. Buffer length:', raw.length, 'Content:', raw);
_scanBuffer = [];
if (_scanBufferTimer) { clearTimeout(_scanBufferTimer); _scanBufferTimer = null; }
// Only treat as a scan if it arrived very fast
if (raw.length >= SCAN_MIN_LENGTH) {
e.preventDefault();
// Restore the focused input to its pre-scan value (remove the 1 char that slipped in)
if (_preScanFocusedInput) {
_preScanFocusedInput.value = _preScanFocusedValue || '';
}
_preScanFocusedInput = null;
_preScanFocusedValue = null;
console.log('[barcode] Treating as scan, calling handleBarcodeScan');
handleBarcodeScan(raw);
} else {
console.log('[barcode] Buffer too short for scan, ignoring');
}
return;
}
// Single printable character
if (e.key.length === 1) {
const gap = _scanBuffer.length > 0 ? now - _scanBuffer[_scanBuffer.length - 1].time : 0;
// If gap is too large, start fresh (human typed slowly)
if (_scanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
console.log('[barcode] Gap too large (' + gap + 'ms), resetting buffer');
_scanBuffer = [];
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}
// Save the focused input + its value before the first scan char lands
if (_scanBuffer.length === 0) {
const active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
_preScanFocusedInput = active;
_preScanFocusedValue = active.value;
} else {
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}
} else {
// Subsequent rapid chars — suppress them from going into the focused input
e.preventDefault();
}
_scanBuffer.push({ char: e.key, time: now });
console.log('[barcode] Buffered char:', e.key, '| gap:', gap + 'ms | buffer length:', _scanBuffer.length);
// Auto-clear buffer if Enter never comes
if (_scanBufferTimer) clearTimeout(_scanBufferTimer);
_scanBufferTimer = setTimeout(() => {
console.log('[barcode] Buffer auto-cleared (no Enter)');
_scanBuffer = [];
_preScanFocusedInput = null;
_preScanFocusedValue = null;
}, 500);
}
}
async function handleBarcodeScan(raw) {
const parsed = parseGS1(raw);
if (!parsed) {
showToast('Barcode not recognised as a GS1 code', 'warning');
return;
}
const { gtin, expiry, lot } = parsed;
const expiryStr = formatDateForInput(expiry);
// Look up GTIN mapping
let mapping = null;
try {
const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`);
if (resp.ok) {
mapping = await resp.json();
} else if (resp.status !== 404) {
throw new Error(`Server error ${resp.status}`);
}
} catch (err) {
showToast('Failed to look up barcode: ' + err.message, 'error');
return;
}
if (!mapping) {
// Unknown GTIN — open mapping modal then re-process
openGtinMappingModal(gtin, expiryStr, lot);
return;
}
_applyBarcodeScanToLines(mapping, lot, expiryStr);
}
function _applyBarcodeScanToLines(mapping, lot, expiryStr) {
const container = document.getElementById('deliveryLinesContainer');
if (!container) return;
const lines = Array.from(container.querySelectorAll('.delivery-line'));
// 1. Find an existing line with the same variant + lot + expiry → increment pack count
for (const line of lines) {
const variantId = line.querySelector('.delivery-variant-select')?.value;
const batchVal = line.querySelector('.delivery-batch-number')?.value?.trim();
const expiryVal = line.querySelector('.delivery-expiry-date')?.value;
if (
String(variantId) === String(mapping.drug_variant_id) &&
batchVal === lot &&
expiryVal === expiryStr
) {
const countInput = line.querySelector('.delivery-pack-count');
const current = parseFloat(countInput.value) || 0;
countInput.value = current + 1;
updateDeliveryLineQuantityDisplay(line);
showToast(`Pack count incremented to ${current + 1} for lot ${lot}`, 'success');
return;
}
}
// 2. Find any existing empty line (lot and expiry both blank) — never overwrite a filled line
const emptyLine = lines.find(l => {
const batch = l.querySelector('.delivery-batch-number')?.value?.trim();
const expiry = l.querySelector('.delivery-expiry-date')?.value;
return !batch && !expiry;
}) || null;
if (emptyLine) {
_populateDeliveryLine(emptyLine, mapping, lot, expiryStr);
return;
}
// 3. Append a new line
appendDeliveryLine();
const newLine = container.querySelector('.delivery-line:last-child');
if (newLine) _populateDeliveryLine(newLine, mapping, lot, expiryStr);
}
function _populateDeliveryLine(line, mapping, lot, expiryStr) {
const drugSelect = line.querySelector('.delivery-drug-select');
const variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
const batchInput = line.querySelector('.delivery-batch-number');
const expiryInput = line.querySelector('.delivery-expiry-date');
const packCountInput = line.querySelector('.delivery-pack-count');
if (drugSelect) {
drugSelect.innerHTML = buildDeliveryDrugOptions(mapping.drug_id);
drugSelect.value = String(mapping.drug_id);
}
if (variantSelect) {
const drug = allDrugs.find(d => d.id === mapping.drug_id) || null;
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, mapping.drug_variant_id);
variantSelect.value = String(mapping.drug_variant_id);
const variant = getVariantById(mapping.drug_variant_id);
if (packSelect) {
packSelect.innerHTML = buildDeliveryPackOptions(variant, mapping.variant_pack_id);
packSelect.value = String(mapping.variant_pack_id);
}
}
if (batchInput) batchInput.value = lot;
if (expiryInput) expiryInput.value = expiryStr;
if (packCountInput && !packCountInput.value) packCountInput.value = 1;
updateDeliveryLineQuantityDisplay(line);
showToast(`Populated: ${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
}
function _detachDeliveryBarcodeListener() {
const modalEl = document.getElementById('receiveDeliveryModal');
if (modalEl?._barcodeListener) {
document.removeEventListener('keydown', modalEl._barcodeListener);
modalEl._barcodeListener = null;
}
_scanBuffer = [];
_activeScanLineEl = null;
}
function _refreshGtinMappingSelects() {
// Kept for compatibility — delegates to reinit with current selections
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect')?.value || '', 10) || null;
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect')?.value || '', 10) || null;
const packId = parseInt(document.getElementById('gtinMappingPackSelect')?.value || '', 10) || null;
_reinitGtinMappingModal({ drugId, variantId, packId });
}
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
// optionally pre-selecting specific drug/variant/pack IDs.
function _reinitGtinMappingModal(restore) {
const drugSelect = document.getElementById('gtinMappingDrugSelect');
const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect');
if (!drugSelect) return;
// Rebuild drug list
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
const drugId = restore?.drugId || null;
const variantId = restore?.variantId || null;
const packId = restore?.packId || null;
// If no drug to restore, clear cascades and stop
if (!drugId) {
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return;
}
drugSelect.value = String(drugId);
const drug = allDrugs.find(d => d.id === drugId);
// Rebuild variant list
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
if (drug) {
variantSelect.innerHTML += drug.variants.map(v =>
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
).join('');
}
if (!variantId) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return;
}
variantSelect.value = String(variantId);
const variant = drug?.variants?.find(v => v.id === variantId);
// Rebuild pack list
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (variant) {
const packs = getActivePacksForVariant(variant);
packSelect.innerHTML += packs.map(p =>
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
).join('');
if (packId) packSelect.value = String(packId);
}
}
function gtinMappingAddDrug() {
// Snapshot current drug IDs. handleAddDrug will call loadDrugs() once (we intercept it
// to find the new drug ID), then open addVariantModal. We hook the *subsequent* loadDrugs
// call (from handleAddVariant) to reinit the GTIN modal with drug+variant selected.
_gtinMappingWaitingForNewDrug = new Set(allDrugs.map(d => d.id));
openModal(document.getElementById('addModal'));
}
function gtinMappingAddVariant() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
if (!drugId) { showToast('Select a drug first', 'warning'); return; }
const drug = allDrugs.find(d => d.id === drugId);
const existingVariantIds = new Set((drug?.variants || []).map(v => v.id));
// After reload, find the new variant by diffing
_gtinMappingPendingRestore = { drugId, variantId: null, packId: null, _existingVariantIds: existingVariantIds };
_gtinMappingPendingRefresh = true;
openAddVariantModal(drugId);
}
function gtinMappingAddPack() {
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
if (!variantId) { showToast('Select a variant first', 'warning'); return; }
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
if (!drug) return;
const variant = drug.variants?.find(v => v.id === variantId);
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
_gtinMappingPendingRefresh = true;
deliveryDrugId = drugId;
_gtinMappingPendingVariantId = variantId;
openAddPackSizeFromDeliveryModal();
}
// ─── GTIN mapping modal logic ──────────────────────────────────────────────
let _pendingGtinScan = null; // { gtin, expiryStr, lot } while mapping modal is open
function openGtinMappingModal(gtin, expiryStr, lot) {
_pendingGtinScan = { gtin, expiryStr, lot };
document.getElementById('gtinMappingGtin').value = gtin;
// Populate drug selector from allDrugs
const drugSelect = document.getElementById('gtinMappingDrugSelect');
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
document.getElementById('gtinMappingVariantSelect').innerHTML = '<option value="">-- Select variant --</option>';
document.getElementById('gtinMappingPackSelect').innerHTML = '<option value="">-- Select pack --</option>';
openModal(document.getElementById('gtinMappingModal'));
}
function onGtinMappingDrugChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect');
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (!drug) return;
variantSelect.innerHTML += drug.variants.map(v =>
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
).join('');
}
function onGtinMappingVariantChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
const variant = drug?.variants?.find(v => v.id === variantId);
const packSelect = document.getElementById('gtinMappingPackSelect');
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (!variant) return;
const packs = getActivePacksForVariant(variant);
packSelect.innerHTML += packs.map(p =>
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
).join('');
}
async function handleSaveGtinMapping() {
if (!_pendingGtinScan) return;
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
const packId = parseInt(document.getElementById('gtinMappingPackSelect').value || '', 10);
if (!variantId || !packId) {
showToast('Please select a variant and pack', 'warning');
return;
}
try {
const resp = await apiCall('/gtin', {
method: 'POST',
body: JSON.stringify({
gtin: _pendingGtinScan.gtin,
drug_variant_id: variantId,
variant_pack_id: packId,
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to save GTIN mapping');
}
const mapping = await resp.json();
closeModal(document.getElementById('gtinMappingModal'));
showToast(`GTIN mapped to ${mapping.drug_name} ${mapping.variant_strength}`, 'success');
// Now apply the scan that triggered this
_applyBarcodeScanToLines(mapping, _pendingGtinScan.lot, _pendingGtinScan.expiryStr);
_pendingGtinScan = null;
} catch (err) {
showToast('Error saving GTIN: ' + err.message, 'error');
}
}
function buildDeliveryDrugOptions(selectedDrugId = '') {
return [
'<option value="">-- Select drug --</option>',
...allDrugs.map(d => {
const sel = String(d.id) === String(selectedDrugId) ? ' selected' : '';
return `<option value="${d.id}"${sel}>${escapeHtml(d.name)}</option>`;
})
].join('');
}
function appendDeliveryLine(prefill = {}) { function appendDeliveryLine(prefill = {}) {
const container = document.getElementById('deliveryLinesContainer'); const container = document.getElementById('deliveryLinesContainer');
const drug = getActiveDeliveryDrug(); if (!container) return;
if (!container || !drug) return;
deliveryLineCounter += 1; deliveryLineCounter += 1;
const lineId = `delivery-line-${deliveryLineCounter}`; const lineId = `delivery-line-${deliveryLineCounter}`;
@@ -3055,19 +3640,27 @@ function appendDeliveryLine(prefill = {}) {
line.className = 'delivery-line'; line.className = 'delivery-line';
line.dataset.lineId = lineId; line.dataset.lineId = lineId;
const initialVariant = prefill.variantId const initialDrug = prefill.drugId ? allDrugs.find(d => String(d.id) === String(prefill.drugId)) || null : null;
? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null const initialDrugId = prefill.drugId || '';
: drug.variants.length === 1 ? drug.variants[0] : null; const initialVariant = prefill.variantId && initialDrug
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : ''); ? initialDrug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : ''); : null;
const initialVariantId = prefill.variantId || '';
const initialPackId = prefill.packId || (initialVariant && getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
const initialPackCount = prefill.packCount || ''; const initialPackCount = prefill.packCount || '';
line.innerHTML = ` line.innerHTML = `
<div class="delivery-line-grid"> <div class="delivery-line-grid">
<div class="form-group">
<label>Drug</label>
<select class="delivery-drug-select" required>
${buildDeliveryDrugOptions(initialDrugId)}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Variant</label> <label>Variant</label>
<select class="delivery-variant-select" required> <select class="delivery-variant-select" required>
${buildDeliveryVariantOptions(drug, initialVariantId)} ${buildDeliveryVariantOptions(initialDrug, initialVariantId)}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -3116,25 +3709,24 @@ function appendDeliveryLine(prefill = {}) {
} }
function refreshDeliveryVariantSelects() { function refreshDeliveryVariantSelects() {
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer'); const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) return; if (!container) return;
container.querySelectorAll('.delivery-line').forEach(line => { 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'); const packSelect = line.querySelector('.delivery-pack-select');
if (!select) return; if (!variantSelect) return;
const currentVariantId = select.value; const drugId = parseInt(drugSelect?.value || '', 10);
const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : ''); const drug = allDrugs.find(d => d.id === drugId) || null;
select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId); 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) { if (packSelect) {
const currentPackId = packSelect.value; const currentPackId = packSelect.value;
const activePacks = getActivePacksForVariant(variant); packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : '');
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
} }
updateDeliveryLineQuantityDisplay(line); updateDeliveryLineQuantityDisplay(line);
@@ -3154,13 +3746,8 @@ async function initializeDeliveryLocations() {
} }
} }
async function openReceiveDeliveryModal(drugId) { async function openReceiveDeliveryModal() {
deliveryDrugId = drugId; deliveryDrugId = null;
const drug = getActiveDeliveryDrug();
if (!drug) {
showToast('Drug not found', 'error');
return;
}
const form = document.getElementById('receiveDeliveryForm'); const form = document.getElementById('receiveDeliveryForm');
const container = document.getElementById('deliveryLinesContainer'); const container = document.getElementById('deliveryLinesContainer');
@@ -3168,17 +3755,33 @@ async function openReceiveDeliveryModal(drugId) {
if (form) form.reset(); if (form) form.reset();
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
if (label) label.textContent = `Drug: ${drug.name}`; if (label) label.textContent = 'Scan items or add lines manually';
await initializeDeliveryLocations(); await initializeDeliveryLocations();
appendDeliveryLine(); 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() { function handleAddVariantFromDelivery() {
if (!deliveryDrugId) { 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; return;
} }
openAddVariantModal(deliveryDrugId); openAddVariantModal(deliveryDrugId);
@@ -3186,10 +3789,19 @@ function handleAddVariantFromDelivery() {
function openAddPackSizeFromDeliveryModal() { function openAddPackSizeFromDeliveryModal() {
if (!deliveryDrugId) { 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; return;
} }
const drug = getActiveDeliveryDrug(); const drug = allDrugs.find(d => d.id === deliveryDrugId);
if (!drug) { if (!drug) {
showToast('Drug not found', 'error'); showToast('Drug not found', 'error');
return; return;
@@ -3243,7 +3855,6 @@ async function handleAddPackSize(e) {
const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10); const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10);
const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim(); const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim();
const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || ''); const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || '');
const packLabel = `${packType.charAt(0).toUpperCase() + packType.slice(1)} of ${packSize}`;
if (!variantId) { if (!variantId) {
showToast('Please select a variant', 'warning'); showToast('Please select a variant', 'warning');
@@ -3258,7 +3869,6 @@ async function handleAddPackSize(e) {
const response = await apiCall(`/variants/${variantId}/packs`, { const response = await apiCall(`/variants/${variantId}/packs`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
label: packLabel,
pack_unit_name: packType, pack_unit_name: packType,
pack_size_in_base_units: packSize, pack_size_in_base_units: packSize,
is_active: true is_active: true
@@ -3284,9 +3894,8 @@ async function handleAddPackSize(e) {
async function handleReceiveDelivery(e) { async function handleReceiveDelivery(e) {
e.preventDefault(); e.preventDefault();
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer'); const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) { if (!container) {
showToast('Delivery context unavailable', 'error'); showToast('Delivery context unavailable', 'error');
return; return;
} }
@@ -3320,7 +3929,7 @@ async function handleReceiveDelivery(e) {
return; 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; const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
if (!selectedPack) { if (!selectedPack) {
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning'); showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
@@ -3356,6 +3965,7 @@ async function handleReceiveDelivery(e) {
} }
closeModal(document.getElementById('receiveDeliveryModal')); closeModal(document.getElementById('receiveDeliveryModal'));
_detachDeliveryBarcodeListener();
await loadDrugs(); await loadDrugs();
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success'); showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
} catch (error) { } catch (error) {
+45
View File
@@ -59,6 +59,7 @@
<div class="inventory-toolbar"> <div class="inventory-toolbar">
<div class="header-actions"> <div class="header-actions">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button> <button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
<button id="receiveDeliveryBtn" class="btn btn-success btn-small">📦 Receive Delivery</button>
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button> <button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button>
</div> </div>
<div class="toolbar-search"> <div class="toolbar-search">
@@ -632,6 +633,50 @@
</div> </div>
</div> </div>
<!-- GTIN Mapping Modal -->
<div id="gtinMappingModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Unknown Barcode — Map GTIN</h2>
<p style="color:#666; margin-bottom:16px;">This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.</p>
<div class="form-group">
<label>GTIN</label>
<input type="text" id="gtinMappingGtin" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>Drug</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingDrugSelect" onchange="onGtinMappingDrugChange()" style="flex:1">
<option value="">-- Select drug --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddDrug()">+ New Drug</button>
</div>
</div>
<div class="form-group">
<label>Variant</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingVariantSelect" onchange="onGtinMappingVariantChange()" style="flex:1">
<option value="">-- Select variant --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddVariant()">+ New Variant</button>
</div>
</div>
<div class="form-group">
<label>Pack</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingPackSelect" style="flex:1">
<option value="">-- Select pack --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddPack()">+ New Pack</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="handleSaveGtinMapping()">Save Mapping</button>
<button type="button" class="btn btn-secondary close">Cancel</button>
</div>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+1 -1
View File
@@ -913,7 +913,7 @@ footer {
.delivery-line-grid { .delivery-line-grid {
display: 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; gap: 12px;
align-items: end; align-items: end;
} }