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
+157 -76
View File
@@ -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,43 +1669,57 @@ function renderDrugs() {
const isExpanded = expandedDrugs.has(drug.id);
const isReadOnly = currentUser.role === 'readonly';
const isControlled = drug.is_controlled;
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);
const quantityDisplay = expiredQuantity > 0
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(variant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
: `${formatDisplayNumber(variant.quantity)} ${escapeHtml(variant.unit)}`;
return `
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${variant.id}, event)">
<div class="variant-info">
<div class="variant-details">
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div>
<div class="variant-quantity">${quantityDisplay}</div>
const drugDetail = loadedDrugDetails.get(drug.id);
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(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(${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 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 class="variant-actions">
${!isReadOnly ? `
<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>
${batchesSection}
</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>
` : ''}
</div>
${variantExpanded ? renderVariantInventoryDetails(variant) : ''}
</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,30 +1996,45 @@ 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();
}
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();
}
renderDrugs();
}
// Open add variant modal
@@ -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) {