Files
mt-drugs/frontend/app.js
T
2026-04-06 11:26:51 -04:00

3240 lines
131 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_URL = '/api';
let allDrugs = [];
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;
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 = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
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');
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
if (printNotesBtn) printNotesBtn.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 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);
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', () => closeModal(receiveDeliveryModal));
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
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 (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')));
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
const modal = e.target.closest('.modal');
if (modal?.id === 'disposeBatchModal') {
resetDisposeBatchModal();
}
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', () => {
const dropdown = document.getElementById('userDropdown');
if (dropdown) dropdown.style.display = dropdown.style.display === 'none' ? 'block' : '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);
}
});
}
// 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();
} catch (error) {
console.error('Error loading drugs:', error);
document.getElementById('drugsList').innerHTML =
'<p class="empty">Error loading drugs. Make sure the backend is running.</p>';
}
}
// 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 = '<option value="">-- Select a drug variant --</option>';
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 = '<option value="">-- Select pack --</option>';
}
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 = '<option value="">-- Select pack --</option>';
activePacks.forEach(pack => {
const option = document.createElement('option');
option.value = String(pack.id);
option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
packSelect.appendChild(option);
});
if (packCount) packCount.value = '';
if (packPreview) {
packPreview.textContent = activePacks.length > 0
? `Select a pack and whole-number count (${variant.unit} base unit).`
: `No active packs for this variant.`;
}
}
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) {
const activePacks = getActivePacksForVariant(variant);
const isReadOnly = currentUser?.role === 'readonly';
const batches = [...(variant.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 => `
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
<strong>${escapeHtml(pack.label)}</strong>
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
</div>
`).join('')
: '<div style="padding: 6px 8px; background: #ffffff; border: 1px dashed #cfd8e3; border-radius: 5px; font-size: 0.9em; color: #6b7280;">No active packs configured</div>';
const batchesHtml = batches.length > 0
? batches.map(batch => {
const locationLabel = getBatchLocationLabel(batch);
const expired = isBatchExpired(batch);
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label;
const stocktakeLabel = hasPackState
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
const batchCardStyles = 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 `
<div style="${batchCardStyles}">
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<strong>${escapeHtml(batch.batch_number)}</strong>
${expired ? '<span style="background: #b91c1c; color: white; padding: 2px 6px; border-radius: 999px; font-size: 0.75em; font-weight: 700;">Expired</span>' : ''}
</div>
<span style="${expiryStyles}">Expires ${formatDisplayDate(batch.expiry_date)}</span>
</div>
<div style="margin-top: 4px; color: #374151;">${escapeHtml(locationLabel)} | ${stocktakeLabel}</div>
${expired && !isReadOnly ? `
<div style="margin-top: 8px; display: flex; justify-content: flex-end;">
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeBatch(${batch.id}, '${String(batch.batch_number).replace(/'/g, "\\'")}')">Dispose Expired Batch</button>
</div>
` : ''}
</div>
`;
}).join('')
: '<div style="padding: 6px 8px; background: #ffffff; border: 1px dashed #cfd8e3; border-radius: 5px; font-size: 0.9em; color: #6b7280;">No active batches</div>';
return `
<div style="margin-top: 10px; background: #f2f6fa; border: 1px solid #d6e0ea; border-radius: 8px; padding: 10px;" onclick="event.stopPropagation()">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<div style="font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; color: #4b5563; margin-bottom: 6px;">Active Packs</div>
<div style="display: grid; gap: 6px;">${packsHtml}</div>
</div>
<div>
<div style="font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; color: #4b5563; margin-bottom: 6px;">Current Batches</div>
<div style="display: grid; gap: 6px;">${batchesHtml}</div>
</div>
</div>
</div>
`;
}
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();
}
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.variants.forEach(variant => {
(variant.batches || []).forEach(batch => {
if (batch.quantity > 0) {
locations.add(getBatchLocationLabel(batch));
}
});
});
});
locationFilterSelect.innerHTML = '<option value="">All Locations</option>';
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_label || '').trim().toLowerCase();
const selectedPackLabel = String(selectedPack.label || '').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 = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
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 `
<div style="padding: 10px; margin: 6px 0; background: white; border: 1px solid #e0e0e0; border-radius: 4px; ${index === 0 ? 'border-left: 3px solid #2196F3; background: #f8fbff;' : ''}">
<div style="display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) 140px; gap: 12px; align-items: end;">
<div>
<div><strong>${escapeHtml(batch.batch_number)}</strong>${index === 0 ? ' <span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FEFO</span>' : ''}</div>
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
Available: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
Location: <strong>${escapeHtml(locationLabel)}</strong> |
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
</div>
</div>
<div style="font-size: 0.9em; color: #374151;">
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
: ''}
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="dispenseBatchAllocation-${batch.id}">${allocationLabel}</label>
<input
type="number"
id="dispenseBatchAllocation-${batch.id}"
class="dispense-batch-allocation"
data-batch-id="${batch.id}"
data-allocation-mode="${mode}"
data-pack-size="${mode === 'pack' && selectedPack ? selectedPack.pack_size_in_base_units : ''}"
min="0"
max="${allocationMax}"
step="${allocationStep}"
value="0"
>
</div>
</div>
</div>
`;
}).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_label
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
return `
<div style="padding: 8px; margin: 6px 0; background: white; border: 1px solid #f0d7a1; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
<strong>${escapeHtml(batch.batch_number)}</strong>
<span style="color: #b45309; font-weight: 600;">Expired ${expiryLabel}</span>
</div>
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
Qty: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
Location: <strong>${escapeHtml(locationLabel)}</strong>
</div>
<div style="font-size: 0.9em; color: #374151; margin-top: 4px;">${stocktakeLabel}</div>
</div>
`;
}).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 = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack dispensing is unavailable.</div>`;
updateDispenseAllocationSummary();
} else {
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
updateDispenseAllocationSummary();
}
return;
}
if (isLegacyDispenseSelected()) {
const extraText = hasBatchDispenseStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispense by quantity only.';
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy loose stock selected.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}</div>`;
updateDispenseAllocationSummary();
return;
}
if (hasBatchDispenseStock()) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
autoAllocateDispenseBatches();
return;
}
if (hasLegacyDispenseQuantity()) {
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispense by quantity only.</div>`;
updateDispenseAllocationSummary();
return;
}
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
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 = '<option value="">-- Select pack --</option>';
currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]);
updateDispenseSourceUi();
toggleDispensePrintFields();
updateDispenseAllocationSummary();
return;
}
const variant = getVariantById(variantId);
if (variant) {
populateDispensePackSelect(variant);
}
updateDispenseModeUi();
batchInfoSection.style.display = 'block';
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
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 = '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>';
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 = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
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
? `<span style="color: #666;">Enter a dispense quantity. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.</span>`
: `<span style="color: #666;">Enter a dispense amount to allocate batches.</span>`;
return;
}
if (legacyStockOnly) {
if (mode === 'pack') {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Whole-pack dispensing is unavailable for stock that is not attached to batches.</span>`;
return;
}
if (requestedQuantity - currentDispenseLegacyQuantity > 1e-6) {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.</span>`;
return;
}
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Dispensing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.</span>`;
return;
}
if (mode === 'pack' && selectedPack) {
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
if (totalAvailablePacks <= 0) {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispense.</span>`;
return;
}
if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.</span>`;
return;
}
}
if (requestedQuantity - totalAvailableQuantity > 1e-6) {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.</span>`;
return;
}
if (invalidInput) {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">One or more batch allocations exceed available stock or are not valid full-pack amounts.</span>`;
return;
}
if (Math.abs(difference) <= 1e-6) {
if (mode === 'pack' && selectedPack) {
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).</span>`;
} else {
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.</span>`;
}
return;
}
if (difference > 0) {
if (mode === 'pack' && selectedPack) {
const differencePacks = difference / selectedPack.pack_size_in_base_units;
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(differencePacks)} more pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
} else {
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.</span>`;
}
return;
}
if (mode === 'pack' && selectedPack) {
const differencePacks = Math.abs(difference) / selectedPack.pack_size_in_base_units;
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(differencePacks)} pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
return;
}
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.</span>`;
}
// 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
if (selectedLocationFilter) {
drugsToShow = drugsToShow.filter(drug =>
drug.variants.some(variant =>
(variant.batches || []).some(batch =>
batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter
)
)
);
}
// Sort alphabetically by drug name
drugsToShow = drugsToShow.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
if (drugsToShow.length === 0) {
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
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 variantsHtml = isExpanded ? `
${drug.variants.map(variant => {
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold;
const variantExpanded = expandedVariants.has(variant.id);
return `
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${variant.id}, event)">
<div class="variant-info">
<div class="variant-details">
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div>
<div class="variant-quantity">${variant.quantity} ${escapeHtml(variant.unit)}</div>
</div>
<div class="variant-status">
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
${variantIsLowStock ? 'Low Stock' : 'OK'}
</span>
<span style="margin-left: 8px; font-size: 0.85em; color: #475569;">Inventory ${variantExpanded ? '▼' : '▶'}</span>
</div>
</div>
<div class="variant-actions">
${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${variant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${variant.id})">Edit</button>
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${variant.id})" title="${variant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
` : ''}
</div>
${variantExpanded ? renderVariantInventoryDetails(variant) : ''}
</div>
`;
}).join('')}` : '';
return `
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
<div class="drug-info">
<div class="drug-name">
${escapeHtml(drug.name)}
${isControlled ? '<span style="background: #d32f2f; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.75em; margin-left: 8px; display: inline-block;">⚠️ CONTROLLED</span>' : ''}
</div>
<div class="drug-description">${drug.description ? escapeHtml(drug.description) : 'No description'}</div>
<div class="drug-quantity">${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)</div>
<div class="drug-status">
<span class="drug-badge ${isLowStock ? 'badge-low' : 'badge-normal'}">
${isLowStock ? `${lowStockVariants} low` : 'All OK'}
</span>
</div>
</div>
<div class="drug-actions">
${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})"> Add</button>
` : ''}
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
${!isReadOnly ? `
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditModal(${drug.id})">Edit Drug</button>
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteDrug(${drug.id})" title="${drug.variants.some(v => v.has_inventory_history) ? 'Drug has variants with history and cannot be deleted' : ''}">Delete</button>
` : ''}
<span class="expand-icon">${isExpanded ? '▼' : '▶'}</span>
</div>
</div>
<div class="drug-variants ${isExpanded ? 'expanded' : ''}" id="variants-${drug.id}">
${variantsHtml}
</div>
`;
}).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');
document.getElementById('drugForm').reset();
closeModal(document.getElementById('addModal'));
await loadDrugs();
showToast('Drug added successfully!', '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 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 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,
notes: notes || null,
allocations
};
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'));
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
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));
}
} else {
expandedDrugs.add(drugId);
}
renderDrugs();
}
function toggleVariantExpansion(variantId, event) {
if (event) {
event.stopPropagation();
}
if (expandedVariants.has(variantId)) {
expandedVariants.delete(variantId);
} else {
expandedVariants.add(variantId);
}
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 'units';
}
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 || 'bottle';
const selectedSize = prefill.packSize || '';
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
row.innerHTML = `
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
<div class="form-group">
<label>Pack Type *</label>
<select class="variant-pack-unit" required>
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
</select>
</div>
<div class="form-group">
<label class="variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
<input type="number" class="variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}" required>
</div>
<button type="button" class="btn btn-danger btn-small variant-pack-remove-btn">Remove</button>
</div>
`;
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: 'bottle' });
}
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 = `
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
<div class="form-group">
<label>Pack Type *</label>
<select class="edit-variant-pack-unit">
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
</select>
</div>
<div class="form-group">
<label class="edit-variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
<input type="number" class="edit-variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}">
</div>
<button type="button" class="btn btn-danger btn-small edit-variant-pack-remove-btn">Remove</button>
</div>
`;
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'));
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'));
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 = '<p class="loading">Loading history...</p>';
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 = '<p class="empty">No dispensing history for this drug.</p>';
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 `
<div class="history-item">
<div class="history-header">
<div class="history-variant">${drug.name} ${variant.strength}</div>
<div class="history-datetime">${date} ${time}</div>
</div>
<div class="history-details">
<div class="history-row">
<span class="history-label">Quantity:</span>
<span class="history-value">${item.quantity} ${variant.unit}</span>
</div>
<div class="history-row">
<span class="history-label">Animal:</span>
<span class="history-value">${escapeHtml(item.animal_name)}</span>
</div>
<div class="history-row">
<span class="history-label">User:</span>
<span class="history-value">${escapeHtml(item.user_name)}</span>
</div>
${item.notes ? `
<div class="history-row">
<span class="history-label">Notes:</span>
<span class="history-value">${escapeHtml(item.notes)}</span>
</div>
` : ''}
</div>
</div>
`;
}).join('');
historyContent.innerHTML = historyHtml;
} catch (error) {
console.error('Error fetching history:', error);
historyContent.innerHTML = '<p class="error">Failed to load history. Check the console for details.</p>';
}
}
// 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();
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');
}
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;
}
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 = '<h3>Users</h3><p class="loading">Loading users...</p>';
try {
const response = await apiCall('/users');
if (!response.ok) throw new Error('Failed to load users');
const users = await response.json();
const usersHtml = `
<h3>Users</h3>
<div class="users-table">
${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 `
<div class="user-item">
<span>${user.username}</span>
<span class="admin-badge">${roleBadge}</span>
<button class="btn btn-secondary btn-small" onclick="openAdminChangePasswordModal(${user.id}, '${escapeHtml(user.username)}')">🔑 Password</button>
${user.id !== currentUser.id ? `
<button class="btn btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
` : ''}
</div>
`;
}).join('')}
</div>
`;
usersList.innerHTML = usersHtml;
} catch (error) {
console.error('Error loading users:', error);
usersList.innerHTML = '<h3>Users</h3><p class="empty">Error loading users</p>';
}
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 = '<h3>Active Locations</h3><p class="loading">Loading locations...</p>';
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 = '<h3>Active Locations</h3>';
if (activeLocations.length === 0) {
locationsHtml += '<p class="empty">No active locations</p>';
} else {
locationsHtml += `<div class="locations-table">
${activeLocations.map(location => `
<div class="location-item">
<div style="flex: 1;">
<strong>${location.name}</strong>
<div style="font-size: 0.85em; color: #666;">Created: ${new Date(location.created_at).toLocaleDateString()}</div>
</div>
<button class="btn btn-danger btn-small" onclick="archiveLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Archive</button>
</div>
`).join('')}
</div>`;
}
if (inactiveLocations.length > 0) {
locationsHtml += `
<h3 style="margin-top: 20px;">Archived Locations</h3>
<div class="locations-table">
${inactiveLocations.map(location => `
<div class="location-item" style="opacity: 0.6;">
<div style="flex: 1;">
<strong>${location.name}</strong> <span style="color: #999;">(archived)</span>
</div>
<button class="btn btn-secondary btn-small" onclick="restoreLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Restore</button>
</div>
`).join('')}
</div>
`;
}
locationsList.innerHTML = locationsHtml;
} catch (error) {
console.error('Error loading locations:', error);
locationsList.innerHTML = '<h3>Active Locations</h3><p class="empty">Error loading locations</p>';
}
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 = '<option value="">-- Select location --</option>';
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'));
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) {
for (const drug of allDrugs) {
const found = (drug.variants || []).find(v => v.id === variantId);
if (found) return found;
}
return null;
}
function buildDeliveryVariantOptions(drug, selectedVariantId = '') {
if (!drug || !drug.variants || drug.variants.length === 0) {
return '<option value="">-- No variants available --</option>';
}
return [`<option value="">-- Select variant --</option>`, ...drug.variants.map(v => {
const selected = String(v.id) === String(selectedVariantId) ? ' selected' : '';
return `<option value="${v.id}"${selected}>${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`;
})].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 '<option value="">-- No active packs --</option>';
}
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
})].join('');
}
function buildDeliveryLocationOptions(selectedLocationId = '') {
return [`<option value="">-- Select location --</option>`, ...deliveryLocations.map(location => {
const selected = String(location.id) === String(selectedLocationId) ? ' selected' : '';
return `<option value="${location.id}"${selected}>${escapeHtml(location.name)}</option>`;
})].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 variantSelect = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
const packCountInput = line.querySelector('.delivery-pack-count');
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) {
packCountInput.addEventListener('input', () => {
updateDeliveryLineQuantityDisplay(line);
});
}
}
function appendDeliveryLine(prefill = {}) {
const container = document.getElementById('deliveryLinesContainer');
const drug = getActiveDeliveryDrug();
if (!container || !drug) return;
deliveryLineCounter += 1;
const lineId = `delivery-line-${deliveryLineCounter}`;
const line = document.createElement('div');
line.className = 'delivery-line';
line.dataset.lineId = lineId;
const initialVariant = prefill.variantId
? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
: drug.variants.length === 1 ? drug.variants[0] : null;
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
const initialPackCount = prefill.packCount || '';
line.innerHTML = `
<div class="delivery-line-grid">
<div class="form-group">
<label>Variant</label>
<select class="delivery-variant-select" required>
${buildDeliveryVariantOptions(drug, initialVariantId)}
</select>
</div>
<div class="form-group">
<label>Pack Type</label>
<select class="delivery-pack-select" required>
${buildDeliveryPackOptions(initialVariant, initialPackId)}
</select>
</div>
<div class="form-group">
<label>Pack Count</label>
<input type="number" class="delivery-pack-count" min="1" step="1" value="${initialPackCount}" required>
</div>
<div class="form-group">
<label>Batch Number</label>
<input type="text" class="delivery-batch-number" value="${prefill.batchNumber || ''}" placeholder="e.g. ABC123" required>
</div>
<div class="form-group">
<label>Expiry</label>
<input type="date" class="delivery-expiry-date" value="${prefill.expiryDate || ''}" required>
</div>
<div class="form-group">
<label>Location</label>
<select class="delivery-location-select" required>
${buildDeliveryLocationOptions(prefill.locationId || '')}
</select>
</div>
<button type="button" class="btn btn-danger btn-small delivery-remove-btn">Remove</button>
</div>
`;
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 drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !container) return;
container.querySelectorAll('.delivery-line').forEach(line => {
const select = line.querySelector('.delivery-variant-select');
const packSelect = line.querySelector('.delivery-pack-select');
if (!select) return;
const currentVariantId = select.value;
const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : '');
select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId);
const variant = getVariantById(parseInt(select.value || '', 10));
if (packSelect) {
const currentPackId = packSelect.value;
const activePacks = getActivePacksForVariant(variant);
const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : '');
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
}
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(drugId) {
deliveryDrugId = drugId;
const drug = getActiveDeliveryDrug();
if (!drug) {
showToast('Drug not found', 'error');
return;
}
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 = `Drug: ${drug.name}`;
await initializeDeliveryLocations();
appendDeliveryLine();
openModal(document.getElementById('receiveDeliveryModal'));
}
function handleAddVariantFromDelivery() {
if (!deliveryDrugId) {
showToast('Select a drug first', 'warning');
return;
}
openAddVariantModal(deliveryDrugId);
}
async function handleReceiveDelivery(e) {
e.preventDefault();
const drug = getActiveDeliveryDrug();
const container = document.getElementById('deliveryLinesContainer');
if (!drug || !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 = drug.variants.find(v => v.id === 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'));
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');
}
}