const API_URL = '/api'; 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 = ''; let searchTerm = ''; let expandedDrugs = new Set(); let expandedVariants = new Set(); let currentUser = null; let accessToken = null; let deliveryDrugId = null; let deliveryLineCounter = 0; let deliveryLocations = []; let currentDispenseBatches = []; let currentDispenseLegacyQuantity = 0; let _gtinMappingPendingRefresh = false; let _gtinMappingPendingVariantId = null; let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload let _gtinMappingWaitingForNewDrug = null; // Set of drug IDs before add-drug; resolved on first loadDrugs let _highlightedBatchLot = null; // lot number to highlight after a main-screen scan let _highlightedVariantId = null; // variant the highlighted batch belongs to let _highlightClearTimer = null; // timer to auto-remove the highlight /** Build a human-readable pack label from pack fields, e.g. "Box of 28" */ function packLabel(packOrUnitName, packSize) { // Accept either (pack object) or (unit_name string, size number) let unitName, size; if (typeof packOrUnitName === 'object' && packOrUnitName !== null) { unitName = packOrUnitName.pack_unit_name; size = packOrUnitName.pack_size_in_base_units; } else { unitName = packOrUnitName; size = packSize; } const displaySize = size === Math.floor(size) ? Math.floor(size) : size; const unit = String(unitName || 'pack'); return `${unit.charAt(0).toUpperCase()}${unit.slice(1)} of ${displaySize}`; } function resetDisposeBatchModal() { const form = document.getElementById('disposeBatchForm'); if (form) { form.reset(); } const batchIdInput = document.getElementById('disposeBatchId'); const batchNameInput = document.getElementById('disposeBatchName'); if (batchIdInput) batchIdInput.value = ''; if (batchNameInput) batchNameInput.value = ''; } function closeDisposeBatchModal() { resetDisposeBatchModal(); const modal = document.getElementById('disposeBatchModal'); if (modal) { closeModal(modal); } } // Toast notification system function showToast(message, type = 'info', duration = 3000) { const container = document.getElementById('toastContainer'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; toast.innerHTML = ` ${icons[type] || icons.info} ${message} `; container.appendChild(toast); // Auto remove after duration setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => { container.removeChild(toast); }, 300); }, duration); } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { checkAuth(); }); // Check if user is already logged in function checkAuth() { const token = localStorage.getItem('accessToken'); const user = localStorage.getItem('currentUser'); if (token && user) { accessToken = token; currentUser = JSON.parse(user); showMainApp(); } else { showLoginPage(); } } // Show login page function showLoginPage() { document.getElementById('loginPage').style.display = 'flex'; document.getElementById('mainApp').style.display = 'none'; const loginForm = document.getElementById('loginForm'); if (loginForm) loginForm.addEventListener('submit', handleLogin); } // Show main app function showMainApp() { document.getElementById('loginPage').style.display = 'none'; document.getElementById('mainApp').style.display = 'block'; // Handle backward compatibility: convert old is_admin format to role if (!currentUser.role && currentUser.is_admin !== undefined) { currentUser.role = currentUser.is_admin ? 'admin' : 'user'; } // Default to 'user' if role is still undefined if (!currentUser.role) { currentUser.role = 'user'; } const userDisplay = document.getElementById('currentUser'); if (userDisplay) { const roleLabel = currentUser.role.charAt(0).toUpperCase() + currentUser.role.slice(1); userDisplay.textContent = `👤 ${currentUser.username} [${roleLabel}]`; } const adminBtn = document.getElementById('adminBtn'); if (adminBtn) { adminBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; } const locationsBtn = document.getElementById('locationsBtn'); if (locationsBtn) { locationsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; } const reportsBtn = document.getElementById('reportsBtn'); if (reportsBtn) { reportsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; } // Hide action buttons for read-only users const isReadOnly = currentUser.role === 'readonly'; const addDrugBtn = document.getElementById('addDrugBtn'); const dispenseBtn = document.getElementById('dispenseBtn'); const printNotesBtn = document.getElementById('printNotesBtn'); const receiveDeliveryBtn = document.getElementById('receiveDeliveryBtn'); if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block'; if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block'; if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block'; if (receiveDeliveryBtn) receiveDeliveryBtn.style.display = isReadOnly ? 'none' : 'block'; setupEventListeners(); loadDrugs(); } // Handle login async function handleLogin(e) { e.preventDefault(); const username = document.getElementById('loginUsername').value; const password = document.getElementById('loginPassword').value; try { const response = await fetch(`${API_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) { throw new Error('Invalid credentials'); } const data = await response.json(); accessToken = data.access_token; currentUser = data.user; localStorage.setItem('accessToken', accessToken); localStorage.setItem('currentUser', JSON.stringify(currentUser)); document.getElementById('loginForm').reset(); const errorDiv = document.getElementById('loginError'); if (errorDiv) errorDiv.style.display = 'none'; showMainApp(); } catch (error) { const errorDiv = document.getElementById('loginError'); if (errorDiv) { errorDiv.textContent = error.message; errorDiv.style.display = 'block'; } } } // Handle register // Logout function handleLogout() { localStorage.removeItem('accessToken'); localStorage.removeItem('currentUser'); accessToken = null; currentUser = null; const loginForm = document.getElementById('loginForm'); if (loginForm) loginForm.reset(); const registerForm = document.getElementById('registerForm'); if (registerForm) { registerForm.style.display = 'none'; } const form = document.getElementById('loginForm'); if (form) form.style.display = 'block'; showLoginPage(); } // API helper with authentication async function apiCall(endpoint, options = {}) { const headers = { 'Content-Type': 'application/json', ...options.headers }; if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } const response = await fetch(`${API_URL}${endpoint}`, { ...options, headers }); if (response.status === 401) { handleLogout(); throw new Error('Authentication expired'); } return response; } // Setup event listeners function setupEventListeners() { const drugForm = document.getElementById('drugForm'); const variantForm = document.getElementById('variantForm'); const editVariantForm = document.getElementById('editVariantForm'); const dispenseForm = document.getElementById('dispenseForm'); const editForm = document.getElementById('editForm'); const printNotesForm = document.getElementById('printNotesForm'); const disposeBatchForm = document.getElementById('disposeBatchForm'); const addModal = document.getElementById('addModal'); const addVariantModal = document.getElementById('addVariantModal'); const editVariantModal = document.getElementById('editVariantModal'); const dispenseModal = document.getElementById('dispenseModal'); const editModal = document.getElementById('editModal'); const printNotesModal = document.getElementById('printNotesModal'); const disposeBatchModal = document.getElementById('disposeBatchModal'); const batchReceiveModal = document.getElementById('batchReceiveModal'); const receiveDeliveryModal = document.getElementById('receiveDeliveryModal'); const addDrugBtn = document.getElementById('addDrugBtn'); const dispenseBtn = document.getElementById('dispenseBtn'); const printNotesBtn = document.getElementById('printNotesBtn'); const cancelAddBtn = document.getElementById('cancelAddBtn'); const cancelVariantBtn = document.getElementById('cancelVariantBtn'); const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn'); const cancelDispenseBtn = document.getElementById('cancelDispenseBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn'); const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn'); const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn'); const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn'); const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn'); const addVariantFromDeliveryBtn = document.getElementById('addVariantFromDeliveryBtn'); const addPackSizeFromDeliveryBtn = document.getElementById('addPackSizeFromDeliveryBtn'); const addVariantPackRowBtn = document.getElementById('addVariantPackRowBtn'); const addEditVariantPackRowBtn = document.getElementById('addEditVariantPackRowBtn'); const variantUnitSelect = document.getElementById('variantUnit'); const variantStrengthInput = document.getElementById('variantStrength'); const editVariantUnitSelect = document.getElementById('editVariantUnit'); const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]'); const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]'); const dispensePrintEnabled = document.getElementById('dispensePrintEnabled'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); const locationFilterSelect = document.getElementById('locationFilterSelect'); const userMenuBtn = document.getElementById('userMenuBtn'); const adminBtn = document.getElementById('adminBtn'); const locationsBtn = document.getElementById('locationsBtn'); const reportsBtn = document.getElementById('reportsBtn'); const logoutBtn = document.getElementById('logoutBtn'); const changePasswordBtn = document.getElementById('changePasswordBtn'); // Modal close buttons const closeButtons = document.querySelectorAll('.close'); if (drugForm) drugForm.addEventListener('submit', handleAddDrug); // Auto-capitalise the first letter typed in each Add Drug text field ['drugName', 'drugDescription'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('input', () => { if (el.value.length === 1) el.value = el.value.toUpperCase(); }); }); // Auto-capitalise the first letter typed in each Dispense text field ['dispenseAnimal', 'dispenseDosage', 'dispenseNotes'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('input', () => { if (el.value.length === 1) el.value = el.value.toUpperCase(); }); }); const vetInput = document.getElementById('dispenseVet'); if (vetInput) { vetInput.addEventListener('input', () => { const pos = vetInput.selectionStart; vetInput.value = toTitleCase(vetInput.value); vetInput.setSelectionRange(pos, pos); }); } if (variantForm) variantForm.addEventListener('submit', handleAddVariant); if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant); if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug); if (editForm) editForm.addEventListener('submit', handleEditDrug); if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes); if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch); const batchReceiveForm = document.getElementById('batchReceiveForm'); if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive); if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal)); const receiveDeliveryForm = document.getElementById('receiveDeliveryForm'); if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery); if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => { _detachDeliveryBarcodeListener(); closeModal(receiveDeliveryModal); }); if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine()); if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery); if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal); const addPackSizeForm = document.getElementById('addPackSizeForm'); const cancelAddPackSizeBtn = document.getElementById('cancelAddPackSizeBtn'); const addPackSizeModal = document.getElementById('addPackSizeModal'); if (addPackSizeForm) addPackSizeForm.addEventListener('submit', handleAddPackSize); if (cancelAddPackSizeBtn) cancelAddPackSizeBtn.addEventListener('click', () => closeModal(addPackSizeModal)); const addPackSizeVariantSelect = document.getElementById('addPackSizeVariantSelect'); if (addPackSizeVariantSelect) { addPackSizeVariantSelect.addEventListener('change', () => { const variantId = parseInt(addPackSizeVariantSelect.value || '', 10); const variant = getVariantById(variantId); const hint = document.getElementById('addPackSizeHint'); if (hint) hint.textContent = variant ? `Base unit: ${variant.unit}` : ''; }); } if (addVariantPackRowBtn) addVariantPackRowBtn.addEventListener('click', () => appendVariantPackRow()); if (addEditVariantPackRowBtn) addEditVariantPackRowBtn.addEventListener('click', () => appendEditVariantPackRow()); if (variantUnitSelect) { variantUnitSelect.addEventListener('change', () => { refreshVariantPackRowLabels(); }); } if (editVariantUnitSelect) { editVariantUnitSelect.addEventListener('change', () => { refreshEditVariantPackRowLabels(); }); } if (variantStrengthInput && variantUnitSelect) { variantStrengthInput.addEventListener('blur', () => { variantUnitSelect.value = inferBaseUnitFromStrength(variantStrengthInput.value); refreshVariantPackRowLabels(); }); } dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi)); dispenseSourceInputs.forEach(input => input.addEventListener('change', () => { renderDispenseInventorySourceView(); toggleDispensePrintFields(); updateDispenseAllocationSummary(); })); if (dispensePrintEnabled) { dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields); } if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (receiveDeliveryBtn) receiveDeliveryBtn.addEventListener('click', openReceiveDeliveryModal); if (dispenseBtn) dispenseBtn.addEventListener('click', () => { updateDispenseDrugSelect(); updateDispenseModeUi(); openModal(dispenseModal); }); if (cancelAddBtn) cancelAddBtn.addEventListener('click', () => closeModal(addModal)); if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal)); if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal)); if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal)); if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal); if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal); const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn'); if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal)); const closeHistoryBtn = document.getElementById('closeHistoryBtn'); if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal'))); const closeUserManagementBtn = document.getElementById('closeUserManagementBtn'); if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal'))); const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn'); if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal'))); const createLocationForm = document.getElementById('createLocationForm'); if (createLocationForm) createLocationForm.addEventListener('submit', createLocation); const changePasswordForm = document.getElementById('changePasswordForm'); if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword); const cancelChangePasswordBtn = document.getElementById('cancelChangePasswordBtn'); if (cancelChangePasswordBtn) cancelChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('changePasswordModal'))); const adminChangePasswordForm = document.getElementById('adminChangePasswordForm'); if (adminChangePasswordForm) adminChangePasswordForm.addEventListener('submit', handleAdminChangePassword); const cancelAdminChangePasswordBtn = document.getElementById('cancelAdminChangePasswordBtn'); if (cancelAdminChangePasswordBtn) cancelAdminChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('adminChangePasswordModal'))); const cancelGtinMappingBtn = document.getElementById('cancelGtinMappingBtn'); if (cancelGtinMappingBtn) cancelGtinMappingBtn.addEventListener('click', () => closeModal(document.getElementById('gtinMappingModal'))); closeButtons.forEach(btn => btn.addEventListener('click', (e) => { const modal = e.target.closest('.modal'); if (modal?.id === 'disposeBatchModal') { resetDisposeBatchModal(); } if (modal?.id === 'receiveDeliveryModal') { _detachDeliveryBarcodeListener(); } closeModal(modal); })); if (showAllBtn) showAllBtn.addEventListener('click', () => { showLowStockOnly = false; updateFilterButtons(); renderDrugs(); }); if (showLowStockBtn) showLowStockBtn.addEventListener('click', () => { showLowStockOnly = true; updateFilterButtons(); renderDrugs(); }); if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => { selectedLocationFilter = e.target.value; renderDrugs(); }); // User menu if (userMenuBtn) userMenuBtn.addEventListener('click', (e) => { e.stopPropagation(); const dropdown = document.getElementById('userDropdown'); 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 (adminBtn) adminBtn.addEventListener('click', openUserManagement); if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement); if (reportsBtn) reportsBtn.addEventListener('click', openReportsPage); if (logoutBtn) logoutBtn.addEventListener('click', handleLogout); // Search functionality const drugSearch = document.getElementById('drugSearch'); if (drugSearch) { let searchTimeout; drugSearch.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { searchTerm = e.target.value.toLowerCase().trim(); renderDrugs(); }, 150); }); } const dispenseQuantityInput = document.getElementById('dispenseQuantity'); if (dispenseQuantityInput) { dispenseQuantityInput.addEventListener('input', () => { const mode = getSelectedDispenseMode(); if (mode !== 'subunit') { return; } const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); const packPreview = document.getElementById('dispensePackPreview'); const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); const variant = getVariantById(variantId); if (packSelect) packSelect.value = ''; if (packCount) packCount.value = ''; if (packPreview && variant) { packPreview.textContent = `Enter direct quantity in ${variant.unit}.`; } autoAllocateDispenseBatches(); }); } // Close modal when clicking outside window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { if (e.target.id === 'disposeBatchModal') { resetDisposeBatchModal(); } closeModal(e.target); } }); // Main-screen barcode scan document.addEventListener('keydown', _onMainScreenKeydown); } // Load drugs from API async function loadDrugs() { try { const response = await apiCall('/drugs'); if (!response.ok) throw new Error('Failed to load drugs'); allDrugs = await response.json(); updateLocationFilterOptions(); renderDrugs(); updateDispenseDrugSelect(); if (_gtinMappingPendingRefresh) { _gtinMappingPendingRefresh = false; const restore = _gtinMappingPendingRestore || {}; _gtinMappingPendingRestore = null; _gtinMappingPendingVariantId = null; // Resolve new variant by diffing (add-variant flow) if (restore._existingVariantIds && restore.drugId) { const drug = allDrugs.find(d => d.id === restore.drugId); const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id)); if (newVariant) { restore.variantId = newVariant.id; // If no pack snapshot, all packs are new — pick the first active one from detail if (!restore._existingPackIds) { 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) — need packs from loaded detail if (restore._existingPackIds && restore.drugId && 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; } await _reinitGtinMappingModal(restore); } // After handleAddDrug's loadDrugs fires: find the newly created drug and set up // _gtinMappingPendingRefresh so that when handleAddVariant calls loadDrugs next, // we auto-select drug + new variant in the GTIN modal. if (_gtinMappingWaitingForNewDrug) { const newDrug = allDrugs.find(d => !_gtinMappingWaitingForNewDrug.has(d.id)); _gtinMappingWaitingForNewDrug = null; if (newDrug) { // handleAddDrug will now open addVariantModal — prepare to catch that save _gtinMappingPendingRestore = { drugId: newDrug.id, variantId: null, packId: null, _existingVariantIds: new Set((newDrug.variants || []).map(v => v.id)) }; _gtinMappingPendingRefresh = true; } } } catch (error) { console.error('Error loading drugs:', error); document.getElementById('drugsList').innerHTML = '

Error loading drugs. Make sure the backend is running.

'; } } // Modal utility functions function openModal(modal) { // Find the highest z-index among currently visible modals const visibleModals = document.querySelectorAll('.modal.show'); let maxZIndex = 1000; visibleModals.forEach(m => { const zIndex = parseInt(window.getComputedStyle(m).zIndex, 10) || 1000; if (zIndex > maxZIndex) { maxZIndex = zIndex; } }); // Set the new modal's z-index higher than any existing modal modal.style.zIndex = (maxZIndex + 100).toString(); modal.classList.add('show'); document.body.style.overflow = 'hidden'; } function closeModal(modal) { modal.classList.remove('show'); modal.style.zIndex = '1000'; document.body.style.overflow = 'auto'; } function closeEditModal() { closeModal(document.getElementById('editModal')); } function updateDispenseDrugSelect() { const select = document.getElementById('dispenseDrugSelect'); select.innerHTML = ''; allDrugs.forEach(drug => { drug.variants.forEach(variant => { const option = document.createElement('option'); option.value = variant.id; option.textContent = `${drug.name} ${variant.strength} (${variant.quantity} ${variant.unit})`; select.appendChild(option); }); }); const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); const packPreview = document.getElementById('dispensePackPreview'); const quantityModeRadio = document.getElementById('dispenseModeQuantity'); const batchSourceRadio = document.getElementById('dispenseSourceBatch'); const legacySourceRadio = document.getElementById('dispenseSourceLegacy'); if (packSelect) { packSelect.innerHTML = ''; } if (packCount) { packCount.value = ''; } if (quantityModeRadio) { quantityModeRadio.checked = true; } if (packPreview) { packPreview.textContent = 'Select a pack and whole-number count.'; } if (batchSourceRadio) { batchSourceRadio.checked = true; } if (legacySourceRadio) { legacySourceRadio.checked = false; } resetDispensePrintFields(); currentDispenseBatches = []; currentDispenseLegacyQuantity = 0; updateDispenseModeUi(); } function getSelectedDispenseMode() { return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit'; } function hasLegacyDispenseQuantity() { return currentDispenseLegacyQuantity > 0; } function hasBatchDispenseStock() { return currentDispenseBatches.length > 0; } function getSelectedDispenseSource() { if (getSelectedDispenseMode() === 'pack') { return 'batch'; } const selected = document.querySelector('input[name="dispenseSource"]:checked')?.value; if (selected) { return selected; } if (hasLegacyDispenseQuantity() && !hasBatchDispenseStock()) { return 'legacy'; } return 'batch'; } function isLegacyDispenseSelected() { return getSelectedDispenseMode() === 'subunit' && getSelectedDispenseSource() === 'legacy' && hasLegacyDispenseQuantity(); } function updateDispenseSourceUi() { const sourceGroup = document.getElementById('dispenseSourceGroup'); const sourceHelp = document.getElementById('dispenseSourceHelp'); const batchRadio = document.getElementById('dispenseSourceBatch'); const legacyRadio = document.getElementById('dispenseSourceLegacy'); const hasBatches = hasBatchDispenseStock(); const hasLegacy = hasLegacyDispenseQuantity(); if (!sourceGroup || !batchRadio || !legacyRadio) { return; } if (getSelectedDispenseMode() === 'pack' || (!hasBatches && !hasLegacy)) { sourceGroup.style.display = 'none'; batchRadio.checked = true; batchRadio.disabled = !hasBatches; legacyRadio.checked = false; legacyRadio.disabled = true; if (sourceHelp) sourceHelp.textContent = ''; return; } batchRadio.disabled = !hasBatches; legacyRadio.disabled = !hasLegacy; if (hasLegacy && !hasBatches) { legacyRadio.checked = true; } else if (!hasLegacy && hasBatches) { batchRadio.checked = true; } else if (!batchRadio.checked && !legacyRadio.checked) { batchRadio.checked = true; } sourceGroup.style.display = hasLegacy ? '' : 'none'; if (sourceHelp) { if (hasLegacy && hasBatches) { sourceHelp.textContent = `Batch stock available alongside ${formatDisplayNumber(currentDispenseLegacyQuantity)} loose legacy units.`; } else if (hasLegacy) { sourceHelp.textContent = `Legacy loose stock available: ${formatDisplayNumber(currentDispenseLegacyQuantity)}.`; } else { sourceHelp.textContent = ''; } } } function toggleDispensePrintFields() { const printEnabled = document.getElementById('dispensePrintEnabled'); const printFields = document.getElementById('dispensePrintFields'); const printHelpText = document.getElementById('dispensePrintHelpText'); const dosageInput = document.getElementById('dispenseDosage'); const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup'); const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry'); const isEnabled = Boolean(printEnabled?.checked); const legacyStockOnly = isLegacyDispenseSelected(); if (printFields) { printFields.style.display = isEnabled ? '' : 'none'; } if (printHelpText) { printHelpText.textContent = legacyStockOnly ? 'Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and a manually entered expiry date for this legacy stock.' : 'Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and the latest expiry date from the allocated batches.'; } if (dosageInput) { dosageInput.required = isEnabled; } if (legacyExpiryGroup) { legacyExpiryGroup.style.display = isEnabled && legacyStockOnly ? '' : 'none'; } if (legacyExpiryInput) { legacyExpiryInput.required = isEnabled && legacyStockOnly; if (!legacyStockOnly) { legacyExpiryInput.value = ''; } } } function resetDispensePrintFields() { const printEnabled = document.getElementById('dispensePrintEnabled'); const dosageInput = document.getElementById('dispenseDosage'); const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry'); if (printEnabled) { printEnabled.checked = false; } if (dosageInput) { dosageInput.value = ''; } if (legacyExpiryInput) { legacyExpiryInput.value = ''; } toggleDispensePrintFields(); } function formatLabelExpiryDate(expiryDate) { const expiryParts = expiryDate.split('-'); return `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`; } function getDrugContextForVariant(variantId) { for (const drug of allDrugs) { const variant = (drug.variants || []).find(item => item.id === variantId); if (variant) { return { drug, variant }; } } return { drug: null, variant: null }; } function getLatestAllocatedBatchExpiryDate(allocationEntries) { const allocatedBatches = allocationEntries .map(entry => currentDispenseBatches.find(batch => batch.id === entry.batch_id)) .filter(batch => batch?.expiry_date); if (allocatedBatches.length === 0) { return null; } return allocatedBatches .map(batch => batch.expiry_date) .sort((left, right) => new Date(right) - new Date(left))[0]; } async function requestLabelPrint({ animalName, drugName, variantStrength, quantity, unit, dosage, expiryDate }) { const labelData = { variables: { practice_name: 'Many Tears Animal Rescue', animal_name: animalName, drug_name: `${drugName} ${variantStrength}`, dosage, quantity: `${quantity} ${unit}`, expiry_date: formatLabelExpiryDate(expiryDate) } }; const labelResponse = await apiCall('/labels/print', { method: 'POST', body: JSON.stringify(labelData) }); if (!labelResponse.ok) { const error = await labelResponse.json(); throw new Error(error.detail || 'Label printing request failed'); } return labelResponse.json(); } function populateDispensePackSelect(variant) { const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); const packPreview = document.getElementById('dispensePackPreview'); if (!packSelect) return; const activePacks = getActivePacksForVariant(variant); packSelect.innerHTML = ''; activePacks.forEach(pack => { const option = document.createElement('option'); option.value = String(pack.id); option.textContent = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`; packSelect.appendChild(option); }); if (packCount) packCount.value = ''; // Auto-select the first available pack if (activePacks.length > 0) { packSelect.value = String(activePacks[0].id); } if (packPreview) { packPreview.textContent = activePacks.length > 0 ? `Select a pack and whole-number count (${variant.unit} base unit).` : `No active packs for this variant.`; } // Update preview to reflect the auto-selected pack if (activePacks.length > 0) { updateDispenseQuantityFromPack(); } } function updateDispenseModeUi() { const mode = getSelectedDispenseMode(); const quantityGroup = document.getElementById('dispenseQuantityGroup'); const packRow = document.getElementById('dispensePackRow'); const quantityInput = document.getElementById('dispenseQuantity'); const packSelect = document.getElementById('dispensePackSelect'); const packCount = document.getElementById('dispensePackCount'); if (quantityGroup) { quantityGroup.style.display = mode === 'subunit' ? '' : 'none'; } if (packRow) { packRow.style.display = mode === 'pack' ? '' : 'none'; } if (quantityInput) { quantityInput.required = mode === 'subunit'; } if (packSelect) { packSelect.required = mode === 'pack'; } if (packCount) { packCount.required = mode === 'pack'; } updateDispenseSourceUi(); renderDispenseInventorySourceView(); toggleDispensePrintFields(); } function updateDispenseQuantityFromPack() { const mode = getSelectedDispenseMode(); if (mode !== 'pack') return; const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10); const packCount = parseFloat(document.getElementById('dispensePackCount')?.value || ''); const quantityInput = document.getElementById('dispenseQuantity'); const preview = document.getElementById('dispensePackPreview'); const variant = getVariantById(variantId); if (!quantityInput || !preview || !variant) return; const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId); const totalAvailablePacks = selectedPack ? getTotalAvailableDispensePackCount(selectedPack) : 0; if (selectedPack && !Number.isNaN(packCount) && packCount > 0) { if (Math.abs(packCount - Math.round(packCount)) > 1e-6) { preview.textContent = 'Whole-pack mode requires a whole-number pack count.'; return; } if (totalAvailablePacks <= 0) { quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units); preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`; autoAllocateDispenseBatches(); return; } if (packCount > totalAvailablePacks) { quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units); preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`; autoAllocateDispenseBatches(); return; } const quantity = packCount * selectedPack.pack_size_in_base_units; quantityInput.value = String(quantity); preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`; autoAllocateDispenseBatches(); return; } preview.textContent = selectedPack ? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available | 1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}` : `Select a pack to calculate quantity.`; autoAllocateDispenseBatches(); } function formatDisplayDate(value) { if (!value) { return 'Unknown'; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } return parsed.toLocaleDateString(); } function formatDisplayNumber(value) { const numeric = Number(value); if (Number.isNaN(numeric)) return '0'; return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3))); } function isBatchExpired(batch) { if (!batch?.expiry_date) { return false; } const today = new Date(); today.setHours(0, 0, 0, 0); const expiryDate = new Date(`${batch.expiry_date}T00:00:00`); return expiryDate < today; } function renderVariantInventoryDetails(variant, batches) { const activePacks = getActivePacksForVariant(variant); const isReadOnly = currentUser?.role === 'readonly'; const sortedBatches = [...(batches || [])] .filter(batch => Number(batch.quantity) > 0) .sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); const packsHtml = activePacks.length > 0 ? activePacks.map(pack => `
${escapeHtml(packLabel(pack))} (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})
`).join('') : '
No active packs configured
'; 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; const stocktakeLabel = hasPackState ? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose` : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`; const isHighlighted = _highlightedBatchLot === batch.batch_number && _highlightedVariantId === variant.id; const batchCardStyles = isHighlighted ? 'padding: 8px; background: #fef9c3; border: 2px solid #f59e0b; border-radius: 5px; font-size: 0.9em;' : expired ? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;' : 'padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;'; const expiryStyles = expired ? 'color: #b91c1c; font-weight: 700;' : 'color: #4b5563;'; return `
${escapeHtml(batch.batch_number)} ${expired ? 'Expired' : ''}
Expires ${formatDisplayDate(batch.expiry_date)}
${escapeHtml(locationLabel)} | ${stocktakeLabel}
${expired && !isReadOnly ? `
` : ''}
`; }).join('') : '
No active batches
'; return `
Active Packs
${packsHtml}
Current Batches
${batchesHtml}
`; } function disposeBatch(batchId, batchNumber) { const modal = document.getElementById('disposeBatchModal'); const batchIdInput = document.getElementById('disposeBatchId'); const batchNameInput = document.getElementById('disposeBatchName'); const notesInput = document.getElementById('disposeBatchNotes'); if (!modal || !batchIdInput || !batchNameInput || !notesInput) { showToast('Dispose batch modal is unavailable.', 'error'); return; } batchIdInput.value = String(batchId); batchNameInput.value = batchNumber; notesInput.value = ''; openModal(modal); } async function handleDisposeBatch(e) { e.preventDefault(); const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10); const notes = document.getElementById('disposeBatchNotes')?.value.trim() || ''; const modal = document.getElementById('disposeBatchModal'); if (!batchId) { showToast('Batch disposal context is unavailable.', 'error'); return; } try { const response = await apiCall(`/batches/${batchId}/dispose`, { method: 'POST', body: JSON.stringify({ notes: notes || null }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to dispose batch'); } 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) { console.error('Error disposing batch:', error); showToast('Failed to dispose batch: ' + error.message, 'error'); } } function getBatchLocationLabel(batch) { return batch.location_name || batch.location?.name || `Location #${batch.location_id}`; } function updateLocationFilterOptions() { const locationFilterSelect = document.getElementById('locationFilterSelect'); if (!locationFilterSelect) return; const previousValue = selectedLocationFilter; const locations = new Set(); allDrugs.forEach(drug => { (drug.locations || []).forEach(loc => locations.add(loc)); }); locationFilterSelect.innerHTML = ''; Array.from(locations) .sort((a, b) => a.localeCompare(b)) .forEach(location => { const option = document.createElement('option'); option.value = location; option.textContent = location; locationFilterSelect.appendChild(option); }); if (previousValue && locations.has(previousValue)) { selectedLocationFilter = previousValue; locationFilterSelect.value = previousValue; } else { selectedLocationFilter = ''; locationFilterSelect.value = ''; } } function getDispenseRequestedQuantity() { const quantity = parseFloat(document.getElementById('dispenseQuantity')?.value || ''); return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity; } function getSelectedDispensePack() { const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10); const variant = getVariantById(variantId); if (!variant || Number.isNaN(packId)) { return null; } return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null; } function batchMatchesSelectedPack(batch, selectedPack) { if (!batch || !selectedPack) { return false; } if (Number(batch.received_pack_id) === Number(selectedPack.id)) { return true; } const batchPackLabel = String(batch.received_pack_unit_name || '').trim().toLowerCase(); const selectedPackLabel = String(selectedPack.pack_unit_name || '').trim().toLowerCase(); if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) { return true; } const batchPackSize = Number(batch.received_pack_size_snapshot || 0); const selectedPackSize = Number(selectedPack.pack_size_in_base_units || 0); if (batchPackSize > 0 && selectedPackSize > 0 && Math.abs(batchPackSize - selectedPackSize) <= 1e-6) { return true; } return false; } function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { if (mode !== 'pack') { return Number(batch.quantity || 0); } if (!batchMatchesSelectedPack(batch, selectedPack)) { return 0; } const fullPackCount = Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))); return fullPackCount * Number(selectedPack.pack_size_in_base_units || 0); } function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) { if (getSelectedDispenseSource() === 'legacy') { return mode === 'pack' ? 0 : currentDispenseLegacyQuantity; } return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0); } function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) { if (getSelectedDispenseSource() === 'legacy') { return 0; } if (!selectedPack) { return 0; } return currentDispenseBatches.reduce((sum, batch) => { if (!batchMatchesSelectedPack(batch, selectedPack)) { return sum; } return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))); }, 0); } function renderDispenseBatchAllocationRows(activeBatches) { const batchInfoContent = document.getElementById('batchInfoContent'); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); const variant = getVariantById(variantId); const unitLabel = variant?.unit || 'units'; if (!batchInfoContent) return; if (!activeBatches.length) { batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; return; } const mode = getSelectedDispenseMode(); const selectedPack = getSelectedDispensePack(); batchInfoContent.innerHTML = activeBatches.map((batch, index) => { const expiryDate = new Date(batch.expiry_date); const locationLabel = getBatchLocationLabel(batch); const expiryLabel = formatDisplayDate(batch.expiry_date); const today = new Date(); const isExpired = expiryDate < today; const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24)); let expiryStatus = 'OK'; let statusColor = '#4caf50'; if (isExpired) { expiryStatus = 'EXPIRED'; statusColor = '#d32f2f'; } else if (daysToExpiry <= 7) { expiryStatus = `${daysToExpiry}d left`; statusColor = '#ff9800'; } const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) : 0; const allocationLabel = mode === 'pack' ? 'Allocate Packs' : 'Allocate'; const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDispenseQuantity(batch, mode, selectedPack); const allocationStep = mode === 'pack' ? 1 : 0.1; const batchAvailabilityNote = mode === 'pack' ? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0 ? 'No full packs available in this batch' : '') : `Available to allocate: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`; return `
${escapeHtml(batch.batch_number)}${index === 0 ? ' FEFO' : ''}
Available: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} | Location: ${escapeHtml(locationLabel)} | Expiry: ${expiryLabel} (${expiryStatus})
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name ? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose` : ''} ${batchAvailabilityNote ? `
${batchAvailabilityNote}
` : ''}
`; }).join(''); batchInfoContent.querySelectorAll('.dispense-batch-allocation').forEach(input => { input.addEventListener('input', updateDispenseAllocationSummary); }); } function renderExpiredDispenseBatches(expiredBatches) { const expiredDetails = document.getElementById('expiredBatchDetails'); const expiredContent = document.getElementById('expiredBatchContent'); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); const variant = getVariantById(variantId); const unitLabel = variant?.unit || 'units'; if (!expiredDetails || !expiredContent) { return; } if (!expiredBatches.length) { expiredDetails.style.display = 'none'; expiredDetails.open = false; expiredContent.innerHTML = ''; return; } expiredDetails.style.display = 'block'; expiredContent.innerHTML = expiredBatches.map(batch => { const locationLabel = getBatchLocationLabel(batch); const expiryLabel = formatDisplayDate(batch.expiry_date); const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name ? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose` : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`; return `
${escapeHtml(batch.batch_number)} Expired ${expiryLabel}
Qty: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} | Location: ${escapeHtml(locationLabel)}
${stocktakeLabel}
`; }).join(''); } function renderDispenseInventorySourceView() { const batchInfoContent = document.getElementById('batchInfoContent'); const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10); const variant = getVariantById(variantId); if (!batchInfoContent || !variant) { return; } if (getSelectedDispenseMode() === 'pack') { if (hasBatchDispenseStock()) { renderDispenseBatchAllocationRows(currentDispenseBatches); autoAllocateDispenseBatches(); } else if (hasLegacyDispenseQuantity()) { batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack dispensing is unavailable.
`; updateDispenseAllocationSummary(); } else { batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; updateDispenseAllocationSummary(); } return; } if (isLegacyDispenseSelected()) { const extraText = hasBatchDispenseStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispense by quantity only.'; batchInfoContent.innerHTML = `
Legacy loose stock selected. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}
`; updateDispenseAllocationSummary(); return; } if (hasBatchDispenseStock()) { renderDispenseBatchAllocationRows(currentDispenseBatches); autoAllocateDispenseBatches(); return; } if (hasLegacyDispenseQuantity()) { batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispense by quantity only.
`; updateDispenseAllocationSummary(); return; } batchInfoContent.innerHTML = '

⚠️ No active batches available for this variant

'; updateDispenseAllocationSummary(); } // Update batch info display when variant is selected async function updateBatchInfo() { const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const batchInfoSection = document.getElementById('batchInfoSection'); const batchInfoContent = document.getElementById('batchInfoContent'); if (!variantId) { batchInfoSection.style.display = 'none'; const packSelect = document.getElementById('dispensePackSelect'); if (packSelect) packSelect.innerHTML = ''; currentDispenseBatches = []; currentDispenseLegacyQuantity = 0; renderExpiredDispenseBatches([]); updateDispenseSourceUi(); toggleDispensePrintFields(); updateDispenseAllocationSummary(); return; } const variant = getVariantById(variantId); if (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)); const vetLabel = document.getElementById('dispenseVetLabel'); const vetInputEl = document.getElementById('dispenseVet'); if (vetLabel && vetInputEl) { const isControlled = drugOfVariant ? drugOfVariant.is_controlled : false; vetLabel.textContent = isControlled ? 'Prescribing Vet *' : 'Prescribing Vet'; vetLabel.style.color = isControlled ? '#d32f2f' : ''; vetInputEl.placeholder = isControlled ? "Vet's name (required)" : "Vet's name"; } } else { const vetLabel = document.getElementById('dispenseVetLabel'); const vetInputEl = document.getElementById('dispenseVet'); if (vetLabel) { vetLabel.textContent = 'Prescribing Vet'; vetLabel.style.color = ''; } if (vetInputEl) vetInputEl.placeholder = "Vet's name"; } updateDispenseModeUi(); batchInfoSection.style.display = 'block'; batchInfoContent.innerHTML = '

Loading batches...

'; renderExpiredDispenseBatches([]); try { const response = await apiCall(`/variants/${variantId}/batches`); if (!response.ok) throw new Error('Failed to load batches'); const batches = await response.json(); const stockedBatches = batches.filter(b => b.quantity > 0); const expiredBatches = stockedBatches.filter(isBatchExpired); const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch)); const totalBatchQuantity = stockedBatches.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0); currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity); currentDispenseBatches = activeBatches; renderExpiredDispenseBatches(expiredBatches); if (!activeBatches.length && currentDispenseLegacyQuantity <= 0 && expiredBatches.length > 0) { updateDispenseSourceUi(); batchInfoContent.innerHTML = '

⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.

'; toggleDispensePrintFields(); updateDispenseAllocationSummary(); return; } // Sort by expiry date (FEFO order) activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); currentDispenseBatches = activeBatches; updateDispenseSourceUi(); renderDispenseInventorySourceView(); toggleDispensePrintFields(); } catch (error) { console.error('Error loading batches:', error); batchInfoContent.innerHTML = '

Error loading batches

'; currentDispenseBatches = []; currentDispenseLegacyQuantity = 0; renderExpiredDispenseBatches([]); updateDispenseSourceUi(); toggleDispensePrintFields(); updateDispenseAllocationSummary(); } } function autoAllocateDispenseBatches() { const requestedQuantity = getDispenseRequestedQuantity(); const allocationInputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); const mode = getSelectedDispenseMode(); const selectedPack = getSelectedDispensePack(); if (!allocationInputs.length) { updateDispenseAllocationSummary(); return; } if (isLegacyDispenseSelected()) { allocationInputs.forEach(input => { input.value = '0'; }); updateDispenseAllocationSummary(); return; } let remaining = mode === 'pack' ? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0) : requestedQuantity; allocationInputs.forEach(input => { const batchId = parseInt(input.dataset.batchId || '', 10); const batch = currentDispenseBatches.find(row => row.id === batchId); if (!batch || requestedQuantity <= 0) { input.value = '0'; return; } let allocation = 0; if (mode === 'pack' && selectedPack) { const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) : 0; allocation = Math.min(availableFullPacks, Math.max(remaining, 0)); input.value = allocation > 0 ? String(allocation) : '0'; } else { allocation = Math.min(getBatchAvailableDispenseQuantity(batch, mode, selectedPack), Math.max(remaining, 0)); input.value = allocation > 0 ? String(Number(allocation.toFixed(3))) : '0'; } remaining -= allocation; }); updateDispenseAllocationSummary(); } function updateDispenseAllocationSummary() { const summarySection = document.getElementById('batchAllocationSummary'); const summaryContent = document.getElementById('batchAllocationSummaryContent'); const requestedQuantity = getDispenseRequestedQuantity(); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10); const unitLabel = getVariantById(variantId)?.unit || 'units'; const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); const mode = getSelectedDispenseMode(); const selectedPack = getSelectedDispensePack(); const legacyStockOnly = isLegacyDispenseSelected(); const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack); const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; if (!summarySection || !summaryContent || !variantId || (!inputs.length && !legacyStockOnly)) { if (summarySection) summarySection.style.display = 'none'; return; } const allocated = inputs.reduce((sum, input) => { const value = parseFloat(input.value || '0'); return sum + (Number.isNaN(value) ? 0 : value); }, 0); const allocatedQuantity = mode === 'pack' && selectedPack ? allocated * selectedPack.pack_size_in_base_units : allocated; const invalidInput = inputs.find(input => { const batchId = parseInt(input.dataset.batchId || '', 10); const batch = currentDispenseBatches.find(row => row.id === batchId); const value = parseFloat(input.value || '0'); if (!batch || Number.isNaN(value)) { return false; } if (mode === 'pack' && selectedPack) { const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) : 0; return value - availableFullPacks > 1e-6 || Math.abs(value - Math.round(value)) > 1e-6; } const maxAllocation = getBatchAvailableDispenseQuantity(batch, mode, selectedPack); return value - maxAllocation > 1e-6; }); const difference = requestedQuantity - allocatedQuantity; summarySection.style.display = 'block'; if (requestedQuantity <= 0) { summaryContent.innerHTML = legacyStockOnly ? `Enter a dispense quantity. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.` : `Enter a dispense amount to allocate batches.`; return; } if (legacyStockOnly) { if (mode === 'pack') { summaryContent.innerHTML = `Whole-pack dispensing is unavailable for stock that is not attached to batches.`; return; } if (requestedQuantity - currentDispenseLegacyQuantity > 1e-6) { summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.`; return; } summaryContent.innerHTML = `Dispensing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.`; return; } if (mode === 'pack' && selectedPack) { const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0'); if (totalAvailablePacks <= 0) { summaryContent.innerHTML = `No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispense.`; return; } if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) { summaryContent.innerHTML = `Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.`; return; } } if (requestedQuantity - totalAvailableQuantity > 1e-6) { summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.`; return; } if (invalidInput) { summaryContent.innerHTML = `One or more batch allocations exceed available stock or are not valid full-pack amounts.`; return; } if (Math.abs(difference) <= 1e-6) { if (mode === 'pack' && selectedPack) { const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0'); summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).`; } else { summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.`; } return; } if (difference > 0) { if (mode === 'pack' && selectedPack) { const differencePacks = difference / selectedPack.pack_size_in_base_units; summaryContent.innerHTML = `Allocate ${formatDisplayNumber(differencePacks)} more pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.`; } else { summaryContent.innerHTML = `Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.`; } return; } if (mode === 'pack' && selectedPack) { const differencePacks = Math.abs(difference) / selectedPack.pack_size_in_base_units; summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(differencePacks)} pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.`; return; } summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.`; } // Render drugs list function renderDrugs() { const drugsList = document.getElementById('drugsList'); let drugsToShow = allDrugs; // Apply search filter if (searchTerm) { drugsToShow = drugsToShow.filter(drug => drug.name.toLowerCase().includes(searchTerm) || (drug.description && drug.description.toLowerCase().includes(searchTerm)) || drug.variants.some(variant => variant.strength.toLowerCase().includes(searchTerm)) ); } // Apply stock filter if (showLowStockOnly) { drugsToShow = drugsToShow.filter(drug => drug.variants.some(variant => variant.quantity <= variant.low_stock_threshold) ); } // Apply location filter using the pre-computed locations list in the summary if (selectedLocationFilter) { drugsToShow = drugsToShow.filter(drug => (drug.locations || []).includes(selectedLocationFilter) ); } // Sort alphabetically by drug name drugsToShow = drugsToShow.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) ); if (drugsToShow.length === 0) { drugsList.innerHTML = '

No drugs found matching your criteria

'; return; } drugsList.innerHTML = drugsToShow.map(drug => { const totalVariants = drug.variants.length; const lowStockVariants = drug.variants.filter(v => v.quantity <= v.low_stock_threshold).length; const totalQuantity = drug.variants.reduce((sum, v) => sum + v.quantity, 0); const isLowStock = lowStockVariants > 0; const isExpanded = expandedDrugs.has(drug.id); const isReadOnly = currentUser.role === 'readonly'; const isControlled = drug.is_controlled; const drugDetail = loadedDrugDetails.get(drug.id); let variantsHtml = ''; if (isExpanded) { if (!drugDetail) { variantsHtml = '
Loading variants…
'; } 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 '
Loading batches…
'; return renderVariantInventoryDetails(variant, batches); })(); return `
${escapeHtml(drug.name)} ${escapeHtml(summaryVariant.strength)}
${quantityDisplay}
${variantIsLowStock ? 'Low Stock' : 'OK'} Inventory ${variantExpanded ? '▼' : '▶'}
${!isReadOnly ? ` ` : ''}
${batchesSection}
`; }).join(''); } } return `
${escapeHtml(drug.name)} ${isControlled ? '⚠️ CONTROLLED' : ''}
${drug.description ? escapeHtml(drug.description) : 'No description'}
${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)
${isLowStock ? `${lowStockVariants} low` : 'All OK'}
${!isReadOnly ? ` ` : ''} ${!isReadOnly ? ` ` : ''} ${isExpanded ? '▼' : '▶'}
${variantsHtml}
`; }).join(''); } // Handle add drug form async function handleAddDrug(e) { e.preventDefault(); const drugData = { name: document.getElementById('drugName').value, description: document.getElementById('drugDescription').value, is_controlled: document.getElementById('drugIsControlled').checked }; try { const drugResponse = await apiCall('/drugs', { method: 'POST', body: JSON.stringify(drugData) }); if (!drugResponse.ok) throw new Error('Failed to add drug'); const createdDrug = await drugResponse.json(); document.getElementById('drugForm').reset(); expandedDrugs.add(createdDrug.id); await loadDrugs(); closeModal(document.getElementById('addModal')); openAddVariantModal(createdDrug.id); showToast('Drug added successfully. Add the first variant next.', 'success'); } catch (error) { console.error('Error adding drug:', error); showToast('Failed to add drug. Check the console for details.', 'error'); } } // Handle dispense drug form async function handleDispenseDrug(e) { e.preventDefault(); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); let quantity = parseFloat(document.getElementById('dispenseQuantity').value); const dispenseMode = getSelectedDispenseMode(); const dispenseSource = getSelectedDispenseSource(); const requestedPackIdValue = document.getElementById('dispensePackSelect').value; const requestedPackCountValue = document.getElementById('dispensePackCount').value; const animalName = document.getElementById('dispenseAnimal').value; const vetName = document.getElementById('dispenseVet')?.value.trim() || ''; const notes = document.getElementById('dispenseNotes').value; const printEnabled = document.getElementById('dispensePrintEnabled')?.checked; const dosage = document.getElementById('dispenseDosage')?.value.trim() || ''; const legacyExpiryDate = document.getElementById('dispenseLegacyExpiry')?.value || ''; const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null; const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null; const variant = getVariantById(variantId); const drugForVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId)); const legacyStockOnly = isLegacyDispenseSelected(); const selectedPack = variant && selectedPackId ? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId) : null; if (!['subunit', 'pack'].includes(dispenseMode)) { showToast('Please select a valid dispense mode.', 'warning'); return; } if (dispenseMode === 'pack') { if (legacyStockOnly) { showToast('Whole-pack dispensing is unavailable for stock that is not attached to batches.', 'warning'); return; } if (!selectedPack) { showToast('Please select a pack type for whole-pack dispensing.', 'warning'); return; } if (selectedPackCount == null || Number.isNaN(selectedPackCount) || selectedPackCount <= 0) { showToast('Please enter a valid pack count greater than zero.', 'warning'); return; } if (Math.abs(selectedPackCount - Math.round(selectedPackCount)) > 1e-6) { showToast('Whole-pack dispensing requires a whole-number pack count.', 'warning'); return; } quantity = selectedPackCount * selectedPack.pack_size_in_base_units; } const allocationEntries = Array.from(document.querySelectorAll('.dispense-batch-allocation')) .map(input => ({ batch_id: parseInt(input.dataset.batchId || '', 10), entered_value: parseFloat(input.value || '0') })) .filter(entry => !Number.isNaN(entry.batch_id) && !Number.isNaN(entry.entered_value) && entry.entered_value > 0); const allocations = allocationEntries .map(entry => ({ batch_id: entry.batch_id, quantity: dispenseMode === 'pack' && selectedPack ? entry.entered_value * selectedPack.pack_size_in_base_units : entry.entered_value })); const allocatedTotal = allocations.reduce((sum, entry) => sum + entry.quantity, 0); const totalAvailableQuantity = getTotalAvailableDispenseQuantity(dispenseMode, selectedPack); const totalAvailablePacks = dispenseMode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; if (!variantId || isNaN(quantity) || quantity <= 0) { showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning'); return; } if (quantity - totalAvailableQuantity > 1e-6) { if (dispenseMode === 'pack' && selectedPack) { showToast(`Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`, 'warning'); } else { showToast(`Requested quantity exceeds available stock (${formatDisplayNumber(totalAvailableQuantity)} available).`, 'warning'); } return; } if (!legacyStockOnly && allocations.length === 0) { showToast('Allocate quantity against at least one batch.', 'warning'); return; } if (dispenseMode === 'pack' && selectedPack) { const invalidPackAllocation = allocationEntries.find(entry => { const batch = currentDispenseBatches.find(row => row.id === entry.batch_id); const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack) ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) : 0; return !batch || entry.entered_value - availableFullPacks > 1e-6 || Math.abs(entry.entered_value - Math.round(entry.entered_value)) > 1e-6; }); if (invalidPackAllocation) { showToast('Whole-pack allocations must use batches with available full packs and whole-pack multiples only.', 'warning'); return; } } if (!legacyStockOnly && Math.abs(allocatedTotal - quantity) > 1e-6) { showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning'); return; } const printExpiryDate = printEnabled ? (legacyStockOnly ? legacyExpiryDate : getLatestAllocatedBatchExpiryDate(allocationEntries)) : null; if (printEnabled && (!animalName.trim() || !dosage)) { showToast('Animal name/ID and dosage instructions are required to print a label.', 'warning'); return; } if (printEnabled && !printExpiryDate) { showToast(legacyStockOnly ? 'Enter an expiry date to print a label for legacy stock.' : 'Unable to determine a batch expiry date for the selected allocation.', 'warning'); return; } const dispensingData = { drug_variant_id: variantId, quantity: quantity, dispense_mode: dispenseMode, requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null, requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null, dispense_source: dispenseSource, animal_name: animalName || null, prescribing_vet: vetName || null, notes: notes || null, allocations }; if (drugForVariant && drugForVariant.is_controlled && !vetName) { showToast('Prescribing vet name is required for controlled drugs.', 'warning'); document.getElementById('dispenseVet')?.focus(); return; } try { const response = await apiCall('/dispense', { method: 'POST', body: JSON.stringify(dispensingData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to dispense drug'); } let successMessage = 'Drug dispensed successfully!'; let toastType = 'success'; if (printEnabled) { try { const { drug } = getDrugContextForVariant(variantId); const labelResult = await requestLabelPrint({ animalName: animalName.trim(), drugName: drug?.name || 'Unknown drug', variantStrength: variant?.strength || '', quantity, unit: variant?.unit || 'units', dosage, expiryDate: printExpiryDate }); if (!labelResult.success) { successMessage = `Drug dispensed, but label printing failed: ${labelResult.message}`; toastType = 'warning'; } else { successMessage = 'Drug dispensed and label printed successfully!'; } } catch (printError) { console.error('Error printing label after dispensing:', printError); successMessage = 'Drug dispensed, but label printing failed: ' + printError.message; toastType = 'warning'; } } 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) { console.error('Error dispensing drug:', error); showToast('Failed to dispense drug: ' + error.message, 'error'); } } // Open edit modal function openEditModal(drugId) { const drug = allDrugs.find(d => d.id === drugId); if (!drug) return; document.getElementById('editDrugId').value = drug.id; document.getElementById('editDrugName').value = drug.name; document.getElementById('editDrugDescription').value = drug.description || ''; document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false; openModal(document.getElementById('editModal')); } // Close edit modal function closeEditModal() { closeModal(document.getElementById('editModal')); document.getElementById('editForm').reset(); } // Show variants for a drug 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(); } } 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) { const drug = allDrugs.find(d => d.id === drugId); if (!drug) return; currentDrug = drug; const form = document.getElementById('variantForm'); if (form) form.reset(); document.getElementById('variantDrugId').value = drug.id; initializeVariantPackRows(); openModal(document.getElementById('addVariantModal')); } function inferBaseUnitFromStrength(strength) { const value = String(strength || '').toLowerCase(); if (value.includes('/ml') || value.includes('ml')) return 'ml'; if (value.includes('tablet')) return 'tablets'; if (value.includes('capsule')) return 'capsules'; return 'tablets'; } function getVariantPackRowsContainer() { return document.getElementById('variantPackRows'); } function getEditVariantPackRowsContainer() { return document.getElementById('editVariantPackRows'); } function refreshVariantPackRowLabels() { const container = getVariantPackRowsContainer(); const baseUnit = document.getElementById('variantUnit')?.value || 'units'; if (!container) return; container.querySelectorAll('.variant-pack-row').forEach(row => { const packUnit = row.querySelector('.variant-pack-unit')?.value || 'pack'; const label = row.querySelector('.variant-pack-size-label'); if (!label) return; const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1); label.textContent = `${titleCasePack} Size (${baseUnit}) *`; }); } function refreshEditVariantPackRowLabels() { const container = getEditVariantPackRowsContainer(); const baseUnit = document.getElementById('editVariantUnit')?.value || 'units'; if (!container) return; container.querySelectorAll('.edit-variant-pack-row').forEach(row => { const packUnit = row.querySelector('.edit-variant-pack-unit')?.value || 'pack'; const label = row.querySelector('.edit-variant-pack-size-label'); if (!label) return; const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1); label.textContent = `${titleCasePack} Size (${baseUnit}) *`; }); } function appendVariantPackRow(prefill = {}) { const container = getVariantPackRowsContainer(); if (!container) return; const row = document.createElement('div'); row.className = 'delivery-line variant-pack-row'; const selectedPackUnit = prefill.packUnit || 'box'; const selectedSize = prefill.packSize || ''; const baseUnit = document.getElementById('variantUnit')?.value || 'units'; row.innerHTML = `
`; const removeBtn = row.querySelector('.variant-pack-remove-btn'); const unitSelect = row.querySelector('.variant-pack-unit'); if (removeBtn) { removeBtn.addEventListener('click', () => { if (container.querySelectorAll('.variant-pack-row').length <= 1) { showToast('At least one pack size is required', 'warning'); return; } row.remove(); }); } if (unitSelect) { unitSelect.addEventListener('change', refreshVariantPackRowLabels); } container.appendChild(row); refreshVariantPackRowLabels(); } function initializeVariantPackRows() { const container = getVariantPackRowsContainer(); if (!container) return; container.innerHTML = ''; const strengthValue = document.getElementById('variantStrength')?.value || ''; const inferredBaseUnit = inferBaseUnitFromStrength(strengthValue); const variantUnitSelect = document.getElementById('variantUnit'); if (variantUnitSelect) { variantUnitSelect.value = inferredBaseUnit; } appendVariantPackRow({ packUnit: 'box' }); } function appendEditVariantPackRow(prefill = {}) { const container = getEditVariantPackRowsContainer(); if (!container) return; const row = document.createElement('div'); row.className = 'delivery-line edit-variant-pack-row'; const selectedPackUnit = prefill.packUnit || 'bottle'; const selectedSize = prefill.packSize || ''; const baseUnit = document.getElementById('editVariantUnit')?.value || 'units'; row.innerHTML = `
`; const removeBtn = row.querySelector('.edit-variant-pack-remove-btn'); const unitSelect = row.querySelector('.edit-variant-pack-unit'); if (removeBtn) { removeBtn.addEventListener('click', () => { row.remove(); }); } if (unitSelect) { unitSelect.addEventListener('change', refreshEditVariantPackRowLabels); } container.appendChild(row); refreshEditVariantPackRowLabels(); } function initializeEditVariantPackRows() { const container = getEditVariantPackRowsContainer(); if (!container) return; container.innerHTML = ''; appendEditVariantPackRow({ packUnit: 'bottle' }); } function setEditVariantFieldLockState(isLocked) { const strengthInput = document.getElementById('editVariantStrength'); const quantityInput = document.getElementById('editVariantQuantity'); const unitSelect = document.getElementById('editVariantUnit'); const lockNotice = document.getElementById('editVariantLockNotice'); if (strengthInput) strengthInput.disabled = isLocked; if (quantityInput) quantityInput.disabled = isLocked; if (unitSelect) unitSelect.disabled = isLocked; if (lockNotice) lockNotice.style.display = isLocked ? 'block' : 'none'; } // Handle add variant form async function handleAddVariant(e) { e.preventDefault(); const drugId = parseInt(document.getElementById('variantDrugId').value); const baseUnit = document.getElementById('variantUnit').value; const rows = Array.from(document.querySelectorAll('#variantPackRows .variant-pack-row')); if (rows.length === 0) { showToast('Please add at least one pack size', 'warning'); return; } const packPayloads = []; for (let i = 0; i < rows.length; i += 1) { const row = rows[i]; const packUnit = row.querySelector('.variant-pack-unit')?.value; const packSize = parseFloat(row.querySelector('.variant-pack-size')?.value || ''); if (!packUnit || Number.isNaN(packSize) || packSize <= 0) { showToast(`Pack row ${i + 1} is incomplete`, 'warning'); return; } const normalizedPackUnit = packUnit.trim().toLowerCase(); const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1); packPayloads.push({ label: `${titleCasePack} ${packSize} ${baseUnit}`, pack_unit_name: normalizedPackUnit, pack_size_in_base_units: packSize, is_active: true }); } const variantData = { strength: document.getElementById('variantStrength').value, quantity: 0, unit: baseUnit, base_unit: baseUnit, low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value) }; try { const response = await apiCall(`/drugs/${drugId}/variants`, { method: 'POST', body: JSON.stringify(variantData) }); if (!response.ok) throw new Error('Failed to add variant'); const createdVariant = await response.json(); for (const packPayload of packPayloads) { const packResponse = await apiCall(`/variants/${createdVariant.id}/packs`, { method: 'POST', body: JSON.stringify(packPayload) }); if (!packResponse.ok) { const packError = await packResponse.json(); throw new Error(packError.detail || 'Variant created but pack size creation failed'); } } // Archive the auto-created default 1:1 pack when custom pack sizes are configured. const packsResponse = await apiCall(`/variants/${createdVariant.id}/packs`); if (packsResponse.ok) { const packs = await packsResponse.json(); const defaultPack = packs.find( p => p.is_active && Number(p.pack_size_in_base_units) === 1 && (p.pack_unit_name || '').toLowerCase() === baseUnit.toLowerCase() ); if (defaultPack && packs.filter(p => p.is_active).length > 1) { await apiCall(`/variant-packs/${defaultPack.id}`, { method: 'PUT', body: JSON.stringify({ is_active: false }) }); } } 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(); } renderDrugs(); showToast('Variant added successfully!', 'success'); } catch (error) { console.error('Error adding variant:', error); showToast('Failed to add variant. Check the console for details.', 'error'); } } // Open edit variant modal function openEditVariantModal(variantId) { // Find the variant from all drugs let variant = null; for (const drug of allDrugs) { variant = drug.variants.find(v => v.id === variantId); if (variant) break; } if (!variant) return; document.getElementById('editVariantId').value = variant.id; document.getElementById('editVariantStrength').value = variant.strength; document.getElementById('editVariantQuantity').value = variant.quantity; document.getElementById('editVariantUnit').value = variant.unit; document.getElementById('editVariantThreshold').value = variant.low_stock_threshold; const hasInventoryContext = Boolean(variant.has_inventory_history); setEditVariantFieldLockState(hasInventoryContext); initializeEditVariantPackRows(); openModal(document.getElementById('editVariantModal')); } // Handle edit variant form async function handleEditVariant(e) { e.preventDefault(); const variantId = parseInt(document.getElementById('editVariantId').value); const strengthInput = document.getElementById('editVariantStrength'); const quantityInput = document.getElementById('editVariantQuantity'); const unitSelect = document.getElementById('editVariantUnit'); const baseUnit = unitSelect.value; const variantData = { low_stock_threshold: parseFloat(document.getElementById('editVariantThreshold').value) }; if (!strengthInput.disabled && !quantityInput.disabled && !unitSelect.disabled) { const quantityValue = parseFloat(quantityInput.value); if (Number.isNaN(quantityValue) || quantityValue < 0) { showToast('Please enter a valid quantity (0 or greater)', 'warning'); return; } variantData.strength = strengthInput.value; variantData.quantity = quantityValue; variantData.unit = baseUnit; variantData.base_unit = baseUnit; } const packRows = Array.from(document.querySelectorAll('#editVariantPackRows .edit-variant-pack-row')); const newPackPayloads = []; for (let i = 0; i < packRows.length; i += 1) { const row = packRows[i]; const packUnitRaw = row.querySelector('.edit-variant-pack-unit')?.value || ''; const packSizeRaw = row.querySelector('.edit-variant-pack-size')?.value || ''; if (!packUnitRaw && !packSizeRaw) { continue; } const packSize = parseFloat(packSizeRaw); if (!packUnitRaw || Number.isNaN(packSize) || packSize <= 0) { showToast(`Pack row ${i + 1} is incomplete`, 'warning'); return; } const normalizedPackUnit = packUnitRaw.trim().toLowerCase(); const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1); newPackPayloads.push({ label: `${titleCasePack} ${packSize} ${baseUnit}`, pack_unit_name: normalizedPackUnit, pack_size_in_base_units: packSize, is_active: true }); } try { const response = await apiCall(`/variants/${variantId}`, { method: 'PUT', body: JSON.stringify(variantData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to update variant'); } for (const packPayload of newPackPayloads) { const packResponse = await apiCall(`/variants/${variantId}/packs`, { method: 'POST', body: JSON.stringify(packPayload) }); if (!packResponse.ok) { const packError = await packResponse.json(); throw new Error(packError.detail || 'Variant updated but failed to add one or more pack sizes'); } } 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 ? `Variant updated and ${newPackPayloads.length} pack size${newPackPayloads.length === 1 ? '' : 's'} added` : 'Variant updated successfully!'; showToast(message, 'success'); } catch (error) { console.error('Error updating variant:', error); showToast('Failed to update variant: ' + error.message, 'error'); } } // Dispense from variant function dispenseVariant(variantId) { // Update the dropdown display with all variants updateDispenseDrugSelect(); // Pre-select the variant in the dispense modal const drugSelect = document.getElementById('dispenseDrugSelect'); drugSelect.value = variantId; // Update batch info for selected variant updateBatchInfo(); // Open dispense modal openModal(document.getElementById('dispenseModal')); } // Handle print notes form submission async function handlePrintNotes(e) { e.preventDefault(); const animalName = document.getElementById('notesAnimalName').value.trim(); const notes = document.getElementById('notesContent').value.trim(); if (!animalName || !notes) { showToast('Please fill in all required fields', 'warning'); return; } try { // Send notes to print endpoint const notesData = { variables: { animal_name: animalName, notes: notes } }; const response = await apiCall('/notes/print', { method: 'POST', body: JSON.stringify(notesData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Notes printing request failed'); } const result = await response.json(); console.log('Notes print result:', result); if (!result.success) { // Printing failed const isError = result.message && ( result.message.includes('not found') || result.message.includes('error') || result.message.includes('failed') ); const toastType = isError ? 'error' : 'warning'; showToast(result.message, toastType, 5000); return; } // Printing succeeded showToast('Notes printed successfully!', 'success'); document.getElementById('printNotesForm').reset(); closeModal(document.getElementById('printNotesModal')); } catch (error) { console.error('Error printing notes:', error); showToast('Failed to print notes: ' + error.message, 'error'); } } // Delete variant async function deleteVariant(variantId) { const variant = getVariantById(variantId); if (variant && variant.has_inventory_history) { showToast('Cannot delete variant with batch or dispensing history', 'warning'); return; } if (!confirm('Are you sure you want to delete this variant?')) return; try { const response = await apiCall(`/variants/${variantId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to delete variant'); } await loadDrugs(); renderDrugs(); showToast('Variant deleted successfully!', 'success'); } catch (error) { console.error('Error deleting variant:', error); showToast('Failed to delete variant: ' + error.message, 'error'); } } // Show dispensing history for a drug async function showDrugHistory(drugId) { const drug = allDrugs.find(d => d.id === drugId); if (!drug) return; const historyModal = document.getElementById('historyModal'); const historyContent = document.getElementById('historyContent'); document.getElementById('historyDrugName').textContent = drug.name; historyContent.innerHTML = '

Loading history...

'; openModal(historyModal); try { const response = await apiCall(`/dispense/history`); if (!response.ok) throw new Error('Failed to fetch history'); const allHistory = await response.json(); // Filter history for this drug's variants const variantIds = drug.variants.map(v => v.id); const drugHistory = allHistory.filter(item => variantIds.includes(item.drug_variant_id)); if (drugHistory.length === 0) { historyContent.innerHTML = '

No dispensing history for this drug.

'; return; } // Sort by dispensed_at descending (most recent first) drugHistory.sort((a, b) => new Date(b.dispensed_at) - new Date(a.dispensed_at)); const historyHtml = drugHistory.map(item => { const variant = drug.variants.find(v => v.id === item.drug_variant_id); const date = new Date(item.dispensed_at).toLocaleDateString(); const time = new Date(item.dispensed_at).toLocaleTimeString(); return `
${drug.name} ${variant.strength}
${date} ${time}
Quantity: ${item.quantity} ${variant.unit}
Animal: ${escapeHtml(item.animal_name)}
User: ${escapeHtml(item.user_name)}
${item.prescribing_vet ? `
Prescribing Vet: ${escapeHtml(item.prescribing_vet)}
` : ''} ${item.notes ? `
Notes: ${escapeHtml(item.notes)}
` : ''}
`; }).join(''); historyContent.innerHTML = historyHtml; } catch (error) { console.error('Error fetching history:', error); historyContent.innerHTML = '

Failed to load history. Check the console for details.

'; } } // Handle edit drug form async function handleEditDrug(e) { e.preventDefault(); const drugId = parseInt(document.getElementById('editDrugId').value); const drugData = { name: document.getElementById('editDrugName').value, description: document.getElementById('editDrugDescription').value, is_controlled: document.getElementById('editDrugIsControlled').checked }; try { const response = await apiCall(`/drugs/${drugId}`, { method: 'PUT', body: JSON.stringify(drugData) }); 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) { console.error('Error updating drug:', error); showToast('Failed to update drug. Check the console for details.', 'error'); } } // Delete drug async function deleteDrug(drugId) { const drug = allDrugs.find(d => d.id === drugId); if (drug && drug.variants.some(v => v.has_inventory_history)) { showToast('Cannot delete drug with variants that have batch or dispensing history', 'warning'); return; } if (!confirm('Are you sure you want to delete this drug?')) return; try { const response = await apiCall(`/drugs/${drugId}`, { method: 'DELETE' }); if (!response.ok) { const error = await response.json(); 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) { console.error('Error deleting drug:', error); showToast('Failed to delete drug: ' + error.message, 'error'); } } // Password Management function openChangePasswordModal() { const modal = document.getElementById('changePasswordModal'); document.getElementById('changePasswordForm').reset(); // Close dropdown const dropdown = document.getElementById('userDropdown'); if (dropdown) dropdown.style.display = 'none'; openModal(modal); } async function handleChangePassword(e) { e.preventDefault(); const currentPassword = document.getElementById('currentPassword').value; const newPassword = document.getElementById('newPassword').value; const confirmPassword = document.getElementById('confirmNewPassword').value; if (newPassword !== confirmPassword) { showToast('New passwords do not match!', 'warning'); return; } if (newPassword.length < 1) { showToast('New password cannot be empty!', 'warning'); return; } try { const response = await apiCall('/auth/change-password', { method: 'POST', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to change password'); } showToast('Password changed successfully!', 'success'); closeModal(document.getElementById('changePasswordModal')); } catch (error) { console.error('Error changing password:', error); showToast('Failed to change password: ' + error.message, 'error'); } } async function openAdminChangePasswordModal(userId, username) { const modal = document.getElementById('adminChangePasswordModal'); document.getElementById('adminChangePasswordForm').reset(); document.getElementById('adminChangePasswordUserId').value = userId; document.getElementById('adminChangePasswordUsername').value = username; openModal(modal); } async function handleAdminChangePassword(e) { e.preventDefault(); const userId = document.getElementById('adminChangePasswordUserId').value; const newPassword = document.getElementById('adminChangePasswordNewPassword').value; const confirmPassword = document.getElementById('adminChangePasswordConfirm').value; if (newPassword !== confirmPassword) { showToast('Passwords do not match!', 'warning'); return; } if (newPassword.length < 1) { showToast('Password cannot be empty!', 'warning'); return; } try { const response = await apiCall(`/users/${userId}/change-password`, { method: 'POST', body: JSON.stringify({ new_password: newPassword }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to change password'); } showToast('Password changed successfully!', 'success'); closeModal(document.getElementById('adminChangePasswordModal')); openUserManagement(); } catch (error) { console.error('Error changing password:', error); showToast('Failed to change password: ' + error.message, 'error'); } } // Update filter button states function updateFilterButtons() { document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly); document.getElementById('showLowStockBtn').classList.toggle('active', showLowStockOnly); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function toTitleCase(str) { return str.replace(/\S+/g, word => word.charAt(0).toUpperCase() + word.slice(1)); } async function openReportsPage() { const dropdown = document.getElementById('userDropdown'); if (dropdown) dropdown.style.display = 'none'; window.location.href = 'reports.html'; } // User Management async function openUserManagement() { const modal = document.getElementById('userManagementModal'); document.getElementById('newUsername').value = ''; document.getElementById('newUserPassword').value = ''; document.getElementById('newUserRole').value = ''; const usersList = document.getElementById('usersList'); usersList.innerHTML = '

Users

Loading users...

'; try { const response = await apiCall('/users'); if (!response.ok) throw new Error('Failed to load users'); const users = await response.json(); const usersHtml = `

Users

${users.map(user => { const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1); const roleBadge = user.role === 'admin' ? '👑 Admin' : user.role === 'readonly' ? '👁️ Read-Only' : '👤 Regular'; return `
${user.username} ${roleBadge} ${user.id !== currentUser.id ? ` ` : ''}
`; }).join('')}
`; usersList.innerHTML = usersHtml; } catch (error) { console.error('Error loading users:', error); usersList.innerHTML = '

Users

Error loading users

'; } const createUserForm = document.getElementById('createUserForm'); if (createUserForm) { createUserForm.onsubmit = createUser; } openModal(modal); } // Create user async function createUser(e) { e.preventDefault(); const username = document.getElementById('newUsername').value; const password = document.getElementById('newUserPassword').value; const role = document.getElementById('newUserRole').value; if (!role) { showToast('Please select a role', 'warning'); return; } try { const response = await apiCall('/users', { method: 'POST', body: JSON.stringify({ username, password, role }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to create user'); } document.getElementById('newUsername').value = ''; document.getElementById('newUserPassword').value = ''; document.getElementById('newUserRole').value = ''; showToast('User created successfully!', 'success'); openUserManagement(); } catch (error) { console.error('Error creating user:', error); showToast('Failed to create user: ' + error.message, 'error'); } } // Delete user async function deleteUser(userId) { if (!confirm('Are you sure you want to delete this user?')) return; try { const response = await apiCall(`/users/${userId}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete user'); showToast('User deleted successfully!', 'success'); openUserManagement(); } catch (error) { console.error('Error deleting user:', error); showToast('Failed to delete user: ' + error.message, 'error'); } } // Location Management async function openLocationManagement() { const modal = document.getElementById('locationManagementModal'); document.getElementById('newLocationName').value = ''; const locationsList = document.getElementById('locationsList'); locationsList.innerHTML = '

Active Locations

Loading locations...

'; try { const response = await apiCall('/locations'); if (!response.ok) throw new Error('Failed to load locations'); const locations = await response.json(); const activeLocations = locations.filter(loc => loc.is_active); const inactiveLocations = locations.filter(loc => !loc.is_active); let locationsHtml = '

Active Locations

'; if (activeLocations.length === 0) { locationsHtml += '

No active locations

'; } else { locationsHtml += `
${activeLocations.map(location => `
${location.name}
Created: ${new Date(location.created_at).toLocaleDateString()}
`).join('')}
`; } if (inactiveLocations.length > 0) { locationsHtml += `

Archived Locations

${inactiveLocations.map(location => `
${location.name} (archived)
`).join('')}
`; } locationsList.innerHTML = locationsHtml; } catch (error) { console.error('Error loading locations:', error); locationsList.innerHTML = '

Active Locations

Error loading locations

'; } openModal(modal); } // Create location async function createLocation(e) { e.preventDefault(); const name = document.getElementById('newLocationName').value.trim(); if (!name) { showToast('Please enter a location name', 'warning'); return; } try { const response = await apiCall('/locations', { method: 'POST', body: JSON.stringify({ name }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to create location'); } document.getElementById('newLocationName').value = ''; showToast('Location created successfully!', 'success'); openLocationManagement(); } catch (error) { console.error('Error creating location:', error); showToast('Failed to create location: ' + error.message, 'error'); } } // Archive location async function archiveLocation(locationId, locationName) { if (!confirm(`Archive location "${locationName}"?\n\nYou can restore it later if needed.`)) return; try { const response = await apiCall(`/locations/${locationId}`, { method: 'PUT', body: JSON.stringify({ is_active: false }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to archive location'); } showToast('Location archived successfully!', 'success'); openLocationManagement(); } catch (error) { console.error('Error archiving location:', error); showToast('Failed to archive location: ' + error.message, 'error'); } } // Restore location async function restoreLocation(locationId, locationName) { if (!confirm(`Restore location "${locationName}"?`)) return; try { const response = await apiCall(`/locations/${locationId}`, { method: 'PUT', body: JSON.stringify({ is_active: true }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to restore location'); } showToast('Location restored successfully!', 'success'); openLocationManagement(); } catch (error) { console.error('Error restoring location:', error); showToast('Failed to restore location: ' + error.message, 'error'); } } // Batch Management async function openBatchReceiveModal(variantId) { const batchReceiveModal = document.getElementById('batchReceiveModal'); document.getElementById('batchReceiveForm').reset(); document.getElementById('batchVariantId').value = variantId; // Initialize locations await initializeBatchLocations(); openModal(batchReceiveModal); } async function initializeBatchLocations() { const locationSelect = document.getElementById('batchLocation'); try { const response = await apiCall('/locations'); if (!response.ok) throw new Error('Failed to load locations'); const locations = await response.json(); locationSelect.innerHTML = ''; locations.forEach(location => { if (location.is_active) { const option = document.createElement('option'); option.value = location.id; option.textContent = location.name; locationSelect.appendChild(option); } }); } catch (error) { console.error('Error loading locations:', error); showToast('Failed to load storage locations', 'error'); } } async function handleBatchReceive(e) { e.preventDefault(); const variantId = parseInt(document.getElementById('batchVariantId').value); const batchNumber = document.getElementById('batchNumber').value.trim(); const quantity = parseFloat(document.getElementById('batchQuantity').value); const expiryDate = document.getElementById('batchExpiryDate').value; const locationId = parseInt(document.getElementById('batchLocation').value); const notes = document.getElementById('batchNotes').value.trim(); if (!batchNumber || isNaN(quantity) || quantity <= 0 || !expiryDate || !locationId) { showToast('Please fill in all required fields', 'warning'); return; } const batchData = { batch_number: batchNumber, quantity: quantity, expiry_date: expiryDate, location_id: locationId, notes: notes || null }; try { const response = await apiCall(`/variants/${variantId}/batches`, { method: 'POST', body: JSON.stringify(batchData) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to receive batch'); } document.getElementById('batchReceiveForm').reset(); closeModal(document.getElementById('batchReceiveModal')); loadedVariantBatches.delete(variantId); // invalidate level-3 cache await loadDrugs(); showToast('Batch received successfully!', 'success'); } catch (error) { console.error('Error receiving batch:', error); showToast('Failed to receive batch: ' + error.message, 'error'); } } function getActiveDeliveryDrug() { return allDrugs.find(d => d.id === deliveryDrugId); } 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; } 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 ''; } return [``, ...drug.variants.map(v => { const selected = String(v.id) === String(selectedVariantId) ? ' selected' : ''; return ``; })].join(''); } function getActivePacksForVariant(variant) { if (!variant || !Array.isArray(variant.packs)) return []; return variant.packs.filter(pack => pack.is_active); } function buildDeliveryPackOptions(variant, selectedPackId = '') { const packs = getActivePacksForVariant(variant); if (packs.length === 0) { return ''; } return [``, ...packs.map(pack => { const selected = String(pack.id) === String(selectedPackId) ? ' selected' : ''; const label = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`; return ``; })].join(''); } function buildDeliveryLocationOptions(selectedLocationId = '') { const fallbackLocationId = selectedLocationId || (deliveryLocations.length > 0 ? String(deliveryLocations[0].id) : ''); return [``, ...deliveryLocations.map(location => { const selected = String(location.id) === String(fallbackLocationId) ? ' selected' : ''; return ``; })].join(''); } function updateDeliveryLineQuantityDisplay(line) { const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10); const packSelect = line.querySelector('.delivery-pack-select'); const variant = getVariantById(variantId); if (!variant || !packSelect) { return; } const currentPackId = packSelect.value; packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId); } function wireDeliveryLineEvents(line) { const drugSelect = line.querySelector('.delivery-drug-select'); const variantSelect = line.querySelector('.delivery-variant-select'); const packSelect = line.querySelector('.delivery-pack-select'); const packCountInput = line.querySelector('.delivery-pack-count'); if (drugSelect && variantSelect) { 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, ''); if (packCountInput) packCountInput.value = ''; updateDeliveryLineQuantityDisplay(line); }); } if (variantSelect && packSelect) { variantSelect.addEventListener('change', () => { const variantId = parseInt(variantSelect.value || '', 10); const variant = getVariantById(variantId); const activePacks = getActivePacksForVariant(variant); const nextPackId = activePacks.length === 1 ? activePacks[0].id : ''; packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId); if (packCountInput) packCountInput.value = ''; updateDeliveryLineQuantityDisplay(line); }); } if (packSelect) { packSelect.addEventListener('change', () => { updateDeliveryLineQuantityDisplay(line); }); } if (packCountInput) { // Prevent accidental value changes when users scroll while focused. packCountInput.addEventListener('wheel', (event) => { event.preventDefault(); }, { passive: false }); packCountInput.addEventListener('input', () => { updateDeliveryLineQuantityDisplay(line); }); } } // ─── GS1 barcode scanning ────────────────────────────────────────────────── /** * Parse a GS1-128 / DataMatrix scan string. * Handles fixed-length AIs: 01 (GTIN-14), 17 (expiry YYMMDD), then 10 (lot). * Returns { gtin, expiry (Date), lot } or null if the string doesn't match. */ // GS1 AI fixed-length lookup (number of data digits after the AI prefix). // AIs not listed here are treated as variable-length (terminated by GS/FNC1 or end of string). const GS1_FIXED_LENGTHS = { '00': 18, '01': 14, '02': 14, '11': 6, '12': 6, '13': 6, '14': 6, '15': 6, '16': 6, '17': 6, '18': 6, '19': 6, '20': 2, '31': 6, '32': 6, '33': 6, '34': 6, '35': 6, '36': 6, '41': 13, }; // 2-digit AI prefixes we recognise enough to skip over. const GS1_KNOWN_AI_PREFIXES = new Set([ '00','01','02','10','11','12','13','14','15','16','17','18','19', '20','21','22','23','24','25','26', '30','31','32','33','34','35','36','37', '40','41','42','43', '70','71','72','73','74','75','76','77','78','79', '80','81','82','83','84','85','86','87','88','89', '90','91','92','93','94','95','96','97','98','99', ]); /** * Parse a GS1-128 / DataMatrix scan string. * Fixed-length AIs are consumed exactly. Variable-length AIs are terminated * by a GS (FNC1, \x1d) character — if no GS is present they run to end of string * (per GS1 spec: variable-length fields must be FNC1-terminated unless last). * Returns { gtin, expiry (Date), lot } or null if required fields not found. */ function parseGS1(raw) { if (!raw || raw.length < 16) return null; // Strip any leading AIM symbology identifier e.g. "]d2", "]Q3" const aimPrefix = raw.match(/^\][a-zA-Z]\d/); let data = aimPrefix ? raw.substring(3) : raw; // Normalise semicolons to the standard GS/FNC1 character — some scanners // emit ';' as the group separator for variable-length field termination. data = data.replace(/;/g, '\x1d'); const GS = '\x1d'; // FNC1 separator const hasGS = data.includes(GS); let pos = 0; let gtin = null, expiry = null, lot = null; while (pos < data.length) { if (data[pos] === GS) { pos++; continue; } if (pos + 2 > data.length) break; const ai = data.substring(pos, pos + 2); if (!GS1_KNOWN_AI_PREFIXES.has(ai)) break; pos += 2; // consume AI if (GS1_FIXED_LENGTHS[ai] !== undefined) { // Fixed-length: consume exactly N chars const len = GS1_FIXED_LENGTHS[ai]; const value = data.substring(pos, pos + len); pos += len; if (ai === '01') { if (value.length === 14 && /^\d{14}$/.test(value)) gtin = value; } else if (ai === '17') { const yy = parseInt(value.substring(0, 2), 10); const mm = parseInt(value.substring(2, 4), 10); const dd = parseInt(value.substring(4, 6), 10); expiry = dd === 0 ? new Date(yy + 2000, mm, 0) // last day of month : new Date(yy + 2000, mm - 1, dd); } } else { // Variable-length: terminated by GS if present, otherwise end of string let end; if (hasGS) { const gsIdx = data.indexOf(GS, pos); end = gsIdx !== -1 ? gsIdx : data.length; } else { end = data.length; } const value = data.substring(pos, end); pos = end; if (ai === '10') lot = value; // ai 21 (serial), 22, etc. ignored } } if (!gtin || !expiry || !lot) return null; return { gtin, expiry, lot }; } /** Format a Date as YYYY-MM-DD for use in */ function formatDateForInput(d) { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } // Buffer for keyboard-wedge barcode detection let _scanBuffer = []; let _scanBufferTimer = null; let _activeScanLineEl = null; // last delivery line that received focus let _preScanFocusedInput = null; // input that had focus when scan started let _preScanFocusedValue = null; // its value before any scan chars were typed const SCAN_MAX_GAP_MS = 50; const SCAN_MIN_LENGTH = 20; // Separate buffer for main-screen scan (does not interfere with delivery modal) let _mainScanBuffer = []; let _mainScanBufferTimer = null; let _mainPreScanFocusedInput = null; let _mainPreScanFocusedValue = null; function _onMainScreenKeydown(e) { // Only act when no modal is open if (document.querySelector('.modal.show')) return; const now = Date.now(); if (e.key === 'Enter') { const raw = _mainScanBuffer.map(x => x.char).join(''); _mainScanBuffer = []; if (_mainScanBufferTimer) { clearTimeout(_mainScanBufferTimer); _mainScanBufferTimer = null; } if (raw.length >= SCAN_MIN_LENGTH) { e.preventDefault(); if (_mainPreScanFocusedInput) { _mainPreScanFocusedInput.value = _mainPreScanFocusedValue || ''; } _mainPreScanFocusedInput = null; _mainPreScanFocusedValue = null; handleMainScreenBarcodeScan(raw); } return; } if (e.key.length === 1) { const gap = _mainScanBuffer.length > 0 ? now - _mainScanBuffer[_mainScanBuffer.length - 1].time : 0; if (_mainScanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) { _mainScanBuffer = []; _mainPreScanFocusedInput = null; _mainPreScanFocusedValue = null; } if (_mainScanBuffer.length === 0) { const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { _mainPreScanFocusedInput = active; _mainPreScanFocusedValue = active.value; } else { _mainPreScanFocusedInput = null; _mainPreScanFocusedValue = null; } } else { e.preventDefault(); } _mainScanBuffer.push({ char: e.key, time: now }); if (_mainScanBufferTimer) clearTimeout(_mainScanBufferTimer); _mainScanBufferTimer = setTimeout(() => { _mainScanBuffer = []; _mainPreScanFocusedInput = null; _mainPreScanFocusedValue = null; }, 500); } } function _onDeliveryModalKeydown(e) { // Only act when the receive delivery modal is open if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return; // Stop intercepting if the GTIN mapping modal is open on top if (document.getElementById('gtinMappingModal')?.classList.contains('show')) return; // Track which delivery line last had focus const focusedLine = document.activeElement?.closest('.delivery-line'); if (focusedLine) _activeScanLineEl = focusedLine; const now = Date.now(); if (e.key === 'Enter') { const raw = _scanBuffer.map(x => x.char).join(''); _scanBuffer = []; if (_scanBufferTimer) { clearTimeout(_scanBufferTimer); _scanBufferTimer = null; } // Only treat as a scan if it arrived very fast if (raw.length >= SCAN_MIN_LENGTH) { e.preventDefault(); // Restore the focused input to its pre-scan value (remove the 1 char that slipped in) if (_preScanFocusedInput) { _preScanFocusedInput.value = _preScanFocusedValue || ''; } _preScanFocusedInput = null; _preScanFocusedValue = null; handleBarcodeScan(raw); } else { } return; } // Single printable character if (e.key.length === 1) { const gap = _scanBuffer.length > 0 ? now - _scanBuffer[_scanBuffer.length - 1].time : 0; // If gap is too large, start fresh (human typed slowly) if (_scanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) { _scanBuffer = []; _preScanFocusedInput = null; _preScanFocusedValue = null; } // Save the focused input + its value before the first scan char lands if (_scanBuffer.length === 0) { const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { _preScanFocusedInput = active; _preScanFocusedValue = active.value; } else { _preScanFocusedInput = null; _preScanFocusedValue = null; } } else { // Subsequent rapid chars — suppress them from going into the focused input e.preventDefault(); } _scanBuffer.push({ char: e.key, time: now }); // Auto-clear buffer if Enter never comes if (_scanBufferTimer) clearTimeout(_scanBufferTimer); _scanBufferTimer = setTimeout(() => { _scanBuffer = []; _preScanFocusedInput = null; _preScanFocusedValue = null; }, 500); } } async function handleMainScreenBarcodeScan(raw) { const parsed = parseGS1(raw); if (!parsed) { showToast('Barcode not recognised as a GS1 code', 'warning'); return; } const { gtin, lot } = parsed; let mapping = null; try { const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`); if (resp.ok) { mapping = await resp.json(); } else if (resp.status !== 404) { throw new Error(`Server error ${resp.status}`); } } catch (err) { showToast('Failed to look up barcode: ' + err.message, 'error'); return; } if (!mapping) { showToast('Unknown barcode — scan it in a delivery first to map it to a drug', 'warning'); return; } const drugId = mapping.drug_id; const variantId = mapping.drug_variant_id; // Expand drug + variant expandedDrugs.add(drugId); expandedVariants.add(variantId); // Ensure detail + batch data are loaded await ensureDrugDetailLoaded(drugId); if (!loadedVariantBatches.has(variantId)) { try { const resp = await apiCall(`/variants/${variantId}/batches`); if (resp.ok) loadedVariantBatches.set(variantId, await resp.json()); } catch (_) { /* non-fatal */ } } // Set highlight if (_highlightClearTimer) { clearTimeout(_highlightClearTimer); _highlightClearTimer = null; } _highlightedBatchLot = lot; _highlightedVariantId = variantId; renderDrugs(); requestAnimationFrame(() => scrollToHighlightedBatch()); // Auto-clear highlight after 5 seconds _highlightClearTimer = setTimeout(() => { _highlightedBatchLot = null; _highlightedVariantId = null; _highlightClearTimer = null; renderDrugs(); }, 5000); // Confirm to user const batches = loadedVariantBatches.get(variantId) || []; const found = batches.some(b => b.batch_number === lot); if (found) { showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success'); } else { showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot} (no active stock)`, 'warning'); } } function scrollToHighlightedBatch() { if (!_highlightedBatchLot || _highlightedVariantId == null) return; const el = document.querySelector(`[data-batch-number="${CSS.escape(_highlightedBatchLot)}"][data-variant-id="${_highlightedVariantId}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } async function handleBarcodeScan(raw) { const parsed = parseGS1(raw); if (!parsed) { showToast('Barcode not recognised as a GS1 code', 'warning'); return; } const { gtin, expiry, lot } = parsed; const expiryStr = formatDateForInput(expiry); // Look up GTIN mapping let mapping = null; try { const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`); if (resp.ok) { mapping = await resp.json(); } else if (resp.status !== 404) { throw new Error(`Server error ${resp.status}`); } } catch (err) { showToast('Failed to look up barcode: ' + err.message, 'error'); return; } if (!mapping) { // Unknown GTIN — open mapping modal then re-process openGtinMappingModal(gtin, expiryStr, lot); return; } await _applyBarcodeScanToLines(mapping, lot, expiryStr); } async function _applyBarcodeScanToLines(mapping, lot, expiryStr) { const container = document.getElementById('deliveryLinesContainer'); if (!container) return; const lines = Array.from(container.querySelectorAll('.delivery-line')); // 1. Find an existing line with the same variant + lot + expiry → increment pack count for (const line of lines) { const variantId = line.querySelector('.delivery-variant-select')?.value; const batchVal = line.querySelector('.delivery-batch-number')?.value?.trim(); const expiryVal = line.querySelector('.delivery-expiry-date')?.value; if ( String(variantId) === String(mapping.drug_variant_id) && batchVal === lot && expiryVal === expiryStr ) { const countInput = line.querySelector('.delivery-pack-count'); const current = parseFloat(countInput.value) || 0; countInput.value = current + 1; updateDeliveryLineQuantityDisplay(line); showToast(`Pack count incremented to ${current + 1} for lot ${lot}`, 'success'); return; } } // 2. Find any existing empty line (lot and expiry both blank) — never overwrite a filled line const emptyLine = lines.find(l => { const batch = l.querySelector('.delivery-batch-number')?.value?.trim(); const expiry = l.querySelector('.delivery-expiry-date')?.value; return !batch && !expiry; }) || null; if (emptyLine) { await _populateDeliveryLine(emptyLine, mapping, lot, expiryStr); return; } // 3. Append a new line appendDeliveryLine(); const newLine = container.querySelector('.delivery-line:last-child'); if (newLine) await _populateDeliveryLine(newLine, mapping, lot, expiryStr); } async function _populateDeliveryLine(line, mapping, lot, expiryStr) { const drugSelect = line.querySelector('.delivery-drug-select'); const variantSelect = line.querySelector('.delivery-variant-select'); const packSelect = line.querySelector('.delivery-pack-select'); const batchInput = line.querySelector('.delivery-batch-number'); const expiryInput = line.querySelector('.delivery-expiry-date'); const packCountInput = line.querySelector('.delivery-pack-count'); if (drugSelect) { drugSelect.innerHTML = buildDeliveryDrugOptions(mapping.drug_id); drugSelect.value = String(mapping.drug_id); } // Ensure drug detail (with packs) is loaded before trying to populate pack select await ensureDrugDetailLoaded(mapping.drug_id); if (variantSelect) { const drug = allDrugs.find(d => d.id === mapping.drug_id) || null; variantSelect.innerHTML = buildDeliveryVariantOptions(drug, mapping.drug_variant_id); variantSelect.value = String(mapping.drug_variant_id); const variant = getVariantById(mapping.drug_variant_id); if (packSelect) { packSelect.innerHTML = buildDeliveryPackOptions(variant, mapping.variant_pack_id); packSelect.value = String(mapping.variant_pack_id); } } if (batchInput) batchInput.value = lot; if (expiryInput) expiryInput.value = expiryStr; if (packCountInput && !packCountInput.value) packCountInput.value = 1; updateDeliveryLineQuantityDisplay(line); showToast(`Populated: ${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success'); } function _detachDeliveryBarcodeListener() { const modalEl = document.getElementById('receiveDeliveryModal'); if (modalEl?._barcodeListener) { document.removeEventListener('keydown', modalEl._barcodeListener); modalEl._barcodeListener = null; } _scanBuffer = []; _activeScanLineEl = null; } function _refreshGtinMappingSelects() { // Kept for compatibility — delegates to reinit with current selections const drugId = parseInt(document.getElementById('gtinMappingDrugSelect')?.value || '', 10) || null; const variantId = parseInt(document.getElementById('gtinMappingVariantSelect')?.value || '', 10) || null; const packId = parseInt(document.getElementById('gtinMappingPackSelect')?.value || '', 10) || null; _reinitGtinMappingModal({ drugId, variantId, packId }); } // Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data, // optionally pre-selecting specific drug/variant/pack IDs. async function _reinitGtinMappingModal(restore) { const drugSelect = document.getElementById('gtinMappingDrugSelect'); const variantSelect = document.getElementById('gtinMappingVariantSelect'); const packSelect = document.getElementById('gtinMappingPackSelect'); if (!drugSelect) return; // Rebuild drug list drugSelect.innerHTML = '' + allDrugs.map(d => ``).join(''); const drugId = restore?.drugId || null; const variantId = restore?.variantId || null; const packId = restore?.packId || null; // If no drug to restore, clear cascades and stop if (!drugId) { variantSelect.innerHTML = ''; packSelect.innerHTML = ''; return; } drugSelect.value = String(drugId); await ensureDrugDetailLoaded(drugId); const drug = allDrugs.find(d => d.id === drugId); // Rebuild variant list variantSelect.innerHTML = ''; if (drug) { variantSelect.innerHTML += drug.variants.map(v => `` ).join(''); } if (!variantId) { packSelect.innerHTML = ''; return; } variantSelect.value = String(variantId); const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs) // Rebuild pack list packSelect.innerHTML = ''; if (variant) { const packs = getActivePacksForVariant(variant); packSelect.innerHTML += packs.map(p => `` ).join(''); if (packId) packSelect.value = String(packId); } } function gtinMappingAddDrug() { // Snapshot current drug IDs. handleAddDrug will call loadDrugs() once (we intercept it // to find the new drug ID), then open addVariantModal. We hook the *subsequent* loadDrugs // call (from handleAddVariant) to reinit the GTIN modal with drug+variant selected. _gtinMappingWaitingForNewDrug = new Set(allDrugs.map(d => d.id)); openModal(document.getElementById('addModal')); } function gtinMappingAddVariant() { const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10); if (!drugId) { showToast('Select a drug first', 'warning'); return; } const drug = allDrugs.find(d => d.id === drugId); const existingVariantIds = new Set((drug?.variants || []).map(v => v.id)); // After reload, find the new variant by diffing _gtinMappingPendingRestore = { drugId, variantId: null, packId: null, _existingVariantIds: existingVariantIds }; _gtinMappingPendingRefresh = true; openAddVariantModal(drugId); } function gtinMappingAddPack() { const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10); if (!variantId) { showToast('Select a variant first', 'warning'); return; } const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10); 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; deliveryDrugId = drugId; _gtinMappingPendingVariantId = variantId; openAddPackSizeFromDeliveryModal(); } // ─── GTIN mapping modal logic ────────────────────────────────────────────── let _pendingGtinScan = null; // { gtin, expiryStr, lot } while mapping modal is open function openGtinMappingModal(gtin, expiryStr, lot) { _pendingGtinScan = { gtin, expiryStr, lot }; document.getElementById('gtinMappingGtin').value = gtin; // Populate drug selector from allDrugs const drugSelect = document.getElementById('gtinMappingDrugSelect'); drugSelect.innerHTML = '' + allDrugs.map(d => ``).join(''); document.getElementById('gtinMappingVariantSelect').innerHTML = ''; document.getElementById('gtinMappingPackSelect').innerHTML = ''; openModal(document.getElementById('gtinMappingModal')); } async function onGtinMappingDrugChange() { const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10); const variantSelect = document.getElementById('gtinMappingVariantSelect'); const packSelect = document.getElementById('gtinMappingPackSelect'); variantSelect.innerHTML = ''; packSelect.innerHTML = ''; if (!drugId) return; await ensureDrugDetailLoaded(drugId); const drug = allDrugs.find(d => d.id === drugId); if (!drug) return; variantSelect.innerHTML += drug.variants.map(v => `` ).join(''); } function onGtinMappingVariantChange() { const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10); const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs) const packSelect = document.getElementById('gtinMappingPackSelect'); packSelect.innerHTML = ''; if (!variant) return; const packs = getActivePacksForVariant(variant); packSelect.innerHTML += packs.map(p => `` ).join(''); } async function handleSaveGtinMapping() { if (!_pendingGtinScan) return; const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10); const packId = parseInt(document.getElementById('gtinMappingPackSelect').value || '', 10); if (!variantId || !packId) { showToast('Please select a variant and pack', 'warning'); return; } try { const resp = await apiCall('/gtin', { method: 'POST', body: JSON.stringify({ gtin: _pendingGtinScan.gtin, drug_variant_id: variantId, variant_pack_id: packId, }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || 'Failed to save GTIN mapping'); } const mapping = await resp.json(); closeModal(document.getElementById('gtinMappingModal')); showToast(`GTIN mapped to ${mapping.drug_name} ${mapping.variant_strength}`, 'success'); // Now apply the scan that triggered this _applyBarcodeScanToLines(mapping, _pendingGtinScan.lot, _pendingGtinScan.expiryStr); _pendingGtinScan = null; } catch (err) { showToast('Error saving GTIN: ' + err.message, 'error'); } } function buildDeliveryDrugOptions(selectedDrugId = '') { return [ '', ...allDrugs.map(d => { const sel = String(d.id) === String(selectedDrugId) ? ' selected' : ''; return ``; }) ].join(''); } function appendDeliveryLine(prefill = {}) { const container = document.getElementById('deliveryLinesContainer'); if (!container) return; deliveryLineCounter += 1; const lineId = `delivery-line-${deliveryLineCounter}`; const line = document.createElement('div'); line.className = 'delivery-line'; line.dataset.lineId = lineId; const initialDrug = prefill.drugId ? allDrugs.find(d => String(d.id) === String(prefill.drugId)) || null : null; const initialDrugId = prefill.drugId || ''; const initialVariant = prefill.variantId && initialDrug ? initialDrug.variants.find(v => String(v.id) === String(prefill.variantId)) || null : null; const initialVariantId = prefill.variantId || ''; const initialPackId = prefill.packId || (initialVariant && getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : ''); const initialPackCount = prefill.packCount || ''; line.innerHTML = `
`; const removeBtn = line.querySelector('.delivery-remove-btn'); if (removeBtn) { removeBtn.addEventListener('click', () => { if (container.children.length <= 1) { showToast('At least one delivery line is required', 'warning'); return; } line.remove(); }); } wireDeliveryLineEvents(line); updateDeliveryLineQuantityDisplay(line); container.appendChild(line); } function refreshDeliveryVariantSelects() { const container = document.getElementById('deliveryLinesContainer'); if (!container) return; container.querySelectorAll('.delivery-line').forEach(line => { const drugSelect = line.querySelector('.delivery-drug-select'); const variantSelect = line.querySelector('.delivery-variant-select'); const packSelect = line.querySelector('.delivery-pack-select'); if (!variantSelect) return; const drugId = parseInt(drugSelect?.value || '', 10); const drug = allDrugs.find(d => d.id === drugId) || null; const currentVariantId = variantSelect.value; variantSelect.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId); const variant = getVariantById(parseInt(variantSelect.value || '', 10)); if (packSelect) { const currentPackId = packSelect.value; packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId); } updateDeliveryLineQuantityDisplay(line); }); } async function initializeDeliveryLocations() { try { const response = await apiCall('/locations'); if (!response.ok) throw new Error('Failed to load locations'); const locations = await response.json(); deliveryLocations = locations.filter(location => location.is_active); } catch (error) { console.error('Error loading delivery locations:', error); showToast('Failed to load storage locations', 'error'); deliveryLocations = []; } } async function openReceiveDeliveryModal() { deliveryDrugId = null; const form = document.getElementById('receiveDeliveryForm'); const container = document.getElementById('deliveryLinesContainer'); const label = document.getElementById('receiveDeliveryDrugLabel'); if (form) form.reset(); if (container) container.innerHTML = ''; if (label) label.textContent = 'Scan items or add lines manually'; await initializeDeliveryLocations(); appendDeliveryLine(); // Attach barcode scanner listener _activeScanLineEl = null; _scanBuffer = []; const modalEl = document.getElementById('receiveDeliveryModal'); if (modalEl._barcodeListener) document.removeEventListener('keydown', modalEl._barcodeListener); modalEl._barcodeListener = _onDeliveryModalKeydown; document.addEventListener('keydown', modalEl._barcodeListener); openModal(modalEl); } function handleAddVariantFromDelivery() { if (!deliveryDrugId) { const deliveryContainer = document.getElementById('deliveryLinesContainer'); const firstDrugIdStr = deliveryContainer ? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v) : null; deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null; } if (!deliveryDrugId) { showToast('Select a drug on a delivery line first', 'warning'); return; } openAddVariantModal(deliveryDrugId); } function openAddPackSizeFromDeliveryModal() { if (!deliveryDrugId) { // In multi-drug mode, get drug from the first line that has one selected const deliveryContainer = document.getElementById('deliveryLinesContainer'); const firstDrugIdStr = deliveryContainer ? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v) : null; deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null; } if (!deliveryDrugId) { showToast('Select a drug on a delivery line first', 'warning'); return; } const drug = allDrugs.find(d => d.id === deliveryDrugId); if (!drug) { showToast('Drug not found', 'error'); return; } const form = document.getElementById('addPackSizeForm'); if (form) form.reset(); const label = document.getElementById('addPackSizeDrugLabel'); if (label) label.textContent = `Drug: ${drug.name}`; // Try to pre-select variant from first delivery line that has one chosen const deliveryContainer = document.getElementById('deliveryLinesContainer'); const selectedDeliveryVariantId = deliveryContainer ? (Array.from(deliveryContainer.querySelectorAll('.delivery-variant-select')) .map(s => s.value) .find(v => v)) || '' : ''; const variantSelect = document.getElementById('addPackSizeVariantSelect'); if (variantSelect) { variantSelect.innerHTML = [ '', ...drug.variants.map(v => `` ) ].join(''); if (selectedDeliveryVariantId) { variantSelect.value = selectedDeliveryVariantId; } else if (drug.variants.length === 1) { variantSelect.value = String(drug.variants[0].id); } } // Update the hint for the pre-selected variant const selectedId = parseInt(variantSelect?.value || '', 10); const selectedVariant = getVariantById(selectedId); const hint = document.getElementById('addPackSizeHint'); if (hint) hint.textContent = selectedVariant ? `Base unit: ${selectedVariant.unit}` : ''; // Reset pack type default to box const packTypeSelect = document.getElementById('addPackSizeType'); if (packTypeSelect) packTypeSelect.value = 'box'; openModal(document.getElementById('addPackSizeModal')); } async function handleAddPackSize(e) { e.preventDefault(); const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10); const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim(); const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || ''); if (!variantId) { showToast('Please select a variant', 'warning'); return; } if (!packSize || packSize <= 0) { showToast('Please enter a valid pack size greater than zero', 'warning'); return; } try { const response = await apiCall(`/variants/${variantId}/packs`, { method: 'POST', body: JSON.stringify({ pack_unit_name: packType, pack_size_in_base_units: packSize, is_active: true }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to add pack size'); } 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(); showToast('Pack size added successfully!', 'success'); } catch (error) { console.error('Error adding pack size:', error); showToast('Failed to add pack size: ' + error.message, 'error'); } } async function handleReceiveDelivery(e) { e.preventDefault(); const container = document.getElementById('deliveryLinesContainer'); if (!container) { showToast('Delivery context unavailable', 'error'); return; } const lines = Array.from(container.querySelectorAll('.delivery-line')); if (lines.length === 0) { showToast('Add at least one delivery line', 'warning'); return; } const payloads = []; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10); const packIdRaw = line.querySelector('.delivery-pack-select')?.value || ''; const packId = packIdRaw ? parseInt(packIdRaw, 10) : null; const packCountRaw = line.querySelector('.delivery-pack-count')?.value || ''; const packCount = packCountRaw ? parseFloat(packCountRaw) : null; const batchNumber = (line.querySelector('.delivery-batch-number')?.value || '').trim(); const expiryDate = line.querySelector('.delivery-expiry-date')?.value || ''; const locationId = parseInt(line.querySelector('.delivery-location-select')?.value || '', 10); if (!variantId || !packId || packCount === null || Number.isNaN(packCount) || packCount <= 0 || !batchNumber || !expiryDate || !locationId) { showToast(`Delivery line ${i + 1} is incomplete`, 'warning'); return; } if (Math.abs(packCount - Math.round(packCount)) > 1e-6) { showToast(`Delivery line ${i + 1} pack count must be a whole number`, 'warning'); return; } const variant = getVariantById(variantId); const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null; if (!selectedPack) { showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning'); return; } const computedQuantity = packCount * selectedPack.pack_size_in_base_units; payloads.push({ variantId, payload: { batch_number: batchNumber, received_pack_id: packId, received_pack_count: packCount, expiry_date: expiryDate, location_id: locationId, notes: `Received ${packCount} ${selectedPack.pack_unit_name}(s), total ${computedQuantity} ${variant ? variant.unit : 'units'}` } }); } try { for (let i = 0; i < payloads.length; i += 1) { const entry = payloads[i]; const response = await apiCall(`/variants/${entry.variantId}/batches`, { method: 'POST', body: JSON.stringify(entry.payload) }); if (!response.ok) { const error = await response.json(); throw new Error(`Line ${i + 1}: ${error.detail || 'Failed to receive delivery line'}`); } } 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) { console.error('Error receiving delivery:', error); showToast('Failed to receive delivery: ' + error.message, 'error'); } }