Refactor - API lazy loading
This commit is contained in:
+162
-8
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta, date
|
||||
import math
|
||||
@@ -263,6 +264,34 @@ class DrugWithVariantsResponse(BaseModel):
|
||||
class Config:
|
||||
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):
|
||||
batch_id: int
|
||||
quantity: float
|
||||
@@ -399,6 +428,40 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
|
||||
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]:
|
||||
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
||||
pack = None
|
||||
@@ -866,21 +929,112 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db:
|
||||
def read_root():
|
||||
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)):
|
||||
"""Get all drugs with their variants"""
|
||||
"""Get all drugs with lightweight variant summaries (no packs or batches)."""
|
||||
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 = []
|
||||
for drug in drugs:
|
||||
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||
drug_dict = {
|
||||
drug_variants = variants_by_drug.get(drug.id, [])
|
||||
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,
|
||||
"name": drug.name,
|
||||
"description": drug.description,
|
||||
"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
|
||||
|
||||
@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)
|
||||
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()
|
||||
if not drug:
|
||||
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,
|
||||
"description": drug.description,
|
||||
"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
|
||||
|
||||
|
||||
+139
-58
@@ -1,5 +1,7 @@
|
||||
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 showLowStockOnly = false;
|
||||
let selectedLocationFilter = '';
|
||||
@@ -450,9 +452,21 @@ function setupEventListeners() {
|
||||
});
|
||||
|
||||
// User menu
|
||||
if (userMenuBtn) userMenuBtn.addEventListener('click', () => {
|
||||
if (userMenuBtn) userMenuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
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);
|
||||
@@ -532,22 +546,24 @@ async function loadDrugs() {
|
||||
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 no pack snapshot, all packs are new — pick the first active one from detail
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
const drug = allDrugs.find(d => d.id === restore.drugId);
|
||||
const variant = drug?.variants?.find(v => v.id === restore.variantId);
|
||||
await ensureDrugDetailLoaded(restore.drugId);
|
||||
const variant = getVariantById(restore.variantId);
|
||||
const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.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
|
||||
@@ -959,10 +975,10 @@ function isBatchExpired(batch) {
|
||||
return expiryDate < today;
|
||||
}
|
||||
|
||||
function renderVariantInventoryDetails(variant) {
|
||||
function renderVariantInventoryDetails(variant, batches) {
|
||||
const activePacks = getActivePacksForVariant(variant);
|
||||
const isReadOnly = currentUser?.role === 'readonly';
|
||||
const batches = [...(variant.batches || [])]
|
||||
const sortedBatches = [...(batches || [])]
|
||||
.filter(batch => Number(batch.quantity) > 0)
|
||||
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||||
|
||||
@@ -975,8 +991,8 @@ function renderVariantInventoryDetails(variant) {
|
||||
`).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>';
|
||||
|
||||
const batchesHtml = batches.length > 0
|
||||
? batches.map(batch => {
|
||||
const batchesHtml = sortedBatches.length > 0
|
||||
? sortedBatches.map(batch => {
|
||||
const locationLabel = getBatchLocationLabel(batch);
|
||||
const expired = isBatchExpired(batch);
|
||||
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name;
|
||||
@@ -1067,6 +1083,16 @@ async function handleDisposeBatch(e) {
|
||||
if (modal) {
|
||||
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();
|
||||
showToast('Expired batch marked as disposed.', 'success');
|
||||
} catch (error) {
|
||||
@@ -1087,13 +1113,7 @@ function updateLocationFilterOptions() {
|
||||
const locations = new Set();
|
||||
|
||||
allDrugs.forEach(drug => {
|
||||
drug.variants.forEach(variant => {
|
||||
(variant.batches || []).forEach(batch => {
|
||||
if (batch.quantity > 0) {
|
||||
locations.add(getBatchLocationLabel(batch));
|
||||
}
|
||||
});
|
||||
});
|
||||
(drug.locations || []).forEach(loc => locations.add(loc));
|
||||
});
|
||||
|
||||
locationFilterSelect.innerHTML = '<option value="">All Locations</option>';
|
||||
@@ -1388,7 +1408,10 @@ async function updateBatchInfo() {
|
||||
|
||||
const variant = getVariantById(variantId);
|
||||
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();
|
||||
|
||||
@@ -1621,14 +1644,10 @@ function renderDrugs() {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply location filter
|
||||
// Apply location filter using the pre-computed locations list in the summary
|
||||
if (selectedLocationFilter) {
|
||||
drugsToShow = drugsToShow.filter(drug =>
|
||||
drug.variants.some(variant =>
|
||||
(variant.batches || []).some(batch =>
|
||||
batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter
|
||||
)
|
||||
)
|
||||
(drug.locations || []).includes(selectedLocationFilter)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1650,23 +1669,35 @@ function renderDrugs() {
|
||||
const isExpanded = expandedDrugs.has(drug.id);
|
||||
const isReadOnly = currentUser.role === 'readonly';
|
||||
const isControlled = drug.is_controlled;
|
||||
const drugDetail = loadedDrugDetails.get(drug.id);
|
||||
|
||||
const variantsHtml = isExpanded ? `
|
||||
${drug.variants.map(variant => {
|
||||
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold;
|
||||
const variantExpanded = expandedVariants.has(variant.id);
|
||||
const expiredQuantity = (variant.batches || [])
|
||||
.filter(batch => Number(batch.quantity) > 0 && isBatchExpired(batch))
|
||||
.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0);
|
||||
const inDateQuantity = Math.max(0, Number(variant.quantity || 0) - expiredQuantity);
|
||||
let variantsHtml = '';
|
||||
if (isExpanded) {
|
||||
if (!drugDetail) {
|
||||
variantsHtml = '<div class="variant-item" style="padding: 12px; color: #6b7280; font-style: italic;">Loading variants…</div>';
|
||||
} else {
|
||||
variantsHtml = drug.variants.map(summaryVariant => {
|
||||
// Use detail variant (has packs); fall back to summary if not found
|
||||
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
|
||||
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(variant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
|
||||
: `${formatDisplayNumber(variant.quantity)} ${escapeHtml(variant.unit)}`;
|
||||
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(summaryVariant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
|
||||
: `${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 `
|
||||
<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-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>
|
||||
<div class="variant-status">
|
||||
@@ -1678,15 +1709,17 @@ function renderDrugs() {
|
||||
</div>
|
||||
<div class="variant-actions">
|
||||
${!isReadOnly ? `
|
||||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${variant.id})">💊 Dispense</button>
|
||||
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${variant.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-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${summaryVariant.id})">💊 Dispense</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(${summaryVariant.id})" title="${summaryVariant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
|
||||
` : ''}
|
||||
</div>
|
||||
${variantExpanded ? renderVariantInventoryDetails(variant) : ''}
|
||||
${batchesSection}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}` : '';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<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();
|
||||
resetDispensePrintFields();
|
||||
closeModal(document.getElementById('dispenseModal'));
|
||||
loadedVariantBatches.delete(variantId); // invalidate level-3 batch cache
|
||||
await loadDrugs();
|
||||
showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined);
|
||||
} catch (error) {
|
||||
@@ -1962,31 +1996,46 @@ function closeEditModal() {
|
||||
}
|
||||
|
||||
// Show variants for a drug
|
||||
function toggleDrugExpansion(drugId) {
|
||||
async function toggleDrugExpansion(drugId) {
|
||||
if (expandedDrugs.has(drugId)) {
|
||||
expandedDrugs.delete(drugId);
|
||||
const collapsedDrug = allDrugs.find(drug => drug.id === drugId);
|
||||
if (collapsedDrug) {
|
||||
(collapsedDrug.variants || []).forEach(variant => expandedVariants.delete(variant.id));
|
||||
}
|
||||
renderDrugs();
|
||||
} else {
|
||||
expandedDrugs.add(drugId);
|
||||
}
|
||||
renderDrugs(); // show loading state immediately
|
||||
await ensureDrugDetailLoaded(drugId);
|
||||
renderDrugs();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVariantExpansion(variantId, event) {
|
||||
async function toggleVariantExpansion(variantId, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (expandedVariants.has(variantId)) {
|
||||
expandedVariants.delete(variantId);
|
||||
renderDrugs();
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Open add variant modal
|
||||
function openAddVariantModal(drugId) {
|
||||
@@ -2256,6 +2305,7 @@ async function handleAddVariant(e) {
|
||||
|
||||
document.getElementById('variantForm').reset();
|
||||
closeModal(document.getElementById('addVariantModal'));
|
||||
loadedDrugDetails.delete(drugId); // invalidate level-2 cache for this drug
|
||||
await loadDrugs();
|
||||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||||
refreshDeliveryVariantSelects();
|
||||
@@ -2369,6 +2419,8 @@ async function handleEditVariant(e) {
|
||||
}
|
||||
|
||||
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();
|
||||
renderDrugs();
|
||||
const message = newPackPayloads.length > 0
|
||||
@@ -2574,6 +2626,7 @@ async function handleEditDrug(e) {
|
||||
if (!response.ok) throw new Error('Failed to update drug');
|
||||
|
||||
closeEditModal();
|
||||
loadedDrugDetails.delete(drugId); // invalidate level-2 cache
|
||||
await loadDrugs();
|
||||
showToast('Drug updated successfully!', 'success');
|
||||
} catch (error) {
|
||||
@@ -2602,6 +2655,8 @@ async function deleteDrug(drugId) {
|
||||
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();
|
||||
showToast('Drug deleted successfully!', 'success');
|
||||
} catch (error) {
|
||||
@@ -3039,6 +3094,7 @@ async function handleBatchReceive(e) {
|
||||
|
||||
document.getElementById('batchReceiveForm').reset();
|
||||
closeModal(document.getElementById('batchReceiveModal'));
|
||||
loadedVariantBatches.delete(variantId); // invalidate level-3 cache
|
||||
await loadDrugs();
|
||||
showToast('Batch received successfully!', 'success');
|
||||
} catch (error) {
|
||||
@@ -3052,6 +3108,12 @@ function getActiveDeliveryDrug() {
|
||||
}
|
||||
|
||||
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) {
|
||||
const found = (drug.variants || []).find(v => v.id === variantId);
|
||||
if (found) return found;
|
||||
@@ -3059,6 +3121,18 @@ function getVariantById(variantId) {
|
||||
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 = '') {
|
||||
if (!drug || !drug.variants || drug.variants.length === 0) {
|
||||
return '<option value="">-- No variants available --</option>';
|
||||
@@ -3116,8 +3190,9 @@ function wireDeliveryLineEvents(line) {
|
||||
const packCountInput = line.querySelector('.delivery-pack-count');
|
||||
|
||||
if (drugSelect && variantSelect) {
|
||||
drugSelect.addEventListener('change', () => {
|
||||
drugSelect.addEventListener('change', async () => {
|
||||
const drugId = parseInt(drugSelect.value || '', 10);
|
||||
await ensureDrugDetailLoaded(drugId);
|
||||
const drug = allDrugs.find(d => d.id === drugId) || null;
|
||||
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, '');
|
||||
if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, '');
|
||||
@@ -3466,7 +3541,7 @@ function _refreshGtinMappingSelects() {
|
||||
|
||||
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
|
||||
// optionally pre-selecting specific drug/variant/pack IDs.
|
||||
function _reinitGtinMappingModal(restore) {
|
||||
async function _reinitGtinMappingModal(restore) {
|
||||
const drugSelect = document.getElementById('gtinMappingDrugSelect');
|
||||
const variantSelect = document.getElementById('gtinMappingVariantSelect');
|
||||
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||||
@@ -3488,6 +3563,7 @@ function _reinitGtinMappingModal(restore) {
|
||||
}
|
||||
|
||||
drugSelect.value = String(drugId);
|
||||
await ensureDrugDetailLoaded(drugId);
|
||||
const drug = allDrugs.find(d => d.id === drugId);
|
||||
|
||||
// Rebuild variant list
|
||||
@@ -3504,7 +3580,7 @@ function _reinitGtinMappingModal(restore) {
|
||||
}
|
||||
|
||||
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
|
||||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
@@ -3540,9 +3616,9 @@ 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);
|
||||
if (!drugId) return;
|
||||
// Use getVariantById which returns the detail variant (with packs) if loaded
|
||||
const variant = getVariantById(variantId);
|
||||
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
|
||||
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
|
||||
_gtinMappingPendingRefresh = true;
|
||||
@@ -3571,15 +3647,17 @@ function openGtinMappingModal(gtin, expiryStr, lot) {
|
||||
openModal(document.getElementById('gtinMappingModal'));
|
||||
}
|
||||
|
||||
function onGtinMappingDrugChange() {
|
||||
async 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 (!drugId) return;
|
||||
await ensureDrugDetailLoaded(drugId);
|
||||
const drug = allDrugs.find(d => d.id === drugId);
|
||||
if (!drug) return;
|
||||
variantSelect.innerHTML += drug.variants.map(v =>
|
||||
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||||
@@ -3587,10 +3665,8 @@ function onGtinMappingDrugChange() {
|
||||
}
|
||||
|
||||
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 variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs)
|
||||
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||||
|
||||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
@@ -3901,6 +3977,9 @@ async function handleAddPackSize(e) {
|
||||
}
|
||||
|
||||
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();
|
||||
// Refresh delivery line pack selects so the new pack is immediately available
|
||||
refreshDeliveryVariantSelects();
|
||||
@@ -3986,6 +4065,8 @@ async function handleReceiveDelivery(e) {
|
||||
|
||||
closeModal(document.getElementById('receiveDeliveryModal'));
|
||||
_detachDeliveryBarcodeListener();
|
||||
// Invalidate batch cache for all delivered variants
|
||||
payloads.forEach(entry => loadedVariantBatches.delete(entry.variantId));
|
||||
await loadDrugs();
|
||||
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user