Refactor - API lazy loading

This commit is contained in:
2026-04-20 14:12:11 -04:00
parent 6be571a48c
commit 36634dc2bf
2 changed files with 325 additions and 90 deletions
+162 -8
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
import math import math
@@ -263,6 +264,34 @@ class DrugWithVariantsResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class VariantSummaryResponse(BaseModel):
"""Lightweight variant summary returned by GET /drugs list — no packs or batches."""
id: int
drug_id: int
strength: str
quantity: float
unit: str
low_stock_threshold: float
has_inventory_history: bool = False
expired_quantity: float = 0.0
class Config:
from_attributes = True
class DrugSummaryResponse(BaseModel):
"""Lightweight drug summary for the main list — variants without packs or batches."""
id: int
name: str
description: Optional[str] = None
is_controlled: bool = False
locations: List[str] = []
variants: List[VariantSummaryResponse] = []
class Config:
from_attributes = True
class DispensingAllocationCreate(BaseModel): class DispensingAllocationCreate(BaseModel):
batch_id: int batch_id: int
quantity: float quantity: float
@@ -399,6 +428,40 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
return variant_dict return variant_dict
def serialize_variant_with_packs(db: Session, variant: DrugVariant) -> Dict[str, Any]:
"""Return variant data with packs but without batch details (level-2 detail)."""
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant.id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant.id)
.first()
is not None
)
packs = (
db.query(VariantPack)
.filter(VariantPack.drug_variant_id == variant.id)
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
.all()
)
return {
"id": variant.id,
"drug_id": variant.drug_id,
"strength": variant.strength,
"quantity": variant.quantity,
"unit": variant.unit,
"base_unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold,
"has_inventory_history": has_batch_history or has_dispense_history,
"packs": [serialize_variant_pack(pack) for pack in packs],
"batches": [],
}
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]: def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
location = db.query(Location).filter(Location.id == batch.location_id).first() location = db.query(Location).filter(Location.id == batch.location_id).first()
pack = None pack = None
@@ -866,21 +929,112 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db:
def read_root(): def read_root():
return {"message": "Drug Inventory API"} return {"message": "Drug Inventory API"}
@router.get("/drugs", response_model=List[DrugWithVariantsResponse]) @router.get("/drugs", response_model=List[DrugSummaryResponse])
def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get all drugs with their variants""" """Get all drugs with lightweight variant summaries (no packs or batches)."""
drugs = db.query(Drug).all() drugs = db.query(Drug).all()
if not drugs:
return []
drug_ids = [d.id for d in drugs]
all_variants = (
db.query(DrugVariant)
.filter(DrugVariant.drug_id.in_(drug_ids))
.all()
)
variants_by_drug: Dict[int, list] = {}
for v in all_variants:
variants_by_drug.setdefault(v.drug_id, []).append(v)
variant_ids = [v.id for v in all_variants]
if not variant_ids:
return [
{
"id": d.id,
"name": d.name,
"description": d.description,
"is_controlled": bool(d.is_controlled),
"locations": [],
"variants": [],
}
for d in drugs
]
# Variant IDs that have ever had a batch received
batch_history_ids = set(
row[0]
for row in db.query(Batch.drug_variant_id)
.filter(Batch.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Variant IDs that have ever had a dispense
dispense_history_ids = set(
row[0]
for row in db.query(Dispensing.drug_variant_id)
.filter(Dispensing.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Sum of expired active batch stock per variant
today = date.today()
expired_rows = (
db.query(Batch.drug_variant_id, func.sum(Batch.quantity))
.filter(
Batch.drug_variant_id.in_(variant_ids),
Batch.quantity > 0,
Batch.expiry_date < today,
)
.group_by(Batch.drug_variant_id)
.all()
)
expired_by_variant: Dict[int, float] = {row[0]: float(row[1]) for row in expired_rows}
# Distinct location names per drug (from active batch stock)
location_rows = (
db.query(DrugVariant.drug_id, Location.name)
.join(Batch, Batch.drug_variant_id == DrugVariant.id)
.join(Location, Location.id == Batch.location_id)
.filter(
DrugVariant.drug_id.in_(drug_ids),
Batch.quantity > 0,
)
.distinct()
.all()
)
locations_by_drug: Dict[int, list] = {}
for drug_id, loc_name in location_rows:
locations_by_drug.setdefault(drug_id, []).append(loc_name)
result = [] result = []
for drug in drugs: for drug in drugs:
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() drug_variants = variants_by_drug.get(drug.id, [])
drug_dict = { variant_summaries = [
{
"id": v.id,
"drug_id": v.drug_id,
"strength": v.strength,
"quantity": v.quantity,
"unit": v.unit,
"low_stock_threshold": v.low_stock_threshold,
"has_inventory_history": v.id in batch_history_ids or v.id in dispense_history_ids,
"expired_quantity": expired_by_variant.get(v.id, 0.0),
}
for v in drug_variants
]
result.append(
{
"id": drug.id, "id": drug.id,
"name": drug.name, "name": drug.name,
"description": drug.description, "description": drug.description,
"is_controlled": bool(drug.is_controlled), "is_controlled": bool(drug.is_controlled),
"variants": [enrich_variant_with_batches(db, v) for v in variants], "locations": locations_by_drug.get(drug.id, []),
"variants": variant_summaries,
} }
result.append(drug_dict) )
return result return result
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse]) @router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
@@ -910,7 +1064,7 @@ def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends(
@router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse) @router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get a specific drug with its variants""" """Get a specific drug with its variants and packs (no batch detail — level-2 detail)."""
drug = db.query(Drug).filter(Drug.id == drug_id).first() drug = db.query(Drug).filter(Drug.id == drug_id).first()
if not drug: if not drug:
raise HTTPException(status_code=404, detail="Drug not found") raise HTTPException(status_code=404, detail="Drug not found")
@@ -921,7 +1075,7 @@ def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = D
"name": drug.name, "name": drug.name,
"description": drug.description, "description": drug.description,
"is_controlled": bool(drug.is_controlled), "is_controlled": bool(drug.is_controlled),
"variants": [enrich_variant_with_batches(db, v) for v in variants], "variants": [serialize_variant_with_packs(db, v) for v in variants],
} }
return drug_dict return drug_dict
+139 -58
View File
@@ -1,5 +1,7 @@
const API_URL = '/api'; const API_URL = '/api';
let allDrugs = []; let allDrugs = []; // level-1 summaries: no packs, no batches
let loadedDrugDetails = new Map(); // drugId → full drug with variant packs (level-2)
let loadedVariantBatches = new Map(); // variantId → Batch[] (level-3)
let currentDrug = null; let currentDrug = null;
let showLowStockOnly = false; let showLowStockOnly = false;
let selectedLocationFilter = ''; let selectedLocationFilter = '';
@@ -450,9 +452,21 @@ function setupEventListeners() {
}); });
// User menu // User menu
if (userMenuBtn) userMenuBtn.addEventListener('click', () => { if (userMenuBtn) userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = document.getElementById('userDropdown'); const dropdown = document.getElementById('userDropdown');
if (dropdown) dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none'; if (dropdown) {
const isHidden = getComputedStyle(dropdown).display === 'none';
dropdown.style.display = isHidden ? 'block' : 'none';
}
});
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('userDropdown');
const btn = document.getElementById('userMenuBtn');
if (dropdown && btn && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.style.display = 'none';
}
}); });
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal); if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
@@ -532,22 +546,24 @@ async function loadDrugs() {
const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id)); const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id));
if (newVariant) { if (newVariant) {
restore.variantId = newVariant.id; restore.variantId = newVariant.id;
// If no pack snapshot, all packs are new — pick the first active one // If no pack snapshot, all packs are new — pick the first active one from detail
if (!restore._existingPackIds) { if (!restore._existingPackIds) {
const firstPack = getActivePacksForVariant(newVariant)?.[0]; await ensureDrugDetailLoaded(restore.drugId);
const detailVariant = getVariantById(newVariant.id);
const firstPack = getActivePacksForVariant(detailVariant)?.[0];
if (firstPack) restore.packId = firstPack.id; if (firstPack) restore.packId = firstPack.id;
} }
} }
} }
// Resolve new pack by diffing (add-pack flow) // Resolve new pack by diffing (add-pack flow) — need packs from loaded detail
if (restore._existingPackIds && restore.drugId && restore.variantId) { if (restore._existingPackIds && restore.drugId && restore.variantId) {
const drug = allDrugs.find(d => d.id === restore.drugId); await ensureDrugDetailLoaded(restore.drugId);
const variant = drug?.variants?.find(v => v.id === restore.variantId); const variant = getVariantById(restore.variantId);
const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.id)); const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.id));
if (newPack) restore.packId = newPack.id; if (newPack) restore.packId = newPack.id;
} }
_reinitGtinMappingModal(restore); await _reinitGtinMappingModal(restore);
} }
// After handleAddDrug's loadDrugs fires: find the newly created drug and set up // After handleAddDrug's loadDrugs fires: find the newly created drug and set up
@@ -959,10 +975,10 @@ function isBatchExpired(batch) {
return expiryDate < today; return expiryDate < today;
} }
function renderVariantInventoryDetails(variant) { function renderVariantInventoryDetails(variant, batches) {
const activePacks = getActivePacksForVariant(variant); const activePacks = getActivePacksForVariant(variant);
const isReadOnly = currentUser?.role === 'readonly'; const isReadOnly = currentUser?.role === 'readonly';
const batches = [...(variant.batches || [])] const sortedBatches = [...(batches || [])]
.filter(batch => Number(batch.quantity) > 0) .filter(batch => Number(batch.quantity) > 0)
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); .sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
@@ -975,8 +991,8 @@ function renderVariantInventoryDetails(variant) {
`).join('') `).join('')
: '<div style="padding: 6px 8px; background: #ffffff; border: 1px dashed #cfd8e3; border-radius: 5px; font-size: 0.9em; color: #6b7280;">No active packs configured</div>'; : '<div style="padding: 6px 8px; background: #ffffff; border: 1px dashed #cfd8e3; border-radius: 5px; font-size: 0.9em; color: #6b7280;">No active packs configured</div>';
const batchesHtml = batches.length > 0 const batchesHtml = sortedBatches.length > 0
? batches.map(batch => { ? sortedBatches.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_unit_name; const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name;
@@ -1067,6 +1083,16 @@ async function handleDisposeBatch(e) {
if (modal) { if (modal) {
closeDisposeBatchModal(); closeDisposeBatchModal();
} }
// Find which variant this batch belongs to and invalidate its batch cache
const batchVariantId = (() => {
for (const [vid, batches] of loadedVariantBatches) {
if (batches.some(b => b.id === batchId)) return vid;
}
return null;
})();
if (batchVariantId) {
loadedVariantBatches.delete(batchVariantId);
}
await loadDrugs(); await loadDrugs();
showToast('Expired batch marked as disposed.', 'success'); showToast('Expired batch marked as disposed.', 'success');
} catch (error) { } catch (error) {
@@ -1087,13 +1113,7 @@ function updateLocationFilterOptions() {
const locations = new Set(); const locations = new Set();
allDrugs.forEach(drug => { allDrugs.forEach(drug => {
drug.variants.forEach(variant => { (drug.locations || []).forEach(loc => locations.add(loc));
(variant.batches || []).forEach(batch => {
if (batch.quantity > 0) {
locations.add(getBatchLocationLabel(batch));
}
});
});
}); });
locationFilterSelect.innerHTML = '<option value="">All Locations</option>'; locationFilterSelect.innerHTML = '<option value="">All Locations</option>';
@@ -1388,7 +1408,10 @@ async function updateBatchInfo() {
const variant = getVariantById(variantId); const variant = getVariantById(variantId);
if (variant) { if (variant) {
populateDispensePackSelect(variant); // Ensure drug detail (with packs) is loaded before populating pack select
const drugOfVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
if (drugOfVariant) await ensureDrugDetailLoaded(drugOfVariant.id);
populateDispensePackSelect(getVariantById(variantId));
} }
updateDispenseModeUi(); updateDispenseModeUi();
@@ -1621,14 +1644,10 @@ function renderDrugs() {
); );
} }
// Apply location filter // Apply location filter using the pre-computed locations list in the summary
if (selectedLocationFilter) { if (selectedLocationFilter) {
drugsToShow = drugsToShow.filter(drug => drugsToShow = drugsToShow.filter(drug =>
drug.variants.some(variant => (drug.locations || []).includes(selectedLocationFilter)
(variant.batches || []).some(batch =>
batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter
)
)
); );
} }
@@ -1650,23 +1669,35 @@ function renderDrugs() {
const isExpanded = expandedDrugs.has(drug.id); const isExpanded = expandedDrugs.has(drug.id);
const isReadOnly = currentUser.role === 'readonly'; const isReadOnly = currentUser.role === 'readonly';
const isControlled = drug.is_controlled; const isControlled = drug.is_controlled;
const drugDetail = loadedDrugDetails.get(drug.id);
const variantsHtml = isExpanded ? ` let variantsHtml = '';
${drug.variants.map(variant => { if (isExpanded) {
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold; if (!drugDetail) {
const variantExpanded = expandedVariants.has(variant.id); variantsHtml = '<div class="variant-item" style="padding: 12px; color: #6b7280; font-style: italic;">Loading variants…</div>';
const expiredQuantity = (variant.batches || []) } else {
.filter(batch => Number(batch.quantity) > 0 && isBatchExpired(batch)) variantsHtml = drug.variants.map(summaryVariant => {
.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0); // Use detail variant (has packs); fall back to summary if not found
const inDateQuantity = Math.max(0, Number(variant.quantity || 0) - expiredQuantity); const variant = (drugDetail.variants || []).find(v => v.id === summaryVariant.id) || summaryVariant;
const variantIsLowStock = summaryVariant.quantity <= summaryVariant.low_stock_threshold;
const variantExpanded = expandedVariants.has(summaryVariant.id);
// expiredQuantity is pre-computed in the summary
const expiredQuantity = summaryVariant.expired_quantity || 0;
const inDateQuantity = Math.max(0, Number(summaryVariant.quantity || 0) - expiredQuantity);
const quantityDisplay = expiredQuantity > 0 const quantityDisplay = expiredQuantity > 0
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(variant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)` ? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(summaryVariant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
: `${formatDisplayNumber(variant.quantity)} ${escapeHtml(variant.unit)}`; : `${formatDisplayNumber(summaryVariant.quantity)} ${escapeHtml(summaryVariant.unit)}`;
const batches = loadedVariantBatches.get(summaryVariant.id);
const batchesSection = (() => {
if (!variantExpanded) return '';
if (!batches) return '<div style="padding: 10px; color: #6b7280; font-style: italic;">Loading batches…</div>';
return renderVariantInventoryDetails(variant, batches);
})();
return ` return `
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${variant.id}, event)"> <div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${summaryVariant.id}, event)">
<div class="variant-info"> <div class="variant-info">
<div class="variant-details"> <div class="variant-details">
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div> <div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(summaryVariant.strength)}</div>
<div class="variant-quantity">${quantityDisplay}</div> <div class="variant-quantity">${quantityDisplay}</div>
</div> </div>
<div class="variant-status"> <div class="variant-status">
@@ -1678,15 +1709,17 @@ function renderDrugs() {
</div> </div>
<div class="variant-actions"> <div class="variant-actions">
${!isReadOnly ? ` ${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${variant.id})">💊 Dispense</button> <button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${summaryVariant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${variant.id})">Edit</button> <button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${summaryVariant.id})">Edit</button>
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${variant.id})" title="${variant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button> <button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${summaryVariant.id})" title="${summaryVariant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
` : ''} ` : ''}
</div> </div>
${variantExpanded ? renderVariantInventoryDetails(variant) : ''} ${batchesSection}
</div> </div>
`; `;
}).join('')}` : ''; }).join('');
}
}
return ` return `
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})"> <div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
@@ -1934,6 +1967,7 @@ async function handleDispenseDrug(e) {
document.getElementById('dispenseForm').reset(); document.getElementById('dispenseForm').reset();
resetDispensePrintFields(); resetDispensePrintFields();
closeModal(document.getElementById('dispenseModal')); closeModal(document.getElementById('dispenseModal'));
loadedVariantBatches.delete(variantId); // invalidate level-3 batch cache
await loadDrugs(); await loadDrugs();
showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined); showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined);
} catch (error) { } catch (error) {
@@ -1962,30 +1996,45 @@ function closeEditModal() {
} }
// Show variants for a drug // Show variants for a drug
function toggleDrugExpansion(drugId) { async function toggleDrugExpansion(drugId) {
if (expandedDrugs.has(drugId)) { if (expandedDrugs.has(drugId)) {
expandedDrugs.delete(drugId); expandedDrugs.delete(drugId);
const collapsedDrug = allDrugs.find(drug => drug.id === drugId); const collapsedDrug = allDrugs.find(drug => drug.id === drugId);
if (collapsedDrug) { if (collapsedDrug) {
(collapsedDrug.variants || []).forEach(variant => expandedVariants.delete(variant.id)); (collapsedDrug.variants || []).forEach(variant => expandedVariants.delete(variant.id));
} }
renderDrugs();
} else { } else {
expandedDrugs.add(drugId); expandedDrugs.add(drugId);
} renderDrugs(); // show loading state immediately
await ensureDrugDetailLoaded(drugId);
renderDrugs(); renderDrugs();
}
} }
function toggleVariantExpansion(variantId, event) { async function toggleVariantExpansion(variantId, event) {
if (event) { if (event) {
event.stopPropagation(); event.stopPropagation();
} }
if (expandedVariants.has(variantId)) { if (expandedVariants.has(variantId)) {
expandedVariants.delete(variantId); expandedVariants.delete(variantId);
renderDrugs();
} else { } else {
expandedVariants.add(variantId); expandedVariants.add(variantId);
if (!loadedVariantBatches.has(variantId)) {
renderDrugs(); // show loading state immediately
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (response.ok) {
loadedVariantBatches.set(variantId, await response.json());
}
} catch (error) {
console.error(`Failed to load batches for variant ${variantId}:`, error);
}
} }
renderDrugs(); renderDrugs();
}
} }
// Open add variant modal // Open add variant modal
@@ -2256,6 +2305,7 @@ async function handleAddVariant(e) {
document.getElementById('variantForm').reset(); document.getElementById('variantForm').reset();
closeModal(document.getElementById('addVariantModal')); closeModal(document.getElementById('addVariantModal'));
loadedDrugDetails.delete(drugId); // invalidate level-2 cache for this drug
await loadDrugs(); await loadDrugs();
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) { if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
refreshDeliveryVariantSelects(); refreshDeliveryVariantSelects();
@@ -2369,6 +2419,8 @@ async function handleEditVariant(e) {
} }
closeModal(document.getElementById('editVariantModal')); closeModal(document.getElementById('editVariantModal'));
const variantDrugId = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId))?.id;
if (variantDrugId) loadedDrugDetails.delete(variantDrugId); // invalidate level-2 cache
await loadDrugs(); await loadDrugs();
renderDrugs(); renderDrugs();
const message = newPackPayloads.length > 0 const message = newPackPayloads.length > 0
@@ -2574,6 +2626,7 @@ async function handleEditDrug(e) {
if (!response.ok) throw new Error('Failed to update drug'); if (!response.ok) throw new Error('Failed to update drug');
closeEditModal(); closeEditModal();
loadedDrugDetails.delete(drugId); // invalidate level-2 cache
await loadDrugs(); await loadDrugs();
showToast('Drug updated successfully!', 'success'); showToast('Drug updated successfully!', 'success');
} catch (error) { } catch (error) {
@@ -2602,6 +2655,8 @@ async function deleteDrug(drugId) {
throw new Error(error.detail || 'Failed to delete drug'); throw new Error(error.detail || 'Failed to delete drug');
} }
loadedDrugDetails.delete(drugId); // invalidate level-2 cache
loadedVariantBatches.clear(); // variants of this drug are gone
await loadDrugs(); await loadDrugs();
showToast('Drug deleted successfully!', 'success'); showToast('Drug deleted successfully!', 'success');
} catch (error) { } catch (error) {
@@ -3039,6 +3094,7 @@ async function handleBatchReceive(e) {
document.getElementById('batchReceiveForm').reset(); document.getElementById('batchReceiveForm').reset();
closeModal(document.getElementById('batchReceiveModal')); closeModal(document.getElementById('batchReceiveModal'));
loadedVariantBatches.delete(variantId); // invalidate level-3 cache
await loadDrugs(); await loadDrugs();
showToast('Batch received successfully!', 'success'); showToast('Batch received successfully!', 'success');
} catch (error) { } catch (error) {
@@ -3052,6 +3108,12 @@ function getActiveDeliveryDrug() {
} }
function getVariantById(variantId) { function getVariantById(variantId) {
// Prefer loaded drug details (have packs)
for (const detail of loadedDrugDetails.values()) {
const found = (detail.variants || []).find(v => v.id === variantId);
if (found) return found;
}
// Fall back to summary variant (no packs)
for (const drug of allDrugs) { for (const drug of allDrugs) {
const found = (drug.variants || []).find(v => v.id === variantId); const found = (drug.variants || []).find(v => v.id === variantId);
if (found) return found; if (found) return found;
@@ -3059,6 +3121,18 @@ function getVariantById(variantId) {
return null; return null;
} }
async function ensureDrugDetailLoaded(drugId) {
if (!drugId || loadedDrugDetails.has(drugId)) return;
try {
const response = await apiCall(`/drugs/${drugId}`);
if (response.ok) {
loadedDrugDetails.set(drugId, await response.json());
}
} catch (error) {
console.error(`Failed to load drug detail for drug ${drugId}:`, error);
}
}
function buildDeliveryVariantOptions(drug, selectedVariantId = '') { function buildDeliveryVariantOptions(drug, selectedVariantId = '') {
if (!drug || !drug.variants || drug.variants.length === 0) { if (!drug || !drug.variants || drug.variants.length === 0) {
return '<option value="">-- No variants available --</option>'; return '<option value="">-- No variants available --</option>';
@@ -3116,8 +3190,9 @@ function wireDeliveryLineEvents(line) {
const packCountInput = line.querySelector('.delivery-pack-count'); const packCountInput = line.querySelector('.delivery-pack-count');
if (drugSelect && variantSelect) { if (drugSelect && variantSelect) {
drugSelect.addEventListener('change', () => { drugSelect.addEventListener('change', async () => {
const drugId = parseInt(drugSelect.value || '', 10); const drugId = parseInt(drugSelect.value || '', 10);
await ensureDrugDetailLoaded(drugId);
const drug = allDrugs.find(d => d.id === drugId) || null; const drug = allDrugs.find(d => d.id === drugId) || null;
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, ''); variantSelect.innerHTML = buildDeliveryVariantOptions(drug, '');
if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, ''); if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, '');
@@ -3466,7 +3541,7 @@ function _refreshGtinMappingSelects() {
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data, // Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
// optionally pre-selecting specific drug/variant/pack IDs. // optionally pre-selecting specific drug/variant/pack IDs.
function _reinitGtinMappingModal(restore) { async function _reinitGtinMappingModal(restore) {
const drugSelect = document.getElementById('gtinMappingDrugSelect'); const drugSelect = document.getElementById('gtinMappingDrugSelect');
const variantSelect = document.getElementById('gtinMappingVariantSelect'); const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect'); const packSelect = document.getElementById('gtinMappingPackSelect');
@@ -3488,6 +3563,7 @@ function _reinitGtinMappingModal(restore) {
} }
drugSelect.value = String(drugId); drugSelect.value = String(drugId);
await ensureDrugDetailLoaded(drugId);
const drug = allDrugs.find(d => d.id === drugId); const drug = allDrugs.find(d => d.id === drugId);
// Rebuild variant list // Rebuild variant list
@@ -3504,7 +3580,7 @@ function _reinitGtinMappingModal(restore) {
} }
variantSelect.value = String(variantId); variantSelect.value = String(variantId);
const variant = drug?.variants?.find(v => v.id === variantId); const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs)
// Rebuild pack list // Rebuild pack list
packSelect.innerHTML = '<option value="">-- Select pack --</option>'; packSelect.innerHTML = '<option value="">-- Select pack --</option>';
@@ -3540,9 +3616,9 @@ function gtinMappingAddPack() {
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10); const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
if (!variantId) { showToast('Select a variant first', 'warning'); return; } if (!variantId) { showToast('Select a variant first', 'warning'); return; }
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10); const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId); if (!drugId) return;
if (!drug) return; // Use getVariantById which returns the detail variant (with packs) if loaded
const variant = drug.variants?.find(v => v.id === variantId); const variant = getVariantById(variantId);
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id)); const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds }; _gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
_gtinMappingPendingRefresh = true; _gtinMappingPendingRefresh = true;
@@ -3571,15 +3647,17 @@ function openGtinMappingModal(gtin, expiryStr, lot) {
openModal(document.getElementById('gtinMappingModal')); openModal(document.getElementById('gtinMappingModal'));
} }
function onGtinMappingDrugChange() { async function onGtinMappingDrugChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10); const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId);
const variantSelect = document.getElementById('gtinMappingVariantSelect'); const variantSelect = document.getElementById('gtinMappingVariantSelect');
const packSelect = document.getElementById('gtinMappingPackSelect'); const packSelect = document.getElementById('gtinMappingPackSelect');
variantSelect.innerHTML = '<option value="">-- Select variant --</option>'; variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
packSelect.innerHTML = '<option value="">-- Select pack --</option>'; packSelect.innerHTML = '<option value="">-- Select pack --</option>';
if (!drugId) return;
await ensureDrugDetailLoaded(drugId);
const drug = allDrugs.find(d => d.id === drugId);
if (!drug) return; if (!drug) return;
variantSelect.innerHTML += drug.variants.map(v => variantSelect.innerHTML += drug.variants.map(v =>
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>` `<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
@@ -3587,10 +3665,8 @@ function onGtinMappingDrugChange() {
} }
function onGtinMappingVariantChange() { function onGtinMappingVariantChange() {
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10); const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
const drug = allDrugs.find(d => d.id === drugId); const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs)
const variant = drug?.variants?.find(v => v.id === variantId);
const packSelect = document.getElementById('gtinMappingPackSelect'); const packSelect = document.getElementById('gtinMappingPackSelect');
packSelect.innerHTML = '<option value="">-- Select pack --</option>'; packSelect.innerHTML = '<option value="">-- Select pack --</option>';
@@ -3901,6 +3977,9 @@ async function handleAddPackSize(e) {
} }
closeModal(document.getElementById('addPackSizeModal')); closeModal(document.getElementById('addPackSizeModal'));
// Invalidate drug detail cache so the new pack will be re-fetched on next expand
const packDrugId = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId))?.id;
if (packDrugId) loadedDrugDetails.delete(packDrugId);
await loadDrugs(); await loadDrugs();
// Refresh delivery line pack selects so the new pack is immediately available // Refresh delivery line pack selects so the new pack is immediately available
refreshDeliveryVariantSelects(); refreshDeliveryVariantSelects();
@@ -3986,6 +4065,8 @@ async function handleReceiveDelivery(e) {
closeModal(document.getElementById('receiveDeliveryModal')); closeModal(document.getElementById('receiveDeliveryModal'));
_detachDeliveryBarcodeListener(); _detachDeliveryBarcodeListener();
// Invalidate batch cache for all delivered variants
payloads.forEach(entry => loadedVariantBatches.delete(entry.variantId));
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) {