Refactor - API lazy loading
This commit is contained in:
+168
-14
@@ -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": drug.id,
|
{
|
||||||
"name": drug.name,
|
"id": v.id,
|
||||||
"description": drug.description,
|
"drug_id": v.drug_id,
|
||||||
"is_controlled": bool(drug.is_controlled),
|
"strength": v.strength,
|
||||||
"variants": [enrich_variant_with_batches(db, v) for v in variants],
|
"quantity": v.quantity,
|
||||||
}
|
"unit": v.unit,
|
||||||
result.append(drug_dict)
|
"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),
|
||||||
|
"locations": locations_by_drug.get(drug.id, []),
|
||||||
|
"variants": variant_summaries,
|
||||||
|
}
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
||||||
@@ -910,18 +1064,18 @@ 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")
|
||||||
|
|
||||||
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||||
drug_dict = {
|
drug_dict = {
|
||||||
"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],
|
"variants": [serialize_variant_with_packs(db, v) for v in variants],
|
||||||
}
|
}
|
||||||
return drug_dict
|
return drug_dict
|
||||||
|
|
||||||
|
|||||||
+157
-76
@@ -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,43 +1669,57 @@ 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 ? `
|
|
||||||
${drug.variants.map(variant => {
|
let variantsHtml = '';
|
||||||
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold;
|
if (isExpanded) {
|
||||||
const variantExpanded = expandedVariants.has(variant.id);
|
if (!drugDetail) {
|
||||||
const expiredQuantity = (variant.batches || [])
|
variantsHtml = '<div class="variant-item" style="padding: 12px; color: #6b7280; font-style: italic;">Loading variants…</div>';
|
||||||
.filter(batch => Number(batch.quantity) > 0 && isBatchExpired(batch))
|
} else {
|
||||||
.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0);
|
variantsHtml = drug.variants.map(summaryVariant => {
|
||||||
const inDateQuantity = Math.max(0, Number(variant.quantity || 0) - expiredQuantity);
|
// Use detail variant (has packs); fall back to summary if not found
|
||||||
const quantityDisplay = expiredQuantity > 0
|
const variant = (drugDetail.variants || []).find(v => v.id === summaryVariant.id) || summaryVariant;
|
||||||
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(variant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
|
const variantIsLowStock = summaryVariant.quantity <= summaryVariant.low_stock_threshold;
|
||||||
: `${formatDisplayNumber(variant.quantity)} ${escapeHtml(variant.unit)}`;
|
const variantExpanded = expandedVariants.has(summaryVariant.id);
|
||||||
return `
|
// expiredQuantity is pre-computed in the summary
|
||||||
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${variant.id}, event)">
|
const expiredQuantity = summaryVariant.expired_quantity || 0;
|
||||||
<div class="variant-info">
|
const inDateQuantity = Math.max(0, Number(summaryVariant.quantity || 0) - expiredQuantity);
|
||||||
<div class="variant-details">
|
const quantityDisplay = expiredQuantity > 0
|
||||||
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div>
|
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(summaryVariant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
|
||||||
<div class="variant-quantity">${quantityDisplay}</div>
|
: `${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(${summaryVariant.id}, event)">
|
||||||
|
<div class="variant-info">
|
||||||
|
<div class="variant-details">
|
||||||
|
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(summaryVariant.strength)}</div>
|
||||||
|
<div class="variant-quantity">${quantityDisplay}</div>
|
||||||
|
</div>
|
||||||
|
<div class="variant-status">
|
||||||
|
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
|
||||||
|
${variantIsLowStock ? 'Low Stock' : 'OK'}
|
||||||
|
</span>
|
||||||
|
<span style="margin-left: 8px; font-size: 0.85em; color: #475569;">Inventory ${variantExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="variant-status">
|
<div class="variant-actions">
|
||||||
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
|
${!isReadOnly ? `
|
||||||
${variantIsLowStock ? 'Low Stock' : 'OK'}
|
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${summaryVariant.id})">💊 Dispense</button>
|
||||||
</span>
|
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${summaryVariant.id})">Edit</button>
|
||||||
<span style="margin-left: 8px; font-size: 0.85em; color: #475569;">Inventory ${variantExpanded ? '▼' : '▶'}</span>
|
<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>
|
||||||
|
${batchesSection}
|
||||||
</div>
|
</div>
|
||||||
<div class="variant-actions">
|
`;
|
||||||
${!isReadOnly ? `
|
}).join('');
|
||||||
<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>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
${variantExpanded ? renderVariantInventoryDetails(variant) : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user