5113 lines
216 KiB
JavaScript
5113 lines
216 KiB
JavaScript
const API_URL = '/api';
|
||
let allDrugs = []; // level-1 summaries: no packs, no batches
|
||
let loadedDrugDetails = new Map(); // drugId → full drug with variant packs (level-2)
|
||
let loadedVariantBatches = new Map(); // variantId → Batch[] (level-3)
|
||
let currentDrug = null;
|
||
let showLowStockOnly = false;
|
||
let selectedLocationFilter = '';
|
||
let searchTerm = '';
|
||
let expandedDrugs = new Set();
|
||
let expandedVariants = new Set();
|
||
let currentUser = null;
|
||
let accessToken = null;
|
||
let deliveryDrugId = null;
|
||
let deliveryLineCounter = 0;
|
||
let deliveryLocations = [];
|
||
let currentDispenseBatches = [];
|
||
let currentDispenseLegacyQuantity = 0;
|
||
let currentDisposeBatches = [];
|
||
let currentDisposeLegacyQuantity = 0;
|
||
let _gtinMappingPendingRefresh = false;
|
||
let _gtinMappingPendingVariantId = null;
|
||
let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload
|
||
let _gtinMappingWaitingForNewDrug = null; // Set of drug IDs before add-drug; resolved on first loadDrugs
|
||
let _highlightedBatchLot = null; // lot number to highlight after a main-screen scan
|
||
let _highlightedVariantId = null; // variant the highlighted batch belongs to
|
||
let _highlightClearTimer = null; // timer to auto-remove the highlight
|
||
|
||
/** Build a human-readable pack label from pack fields, e.g. "Box of 28" */
|
||
function packLabel(packOrUnitName, packSize) {
|
||
// Accept either (pack object) or (unit_name string, size number)
|
||
let unitName, size;
|
||
if (typeof packOrUnitName === 'object' && packOrUnitName !== null) {
|
||
unitName = packOrUnitName.pack_unit_name;
|
||
size = packOrUnitName.pack_size_in_base_units;
|
||
} else {
|
||
unitName = packOrUnitName;
|
||
size = packSize;
|
||
}
|
||
const displaySize = size === Math.floor(size) ? Math.floor(size) : size;
|
||
const unit = String(unitName || 'pack');
|
||
return `${unit.charAt(0).toUpperCase()}${unit.slice(1)} of ${displaySize}`;
|
||
}
|
||
|
||
function resetDisposeBatchModal() {
|
||
const form = document.getElementById('disposeBatchForm');
|
||
if (form) {
|
||
form.reset();
|
||
}
|
||
const batchIdInput = document.getElementById('disposeBatchId');
|
||
const batchNameInput = document.getElementById('disposeBatchName');
|
||
const stockSummaryInput = document.getElementById('disposeBatchStockSummary');
|
||
if (batchIdInput) batchIdInput.value = '';
|
||
if (batchNameInput) batchNameInput.value = '';
|
||
if (stockSummaryInput) stockSummaryInput.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');
|
||
const receiveDeliveryBtn = document.getElementById('receiveDeliveryBtn');
|
||
|
||
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
if (receiveDeliveryBtn) receiveDeliveryBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
|
||
setupEventListeners();
|
||
loadDrugs();
|
||
}
|
||
|
||
// Handle login
|
||
async function handleLogin(e) {
|
||
e.preventDefault();
|
||
const username = document.getElementById('loginUsername').value;
|
||
const password = document.getElementById('loginPassword').value;
|
||
|
||
try {
|
||
const response = await fetch(`${API_URL}/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Invalid credentials');
|
||
}
|
||
|
||
const data = await response.json();
|
||
accessToken = data.access_token;
|
||
currentUser = data.user;
|
||
|
||
localStorage.setItem('accessToken', accessToken);
|
||
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||
|
||
document.getElementById('loginForm').reset();
|
||
const errorDiv = document.getElementById('loginError');
|
||
if (errorDiv) errorDiv.style.display = 'none';
|
||
showMainApp();
|
||
} catch (error) {
|
||
const errorDiv = document.getElementById('loginError');
|
||
if (errorDiv) {
|
||
errorDiv.textContent = error.message;
|
||
errorDiv.style.display = 'block';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle register
|
||
// Logout
|
||
function handleLogout() {
|
||
localStorage.removeItem('accessToken');
|
||
localStorage.removeItem('currentUser');
|
||
accessToken = null;
|
||
currentUser = null;
|
||
const loginForm = document.getElementById('loginForm');
|
||
if (loginForm) loginForm.reset();
|
||
const registerForm = document.getElementById('registerForm');
|
||
if (registerForm) {
|
||
registerForm.style.display = 'none';
|
||
}
|
||
const form = document.getElementById('loginForm');
|
||
if (form) form.style.display = 'block';
|
||
showLoginPage();
|
||
}
|
||
|
||
// API helper with authentication
|
||
async function apiCall(endpoint, options = {}) {
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
};
|
||
|
||
if (accessToken) {
|
||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||
...options,
|
||
headers
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handleLogout();
|
||
throw new Error('Authentication expired');
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
// Setup event listeners
|
||
function setupEventListeners() {
|
||
const drugForm = document.getElementById('drugForm');
|
||
const variantForm = document.getElementById('variantForm');
|
||
const editVariantForm = document.getElementById('editVariantForm');
|
||
const dispenseForm = document.getElementById('dispenseForm');
|
||
const disposeInventoryForm = document.getElementById('disposeInventoryForm');
|
||
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 disposeInventoryModal = document.getElementById('disposeInventoryModal');
|
||
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 cancelDisposeInventoryBtn = document.getElementById('cancelDisposeInventoryBtn');
|
||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
|
||
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
|
||
const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn');
|
||
const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn');
|
||
const addVariantFromDeliveryBtn = document.getElementById('addVariantFromDeliveryBtn');
|
||
const addPackSizeFromDeliveryBtn = document.getElementById('addPackSizeFromDeliveryBtn');
|
||
const addVariantPackRowBtn = document.getElementById('addVariantPackRowBtn');
|
||
const addEditVariantPackRowBtn = document.getElementById('addEditVariantPackRowBtn');
|
||
const variantUnitSelect = document.getElementById('variantUnit');
|
||
const variantStrengthInput = document.getElementById('variantStrength');
|
||
const editVariantUnitSelect = document.getElementById('editVariantUnit');
|
||
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
|
||
const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]');
|
||
const disposeModeInputs = document.querySelectorAll('input[name="disposeMode"]');
|
||
const disposeSourceInputs = document.querySelectorAll('input[name="disposeSource"]');
|
||
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
|
||
const showAllBtn = document.getElementById('showAllBtn');
|
||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||
const userMenuBtn = document.getElementById('userMenuBtn');
|
||
const adminBtn = document.getElementById('adminBtn');
|
||
const locationsBtn = document.getElementById('locationsBtn');
|
||
const reportsBtn = document.getElementById('reportsBtn');
|
||
const logoutBtn = document.getElementById('logoutBtn');
|
||
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||
|
||
// Modal close buttons
|
||
const closeButtons = document.querySelectorAll('.close');
|
||
|
||
if (drugForm) drugForm.addEventListener('submit', handleAddDrug);
|
||
|
||
// Auto-capitalise the first letter typed in each Add Drug text field
|
||
['drugName', 'drugDescription'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', () => {
|
||
if (el.value.length === 1) el.value = el.value.toUpperCase();
|
||
});
|
||
});
|
||
|
||
// Auto-capitalise the first letter typed in each Dispense text field
|
||
['dispenseAnimal', 'dispenseDosage', 'dispenseNotes'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', () => {
|
||
if (el.value.length === 1) el.value = el.value.toUpperCase();
|
||
});
|
||
});
|
||
|
||
const vetInput = document.getElementById('dispenseVet');
|
||
if (vetInput) {
|
||
vetInput.addEventListener('input', () => {
|
||
const pos = vetInput.selectionStart;
|
||
vetInput.value = toTitleCase(vetInput.value);
|
||
vetInput.setSelectionRange(pos, pos);
|
||
});
|
||
}
|
||
|
||
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
|
||
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
|
||
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
|
||
if (disposeInventoryForm) disposeInventoryForm.addEventListener('submit', handleDisposeInventory);
|
||
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
||
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
|
||
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
|
||
|
||
const batchReceiveForm = document.getElementById('batchReceiveForm');
|
||
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
|
||
if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal));
|
||
|
||
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
|
||
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
|
||
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => {
|
||
_detachDeliveryBarcodeListener();
|
||
closeModal(receiveDeliveryModal);
|
||
});
|
||
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
|
||
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
|
||
if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal);
|
||
|
||
const addPackSizeForm = document.getElementById('addPackSizeForm');
|
||
const cancelAddPackSizeBtn = document.getElementById('cancelAddPackSizeBtn');
|
||
const addPackSizeModal = document.getElementById('addPackSizeModal');
|
||
if (addPackSizeForm) addPackSizeForm.addEventListener('submit', handleAddPackSize);
|
||
if (cancelAddPackSizeBtn) cancelAddPackSizeBtn.addEventListener('click', () => closeModal(addPackSizeModal));
|
||
|
||
const addPackSizeVariantSelect = document.getElementById('addPackSizeVariantSelect');
|
||
if (addPackSizeVariantSelect) {
|
||
addPackSizeVariantSelect.addEventListener('change', () => {
|
||
const variantId = parseInt(addPackSizeVariantSelect.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const hint = document.getElementById('addPackSizeHint');
|
||
if (hint) hint.textContent = variant ? `Base unit: ${variant.unit}` : '';
|
||
});
|
||
}
|
||
if (addVariantPackRowBtn) addVariantPackRowBtn.addEventListener('click', () => appendVariantPackRow());
|
||
if (addEditVariantPackRowBtn) addEditVariantPackRowBtn.addEventListener('click', () => appendEditVariantPackRow());
|
||
if (variantUnitSelect) {
|
||
variantUnitSelect.addEventListener('change', () => {
|
||
refreshVariantPackRowLabels();
|
||
});
|
||
}
|
||
if (editVariantUnitSelect) {
|
||
editVariantUnitSelect.addEventListener('change', () => {
|
||
refreshEditVariantPackRowLabels();
|
||
});
|
||
}
|
||
if (variantStrengthInput && variantUnitSelect) {
|
||
variantStrengthInput.addEventListener('blur', () => {
|
||
variantUnitSelect.value = inferBaseUnitFromStrength(variantStrengthInput.value);
|
||
refreshVariantPackRowLabels();
|
||
});
|
||
}
|
||
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
|
||
dispenseSourceInputs.forEach(input => input.addEventListener('change', () => {
|
||
renderDispenseInventorySourceView();
|
||
toggleDispensePrintFields();
|
||
updateDispenseAllocationSummary();
|
||
}));
|
||
disposeModeInputs.forEach(input => input.addEventListener('change', updateDisposeModeUi));
|
||
disposeSourceInputs.forEach(input => input.addEventListener('change', () => {
|
||
renderDisposeInventorySourceView();
|
||
updateDisposeAllocationSummary();
|
||
}));
|
||
if (dispensePrintEnabled) {
|
||
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
|
||
}
|
||
|
||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||
if (receiveDeliveryBtn) receiveDeliveryBtn.addEventListener('click', openReceiveDeliveryModal);
|
||
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||
updateDispenseDrugSelect();
|
||
updateDispenseModeUi();
|
||
openModal(dispenseModal);
|
||
});
|
||
|
||
if (cancelAddBtn) cancelAddBtn.addEventListener('click', () => closeModal(addModal));
|
||
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
||
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
||
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||
if (cancelDisposeInventoryBtn) cancelDisposeInventoryBtn.addEventListener('click', () => closeModal(disposeInventoryModal));
|
||
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
||
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
|
||
|
||
const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn');
|
||
if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal));
|
||
|
||
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||
|
||
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
|
||
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal')));
|
||
|
||
const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn');
|
||
if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal')));
|
||
|
||
const createLocationForm = document.getElementById('createLocationForm');
|
||
if (createLocationForm) createLocationForm.addEventListener('submit', createLocation);
|
||
|
||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||
if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword);
|
||
|
||
const cancelChangePasswordBtn = document.getElementById('cancelChangePasswordBtn');
|
||
if (cancelChangePasswordBtn) cancelChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('changePasswordModal')));
|
||
|
||
const adminChangePasswordForm = document.getElementById('adminChangePasswordForm');
|
||
if (adminChangePasswordForm) adminChangePasswordForm.addEventListener('submit', handleAdminChangePassword);
|
||
|
||
const cancelAdminChangePasswordBtn = document.getElementById('cancelAdminChangePasswordBtn');
|
||
if (cancelAdminChangePasswordBtn) cancelAdminChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('adminChangePasswordModal')));
|
||
|
||
const cancelGtinMappingBtn = document.getElementById('cancelGtinMappingBtn');
|
||
if (cancelGtinMappingBtn) cancelGtinMappingBtn.addEventListener('click', () => closeModal(document.getElementById('gtinMappingModal')));
|
||
|
||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||
const modal = e.target.closest('.modal');
|
||
if (modal?.id === 'disposeBatchModal') {
|
||
resetDisposeBatchModal();
|
||
}
|
||
if (modal?.id === 'receiveDeliveryModal') {
|
||
_detachDeliveryBarcodeListener();
|
||
}
|
||
closeModal(modal);
|
||
}));
|
||
|
||
if (showAllBtn) showAllBtn.addEventListener('click', () => {
|
||
showLowStockOnly = false;
|
||
updateFilterButtons();
|
||
renderDrugs();
|
||
});
|
||
if (showLowStockBtn) showLowStockBtn.addEventListener('click', () => {
|
||
showLowStockOnly = true;
|
||
updateFilterButtons();
|
||
renderDrugs();
|
||
});
|
||
if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => {
|
||
selectedLocationFilter = e.target.value;
|
||
renderDrugs();
|
||
});
|
||
|
||
// User menu
|
||
if (userMenuBtn) userMenuBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) {
|
||
const isHidden = getComputedStyle(dropdown).display === 'none';
|
||
dropdown.style.display = isHidden ? 'block' : 'none';
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const dropdown = document.getElementById('userDropdown');
|
||
const btn = document.getElementById('userMenuBtn');
|
||
if (dropdown && btn && !btn.contains(e.target) && !dropdown.contains(e.target)) {
|
||
dropdown.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
|
||
if (adminBtn) adminBtn.addEventListener('click', openUserManagement);
|
||
if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement);
|
||
if (reportsBtn) reportsBtn.addEventListener('click', openReportsPage);
|
||
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
|
||
|
||
// Search functionality
|
||
const drugSearch = document.getElementById('drugSearch');
|
||
if (drugSearch) {
|
||
let searchTimeout;
|
||
drugSearch.addEventListener('input', (e) => {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
searchTerm = e.target.value.toLowerCase().trim();
|
||
renderDrugs();
|
||
}, 150);
|
||
});
|
||
}
|
||
|
||
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
|
||
if (dispenseQuantityInput) {
|
||
// Prevent accidental value changes when users scroll while focused.
|
||
dispenseQuantityInput.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
const dispensePackCountInput = document.getElementById('dispensePackCount');
|
||
if (dispensePackCountInput) {
|
||
// Prevent accidental value changes when users scroll while focused.
|
||
dispensePackCountInput.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
}
|
||
|
||
const disposeQuantityInput = document.getElementById('disposeQuantity');
|
||
if (disposeQuantityInput) {
|
||
disposeQuantityInput.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
disposeQuantityInput.addEventListener('input', () => {
|
||
if (getSelectedDisposeMode() !== 'subunit') return;
|
||
|
||
const packSelect = document.getElementById('disposePackSelect');
|
||
const packCount = document.getElementById('disposePackCount');
|
||
const packPreview = document.getElementById('disposePackPreview');
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect')?.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}.`;
|
||
}
|
||
|
||
autoAllocateDisposeBatches();
|
||
});
|
||
}
|
||
|
||
const disposePackCountInput = document.getElementById('disposePackCount');
|
||
if (disposePackCountInput) {
|
||
disposePackCountInput.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
disposePackCountInput.addEventListener('input', updateDisposeQuantityFromPack);
|
||
}
|
||
|
||
// Close modal when clicking outside
|
||
window.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('modal')) {
|
||
if (e.target.id === 'disposeBatchModal') {
|
||
resetDisposeBatchModal();
|
||
}
|
||
closeModal(e.target);
|
||
}
|
||
});
|
||
|
||
// Main-screen barcode scan
|
||
document.addEventListener('keydown', _onMainScreenKeydown);
|
||
}
|
||
|
||
// Load drugs from API
|
||
async function loadDrugs() {
|
||
try {
|
||
const response = await apiCall('/drugs');
|
||
if (!response.ok) throw new Error('Failed to load drugs');
|
||
allDrugs = await response.json();
|
||
|
||
updateLocationFilterOptions();
|
||
renderDrugs();
|
||
updateDispenseDrugSelect();
|
||
|
||
if (_gtinMappingPendingRefresh) {
|
||
_gtinMappingPendingRefresh = false;
|
||
const restore = _gtinMappingPendingRestore || {};
|
||
_gtinMappingPendingRestore = null;
|
||
_gtinMappingPendingVariantId = null;
|
||
|
||
// Resolve new variant by diffing (add-variant flow)
|
||
if (restore._existingVariantIds && restore.drugId) {
|
||
const drug = allDrugs.find(d => d.id === restore.drugId);
|
||
const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id));
|
||
if (newVariant) {
|
||
restore.variantId = newVariant.id;
|
||
// If no pack snapshot, all packs are new — pick the first active one from detail
|
||
if (!restore._existingPackIds) {
|
||
await ensureDrugDetailLoaded(restore.drugId);
|
||
const detailVariant = getVariantById(newVariant.id);
|
||
const firstPack = getActivePacksForVariant(detailVariant)?.[0];
|
||
if (firstPack) restore.packId = firstPack.id;
|
||
}
|
||
}
|
||
}
|
||
// Resolve new pack by diffing (add-pack flow) — need packs from loaded detail
|
||
if (restore._existingPackIds && restore.drugId && restore.variantId) {
|
||
await ensureDrugDetailLoaded(restore.drugId);
|
||
const variant = getVariantById(restore.variantId);
|
||
const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.id));
|
||
if (newPack) restore.packId = newPack.id;
|
||
}
|
||
|
||
await _reinitGtinMappingModal(restore);
|
||
}
|
||
|
||
// After handleAddDrug's loadDrugs fires: find the newly created drug and set up
|
||
// _gtinMappingPendingRefresh so that when handleAddVariant calls loadDrugs next,
|
||
// we auto-select drug + new variant in the GTIN modal.
|
||
if (_gtinMappingWaitingForNewDrug) {
|
||
const newDrug = allDrugs.find(d => !_gtinMappingWaitingForNewDrug.has(d.id));
|
||
_gtinMappingWaitingForNewDrug = null;
|
||
if (newDrug) {
|
||
// handleAddDrug will now open addVariantModal — prepare to catch that save
|
||
_gtinMappingPendingRestore = {
|
||
drugId: newDrug.id,
|
||
variantId: null,
|
||
packId: null,
|
||
_existingVariantIds: new Set((newDrug.variants || []).map(v => v.id))
|
||
};
|
||
_gtinMappingPendingRefresh = true;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading drugs:', error);
|
||
document.getElementById('drugsList').innerHTML =
|
||
'<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 = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||
packSelect.appendChild(option);
|
||
});
|
||
|
||
if (packCount) packCount.value = '';
|
||
|
||
// Auto-select the first available pack
|
||
if (activePacks.length > 0) {
|
||
packSelect.value = String(activePacks[0].id);
|
||
}
|
||
|
||
if (packPreview) {
|
||
packPreview.textContent = activePacks.length > 0
|
||
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
||
: `No active packs for this variant.`;
|
||
}
|
||
|
||
// Update preview to reflect the auto-selected pack
|
||
if (activePacks.length > 0) {
|
||
updateDispenseQuantityFromPack();
|
||
}
|
||
}
|
||
|
||
function updateDispenseModeUi() {
|
||
const mode = getSelectedDispenseMode();
|
||
const quantityGroup = document.getElementById('dispenseQuantityGroup');
|
||
const packRow = document.getElementById('dispensePackRow');
|
||
const quantityInput = document.getElementById('dispenseQuantity');
|
||
const packSelect = document.getElementById('dispensePackSelect');
|
||
const packCount = document.getElementById('dispensePackCount');
|
||
|
||
if (quantityGroup) {
|
||
quantityGroup.style.display = mode === 'subunit' ? '' : 'none';
|
||
}
|
||
if (packRow) {
|
||
packRow.style.display = mode === 'pack' ? '' : 'none';
|
||
}
|
||
|
||
if (quantityInput) {
|
||
quantityInput.required = mode === 'subunit';
|
||
}
|
||
if (packSelect) {
|
||
packSelect.required = mode === 'pack';
|
||
}
|
||
if (packCount) {
|
||
packCount.required = mode === 'pack';
|
||
}
|
||
|
||
updateDispenseSourceUi();
|
||
renderDispenseInventorySourceView();
|
||
toggleDispensePrintFields();
|
||
}
|
||
|
||
function updateDispenseQuantityFromPack() {
|
||
const mode = getSelectedDispenseMode();
|
||
if (mode !== 'pack') return;
|
||
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||
const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10);
|
||
const packCount = parseFloat(document.getElementById('dispensePackCount')?.value || '');
|
||
const quantityInput = document.getElementById('dispenseQuantity');
|
||
const preview = document.getElementById('dispensePackPreview');
|
||
|
||
const variant = getVariantById(variantId);
|
||
if (!quantityInput || !preview || !variant) return;
|
||
|
||
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
||
const totalAvailablePacks = selectedPack ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
|
||
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
||
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
|
||
return;
|
||
}
|
||
if (totalAvailablePacks <= 0) {
|
||
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||
preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
if (packCount > totalAvailablePacks) {
|
||
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||
preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
||
quantityInput.value = String(quantity);
|
||
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
|
||
preview.textContent = selectedPack
|
||
? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available | 1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
|
||
: `Select a pack to calculate quantity.`;
|
||
autoAllocateDispenseBatches();
|
||
}
|
||
|
||
function formatDisplayDate(value) {
|
||
if (!value) {
|
||
return 'Unknown';
|
||
}
|
||
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return parsed.toLocaleDateString();
|
||
}
|
||
|
||
function formatDisplayNumber(value) {
|
||
const numeric = Number(value);
|
||
if (Number.isNaN(numeric)) return '0';
|
||
return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3)));
|
||
}
|
||
|
||
function isBatchExpired(batch) {
|
||
if (!batch?.expiry_date) {
|
||
return false;
|
||
}
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const expiryDate = new Date(`${batch.expiry_date}T00:00:00`);
|
||
return expiryDate < today;
|
||
}
|
||
|
||
function renderVariantInventoryDetails(variant, batches) {
|
||
const activePacks = getActivePacksForVariant(variant);
|
||
const isAdmin = currentUser?.role === 'admin';
|
||
const sortedBatches = [...(batches || [])]
|
||
.filter(batch => Number(batch.quantity) > 0)
|
||
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||
|
||
const packsHtml = activePacks.length > 0
|
||
? activePacks.map(pack => `
|
||
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
||
<strong>${escapeHtml(packLabel(pack))}</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 = sortedBatches.length > 0
|
||
? sortedBatches.map(batch => {
|
||
const locationLabel = getBatchLocationLabel(batch);
|
||
const expired = isBatchExpired(batch);
|
||
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name;
|
||
const stocktakeLabel = hasPackState
|
||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
||
const isHighlighted = _highlightedBatchLot === batch.batch_number && _highlightedVariantId === variant.id;
|
||
const batchCardStyles = isHighlighted
|
||
? 'padding: 8px; background: #fef9c3; border: 2px solid #f59e0b; border-radius: 5px; font-size: 0.9em;'
|
||
: expired
|
||
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
||
: 'padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;';
|
||
const expiryStyles = expired ? 'color: #b91c1c; font-weight: 700;' : 'color: #4b5563;';
|
||
|
||
return `
|
||
<div style="${batchCardStyles}" data-batch-number="${escapeHtml(batch.batch_number)}" data-variant-id="${variant.id}">
|
||
<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 && isAdmin ? `
|
||
<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, "\\'")}', '${String(stocktakeLabel).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, stockSummary = '') {
|
||
const modal = document.getElementById('disposeBatchModal');
|
||
const batchIdInput = document.getElementById('disposeBatchId');
|
||
const batchNameInput = document.getElementById('disposeBatchName');
|
||
const stockSummaryInput = document.getElementById('disposeBatchStockSummary');
|
||
const notesInput = document.getElementById('disposeBatchNotes');
|
||
|
||
if (!modal || !batchIdInput || !batchNameInput || !stockSummaryInput || !notesInput) {
|
||
showToast('Dispose batch modal is unavailable.', 'error');
|
||
return;
|
||
}
|
||
|
||
batchIdInput.value = String(batchId);
|
||
batchNameInput.value = batchNumber;
|
||
stockSummaryInput.value = stockSummary;
|
||
notesInput.value = '';
|
||
openModal(modal);
|
||
}
|
||
|
||
async function handleDisposeBatch(e) {
|
||
e.preventDefault();
|
||
|
||
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
|
||
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
|
||
const modal = document.getElementById('disposeBatchModal');
|
||
|
||
if (!batchId) {
|
||
showToast('Batch disposal context is unavailable.', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/batches/${batchId}/dispose`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ notes: notes || null })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to dispose batch');
|
||
}
|
||
|
||
if (modal) {
|
||
closeDisposeBatchModal();
|
||
}
|
||
// Find which variant this batch belongs to and invalidate its batch cache
|
||
const batchVariantId = (() => {
|
||
for (const [vid, batches] of loadedVariantBatches) {
|
||
if (batches.some(b => b.id === batchId)) return vid;
|
||
}
|
||
return null;
|
||
})();
|
||
if (batchVariantId) {
|
||
loadedVariantBatches.delete(batchVariantId);
|
||
}
|
||
await loadDrugs();
|
||
showToast('Expired batch marked as disposed.', 'success');
|
||
} catch (error) {
|
||
console.error('Error disposing batch:', error);
|
||
showToast('Failed to dispose batch: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function getBatchLocationLabel(batch) {
|
||
return batch.location_name || batch.location?.name || `Location #${batch.location_id}`;
|
||
}
|
||
|
||
function updateLocationFilterOptions() {
|
||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||
if (!locationFilterSelect) return;
|
||
|
||
const previousValue = selectedLocationFilter;
|
||
const locations = new Set();
|
||
|
||
allDrugs.forEach(drug => {
|
||
(drug.locations || []).forEach(loc => locations.add(loc));
|
||
});
|
||
|
||
locationFilterSelect.innerHTML = '<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_unit_name || '').trim().toLowerCase();
|
||
const selectedPackLabel = String(selectedPack.pack_unit_name || '').trim().toLowerCase();
|
||
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
|
||
return true;
|
||
}
|
||
|
||
const batchPackSize = Number(batch.received_pack_size_snapshot || 0);
|
||
const selectedPackSize = Number(selectedPack.pack_size_in_base_units || 0);
|
||
if (batchPackSize > 0 && selectedPackSize > 0 && Math.abs(batchPackSize - selectedPackSize) <= 1e-6) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||
if (mode !== 'pack') {
|
||
return Number(batch.quantity || 0);
|
||
}
|
||
|
||
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||
return 0;
|
||
}
|
||
|
||
const fullPackCount = Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||
return fullPackCount * Number(selectedPack.pack_size_in_base_units || 0);
|
||
}
|
||
|
||
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||
if (getSelectedDispenseSource() === 'legacy') {
|
||
return mode === 'pack' ? 0 : currentDispenseLegacyQuantity;
|
||
}
|
||
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
|
||
}
|
||
|
||
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
|
||
if (getSelectedDispenseSource() === 'legacy') {
|
||
return 0;
|
||
}
|
||
if (!selectedPack) {
|
||
return 0;
|
||
}
|
||
|
||
return currentDispenseBatches.reduce((sum, batch) => {
|
||
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||
return sum;
|
||
}
|
||
return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||
}, 0);
|
||
}
|
||
|
||
function renderDispenseBatchAllocationRows(activeBatches) {
|
||
const batchInfoContent = document.getElementById('batchInfoContent');
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const unitLabel = variant?.unit || 'units';
|
||
|
||
if (!batchInfoContent) return;
|
||
|
||
if (!activeBatches.length) {
|
||
batchInfoContent.innerHTML = '<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' : '1.0';
|
||
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_unit_name
|
||
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: ''}
|
||
${batchAvailabilityNote ? `<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 => {
|
||
// Prevent accidental value changes when users scroll while focused.
|
||
input.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
input.addEventListener('input', updateDispenseAllocationSummary);
|
||
});
|
||
}
|
||
|
||
function renderExpiredDispenseBatches(expiredBatches) {
|
||
const expiredDetails = document.getElementById('expiredBatchDetails');
|
||
const expiredContent = document.getElementById('expiredBatchContent');
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const unitLabel = variant?.unit || 'units';
|
||
|
||
if (!expiredDetails || !expiredContent) {
|
||
return;
|
||
}
|
||
|
||
if (!expiredBatches.length) {
|
||
expiredDetails.style.display = 'none';
|
||
expiredDetails.open = false;
|
||
expiredContent.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
expiredDetails.style.display = 'block';
|
||
expiredContent.innerHTML = expiredBatches.map(batch => {
|
||
const locationLabel = getBatchLocationLabel(batch);
|
||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
|
||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||
|
||
return `
|
||
<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) {
|
||
// Ensure drug detail (with packs) is loaded before populating pack select
|
||
const drugOfVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
|
||
if (drugOfVariant) await ensureDrugDetailLoaded(drugOfVariant.id);
|
||
populateDispensePackSelect(getVariantById(variantId));
|
||
|
||
const vetLabel = document.getElementById('dispenseVetLabel');
|
||
const vetInputEl = document.getElementById('dispenseVet');
|
||
if (vetLabel && vetInputEl) {
|
||
const isControlled = drugOfVariant ? drugOfVariant.is_controlled : false;
|
||
vetLabel.textContent = isControlled ? 'Prescribing Vet *' : 'Prescribing Vet';
|
||
vetLabel.style.color = isControlled ? '#d32f2f' : '';
|
||
vetInputEl.placeholder = isControlled ? "Vet's name (required)" : "Vet's name";
|
||
}
|
||
} else {
|
||
const vetLabel = document.getElementById('dispenseVetLabel');
|
||
const vetInputEl = document.getElementById('dispenseVet');
|
||
if (vetLabel) { vetLabel.textContent = 'Prescribing Vet'; vetLabel.style.color = ''; }
|
||
if (vetInputEl) vetInputEl.placeholder = "Vet's name";
|
||
}
|
||
updateDispenseModeUi();
|
||
|
||
batchInfoSection.style.display = 'block';
|
||
batchInfoContent.innerHTML = '<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>`;
|
||
}
|
||
|
||
function updateDisposeDrugSelect() {
|
||
const select = document.getElementById('disposeDrugSelect');
|
||
if (!select) return;
|
||
|
||
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('disposePackSelect');
|
||
const packCount = document.getElementById('disposePackCount');
|
||
const packPreview = document.getElementById('disposePackPreview');
|
||
const quantityModeRadio = document.getElementById('disposeModeQuantity');
|
||
const batchSourceRadio = document.getElementById('disposeSourceBatch');
|
||
const legacySourceRadio = document.getElementById('disposeSourceLegacy');
|
||
|
||
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;
|
||
|
||
currentDisposeBatches = [];
|
||
currentDisposeLegacyQuantity = 0;
|
||
updateDisposeModeUi();
|
||
}
|
||
|
||
function getSelectedDisposeMode() {
|
||
return document.querySelector('input[name="disposeMode"]:checked')?.value || 'subunit';
|
||
}
|
||
|
||
function hasLegacyDisposeQuantity() {
|
||
return currentDisposeLegacyQuantity > 0;
|
||
}
|
||
|
||
function hasBatchDisposeStock() {
|
||
return currentDisposeBatches.length > 0;
|
||
}
|
||
|
||
function getSelectedDisposeSource() {
|
||
if (getSelectedDisposeMode() === 'pack') return 'batch';
|
||
|
||
const selected = document.querySelector('input[name="disposeSource"]:checked')?.value;
|
||
if (selected) return selected;
|
||
if (hasLegacyDisposeQuantity() && !hasBatchDisposeStock()) return 'legacy';
|
||
return 'batch';
|
||
}
|
||
|
||
function isLegacyDisposeSelected() {
|
||
return getSelectedDisposeMode() === 'subunit' && getSelectedDisposeSource() === 'legacy' && hasLegacyDisposeQuantity();
|
||
}
|
||
|
||
function updateDisposeSourceUi() {
|
||
const sourceGroup = document.getElementById('disposeSourceGroup');
|
||
const sourceHelp = document.getElementById('disposeSourceHelp');
|
||
const batchRadio = document.getElementById('disposeSourceBatch');
|
||
const legacyRadio = document.getElementById('disposeSourceLegacy');
|
||
const hasBatches = hasBatchDisposeStock();
|
||
const hasLegacy = hasLegacyDisposeQuantity();
|
||
|
||
if (!sourceGroup || !batchRadio || !legacyRadio) return;
|
||
|
||
if (getSelectedDisposeMode() === '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(currentDisposeLegacyQuantity)} loose legacy units.`;
|
||
} else if (hasLegacy) {
|
||
sourceHelp.textContent = `Legacy loose stock available: ${formatDisplayNumber(currentDisposeLegacyQuantity)}.`;
|
||
} else {
|
||
sourceHelp.textContent = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
function getDisposeRequestedQuantity() {
|
||
const quantity = parseFloat(document.getElementById('disposeQuantity')?.value || '');
|
||
return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity;
|
||
}
|
||
|
||
function getSelectedDisposePack() {
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
|
||
const packId = parseInt(document.getElementById('disposePackSelect')?.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
if (!variant || Number.isNaN(packId)) return null;
|
||
return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null;
|
||
}
|
||
|
||
function getBatchAvailableDisposeQuantity(batch, mode = getSelectedDisposeMode(), selectedPack = getSelectedDisposePack()) {
|
||
if (mode !== 'pack') return Number(batch.quantity || 0);
|
||
if (!batchMatchesSelectedPack(batch, selectedPack)) return 0;
|
||
return Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) * Number(selectedPack.pack_size_in_base_units || 0);
|
||
}
|
||
|
||
function getTotalAvailableDisposeQuantity(mode = getSelectedDisposeMode(), selectedPack = getSelectedDisposePack()) {
|
||
if (getSelectedDisposeSource() === 'legacy') {
|
||
return mode === 'pack' ? 0 : currentDisposeLegacyQuantity;
|
||
}
|
||
return currentDisposeBatches.reduce((sum, batch) => sum + getBatchAvailableDisposeQuantity(batch, mode, selectedPack), 0);
|
||
}
|
||
|
||
function getTotalAvailableDisposePackCount(selectedPack = getSelectedDisposePack()) {
|
||
if (getSelectedDisposeSource() === 'legacy' || !selectedPack) return 0;
|
||
return currentDisposeBatches.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 populateDisposePackSelect(variant) {
|
||
const packSelect = document.getElementById('disposePackSelect');
|
||
const packCount = document.getElementById('disposePackCount');
|
||
const packPreview = document.getElementById('disposePackPreview');
|
||
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 = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||
packSelect.appendChild(option);
|
||
});
|
||
|
||
if (packCount) packCount.value = '';
|
||
if (activePacks.length > 0) packSelect.value = String(activePacks[0].id);
|
||
if (packPreview) {
|
||
packPreview.textContent = activePacks.length > 0
|
||
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
||
: 'No active packs for this variant.';
|
||
}
|
||
if (activePacks.length > 0) updateDisposeQuantityFromPack();
|
||
}
|
||
|
||
function updateDisposeModeUi() {
|
||
const mode = getSelectedDisposeMode();
|
||
const quantityGroup = document.getElementById('disposeQuantityGroup');
|
||
const packRow = document.getElementById('disposePackRow');
|
||
const quantityInput = document.getElementById('disposeQuantity');
|
||
const packSelect = document.getElementById('disposePackSelect');
|
||
const packCount = document.getElementById('disposePackCount');
|
||
|
||
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';
|
||
|
||
updateDisposeSourceUi();
|
||
renderDisposeInventorySourceView();
|
||
}
|
||
|
||
function updateDisposeQuantityFromPack() {
|
||
if (getSelectedDisposeMode() !== 'pack') return;
|
||
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
|
||
const packId = parseInt(document.getElementById('disposePackSelect')?.value || '', 10);
|
||
const packCount = parseFloat(document.getElementById('disposePackCount')?.value || '');
|
||
const quantityInput = document.getElementById('disposeQuantity');
|
||
const preview = document.getElementById('disposePackPreview');
|
||
const variant = getVariantById(variantId);
|
||
if (!quantityInput || !preview || !variant) return;
|
||
|
||
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
||
const totalAvailablePacks = selectedPack ? getTotalAvailableDisposePackCount(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;
|
||
}
|
||
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
||
quantityInput.value = String(quantity);
|
||
if (totalAvailablePacks <= 0) {
|
||
preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`;
|
||
} else if (packCount > totalAvailablePacks) {
|
||
preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`;
|
||
} else {
|
||
preview.textContent = `${packCount} x ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
|
||
}
|
||
autoAllocateDisposeBatches();
|
||
return;
|
||
}
|
||
|
||
quantityInput.value = '';
|
||
preview.textContent = selectedPack
|
||
? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`
|
||
: 'Select a pack and whole-number count.';
|
||
autoAllocateDisposeBatches();
|
||
}
|
||
|
||
function renderDisposeBatchAllocationRows(activeBatches) {
|
||
const batchInfoContent = document.getElementById('disposeBatchInfoContent');
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect').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 = getSelectedDisposeMode();
|
||
const selectedPack = getSelectedDisposePack();
|
||
|
||
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 daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
|
||
const statusColor = daysToExpiry <= 7 ? '#ff9800' : '#4caf50';
|
||
const expiryStatus = daysToExpiry <= 7 ? `${daysToExpiry}d left` : 'OK';
|
||
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||
: 0;
|
||
const allocationLabel = mode === 'pack' ? 'Dispose Packs' : 'Dispose';
|
||
const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDisposeQuantity(batch, mode, selectedPack);
|
||
const allocationStep = mode === 'pack' ? '1' : '1.0';
|
||
const batchAvailabilityNote = mode === 'pack'
|
||
? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0 ? 'No full packs available in this batch' : '')
|
||
: `Available to dispose: ${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_unit_name
|
||
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: ''}
|
||
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 0;">
|
||
<label for="disposeBatchAllocation-${batch.id}">${allocationLabel}</label>
|
||
<input type="number" id="disposeBatchAllocation-${batch.id}" class="dispose-batch-allocation" data-batch-id="${batch.id}" min="0" max="${allocationMax}" step="${allocationStep}" value="0">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
batchInfoContent.querySelectorAll('.dispose-batch-allocation').forEach(input => {
|
||
input.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
input.addEventListener('input', updateDisposeAllocationSummary);
|
||
});
|
||
}
|
||
|
||
function renderExpiredDisposeBatches(expiredBatches) {
|
||
const expiredDetails = document.getElementById('disposeExpiredBatchDetails');
|
||
const expiredContent = document.getElementById('disposeExpiredBatchContent');
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect').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 stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
|
||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||
|
||
return `
|
||
<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 ${formatDisplayDate(batch.expiry_date)}</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 renderDisposeInventorySourceView() {
|
||
const batchInfoContent = document.getElementById('disposeBatchInfoContent');
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
if (!batchInfoContent || !variant) return;
|
||
|
||
if (getSelectedDisposeMode() === 'pack') {
|
||
if (hasBatchDisposeStock()) {
|
||
renderDisposeBatchAllocationRows(currentDisposeBatches);
|
||
autoAllocateDisposeBatches();
|
||
} else if (hasLegacyDisposeQuantity()) {
|
||
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack disposal is unavailable.</div>`;
|
||
updateDisposeAllocationSummary();
|
||
} else {
|
||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">No active batches available for this variant</p>';
|
||
updateDisposeAllocationSummary();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isLegacyDisposeSelected()) {
|
||
const extraText = hasBatchDisposeStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispose 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(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}</div>`;
|
||
updateDisposeAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
if (hasBatchDisposeStock()) {
|
||
renderDisposeBatchAllocationRows(currentDisposeBatches);
|
||
autoAllocateDisposeBatches();
|
||
return;
|
||
}
|
||
|
||
if (hasLegacyDisposeQuantity()) {
|
||
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispose by quantity only.</div>`;
|
||
updateDisposeAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">No active batches available for this variant</p>';
|
||
updateDisposeAllocationSummary();
|
||
}
|
||
|
||
async function updateDisposeBatchInfo() {
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect').value);
|
||
const batchInfoSection = document.getElementById('disposeBatchInfoSection');
|
||
const batchInfoContent = document.getElementById('disposeBatchInfoContent');
|
||
|
||
if (!variantId) {
|
||
batchInfoSection.style.display = 'none';
|
||
const packSelect = document.getElementById('disposePackSelect');
|
||
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
currentDisposeBatches = [];
|
||
currentDisposeLegacyQuantity = 0;
|
||
renderExpiredDisposeBatches([]);
|
||
updateDisposeSourceUi();
|
||
updateDisposeAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
const variant = getVariantById(variantId);
|
||
if (variant) {
|
||
const drugOfVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
|
||
if (drugOfVariant) await ensureDrugDetailLoaded(drugOfVariant.id);
|
||
populateDisposePackSelect(getVariantById(variantId));
|
||
}
|
||
updateDisposeModeUi();
|
||
|
||
batchInfoSection.style.display = 'block';
|
||
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
|
||
renderExpiredDisposeBatches([]);
|
||
|
||
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);
|
||
currentDisposeLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity);
|
||
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||
currentDisposeBatches = activeBatches;
|
||
renderExpiredDisposeBatches(expiredBatches);
|
||
updateDisposeSourceUi();
|
||
renderDisposeInventorySourceView();
|
||
} catch (error) {
|
||
console.error('Error loading disposal batches:', error);
|
||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
|
||
currentDisposeBatches = [];
|
||
currentDisposeLegacyQuantity = 0;
|
||
renderExpiredDisposeBatches([]);
|
||
updateDisposeSourceUi();
|
||
updateDisposeAllocationSummary();
|
||
}
|
||
}
|
||
|
||
function autoAllocateDisposeBatches() {
|
||
const requestedQuantity = getDisposeRequestedQuantity();
|
||
const allocationInputs = Array.from(document.querySelectorAll('.dispose-batch-allocation'));
|
||
const mode = getSelectedDisposeMode();
|
||
const selectedPack = getSelectedDisposePack();
|
||
if (!allocationInputs.length) {
|
||
updateDisposeAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
if (isLegacyDisposeSelected()) {
|
||
allocationInputs.forEach(input => { input.value = '0'; });
|
||
updateDisposeAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
let remaining = mode === 'pack'
|
||
? Math.max(0, Math.round(parseFloat(document.getElementById('disposePackCount')?.value || '0')) || 0)
|
||
: requestedQuantity;
|
||
allocationInputs.forEach(input => {
|
||
const batchId = parseInt(input.dataset.batchId || '', 10);
|
||
const batch = currentDisposeBatches.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(getBatchAvailableDisposeQuantity(batch, mode, selectedPack), Math.max(remaining, 0));
|
||
input.value = allocation > 0 ? String(Number(allocation.toFixed(3))) : '0';
|
||
}
|
||
remaining -= allocation;
|
||
});
|
||
|
||
updateDisposeAllocationSummary();
|
||
}
|
||
|
||
function updateDisposeAllocationSummary() {
|
||
const summarySection = document.getElementById('disposeAllocationSummary');
|
||
const summaryContent = document.getElementById('disposeAllocationSummaryContent');
|
||
const requestedQuantity = getDisposeRequestedQuantity();
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect').value || '', 10);
|
||
const unitLabel = getVariantById(variantId)?.unit || 'units';
|
||
const inputs = Array.from(document.querySelectorAll('.dispose-batch-allocation'));
|
||
const mode = getSelectedDisposeMode();
|
||
const selectedPack = getSelectedDisposePack();
|
||
const legacyStockOnly = isLegacyDisposeSelected();
|
||
const totalAvailableQuantity = getTotalAvailableDisposeQuantity(mode, selectedPack);
|
||
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDisposePackCount(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 = currentDisposeBatches.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;
|
||
}
|
||
return value - getBatchAvailableDisposeQuantity(batch, mode, selectedPack) > 1e-6;
|
||
});
|
||
|
||
const difference = requestedQuantity - allocatedQuantity;
|
||
summarySection.style.display = 'block';
|
||
|
||
if (requestedQuantity <= 0) {
|
||
summaryContent.innerHTML = legacyStockOnly
|
||
? `<span style="color: #666;">Enter a disposal quantity. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.</span>`
|
||
: '<span style="color: #666;">Enter a disposal amount to allocate batches.</span>';
|
||
return;
|
||
}
|
||
|
||
if (legacyStockOnly) {
|
||
if (requestedQuantity - currentDisposeLegacyQuantity > 1e-6) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.</span>`;
|
||
return;
|
||
}
|
||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Disposing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.</span>`;
|
||
return;
|
||
}
|
||
|
||
if (mode === 'pack' && selectedPack) {
|
||
const requestedPackCount = parseFloat(document.getElementById('disposePackCount')?.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 dispose.</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('disposePackCount')?.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) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} 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 using the pre-computed locations list in the summary
|
||
if (selectedLocationFilter) {
|
||
drugsToShow = drugsToShow.filter(drug =>
|
||
(drug.locations || []).includes(selectedLocationFilter)
|
||
);
|
||
}
|
||
|
||
// Sort alphabetically by drug name
|
||
drugsToShow = drugsToShow.sort((a, b) =>
|
||
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||
);
|
||
|
||
if (drugsToShow.length === 0) {
|
||
drugsList.innerHTML = '<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 isAdmin = currentUser.role === 'admin';
|
||
const isControlled = drug.is_controlled;
|
||
const drugDetail = loadedDrugDetails.get(drug.id);
|
||
|
||
let variantsHtml = '';
|
||
if (isExpanded) {
|
||
if (!drugDetail) {
|
||
variantsHtml = '<div class="variant-item" style="padding: 12px; color: #6b7280; font-style: italic;">Loading variants…</div>';
|
||
} else {
|
||
variantsHtml = drug.variants.map(summaryVariant => {
|
||
// Use detail variant (has packs); fall back to summary if not found
|
||
const variant = (drugDetail.variants || []).find(v => v.id === summaryVariant.id) || summaryVariant;
|
||
const variantIsLowStock = summaryVariant.quantity <= summaryVariant.low_stock_threshold;
|
||
const variantExpanded = expandedVariants.has(summaryVariant.id);
|
||
// expiredQuantity is pre-computed in the summary
|
||
const expiredQuantity = summaryVariant.expired_quantity || 0;
|
||
const inDateQuantity = Math.max(0, Number(summaryVariant.quantity || 0) - expiredQuantity);
|
||
const quantityDisplay = expiredQuantity > 0
|
||
? `${formatDisplayNumber(inDateQuantity)} ${escapeHtml(summaryVariant.unit)} (${formatDisplayNumber(expiredQuantity)} expired)`
|
||
: `${formatDisplayNumber(summaryVariant.quantity)} ${escapeHtml(summaryVariant.unit)}`;
|
||
const batches = loadedVariantBatches.get(summaryVariant.id);
|
||
const batchesSection = (() => {
|
||
if (!variantExpanded) return '';
|
||
if (!batches) return '<div style="padding: 10px; color: #6b7280; font-style: italic;">Loading batches…</div>';
|
||
return renderVariantInventoryDetails(variant, batches);
|
||
})();
|
||
return `
|
||
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${summaryVariant.id}, event)">
|
||
<div class="variant-info">
|
||
<div class="variant-details">
|
||
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(summaryVariant.strength)}</div>
|
||
<div class="variant-quantity">${quantityDisplay}</div>
|
||
</div>
|
||
<div class="variant-status">
|
||
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
|
||
${variantIsLowStock ? 'Low Stock' : 'OK'}
|
||
</span>
|
||
<span style="margin-left: 8px; font-size: 0.85em; color: #475569;">Inventory ${variantExpanded ? '▼' : '▶'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="variant-actions">
|
||
${!isReadOnly ? `
|
||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${summaryVariant.id})">💊 Dispense</button>
|
||
${isAdmin ? `<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeVariant(${summaryVariant.id})">Dispose</button>` : ''}
|
||
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${summaryVariant.id})">Edit</button>
|
||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${summaryVariant.id})" title="${summaryVariant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
|
||
` : ''}
|
||
</div>
|
||
${batchesSection}
|
||
</div>
|
||
`;
|
||
}).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-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');
|
||
const createdDrug = await drugResponse.json();
|
||
|
||
document.getElementById('drugForm').reset();
|
||
expandedDrugs.add(createdDrug.id);
|
||
await loadDrugs();
|
||
closeModal(document.getElementById('addModal'));
|
||
openAddVariantModal(createdDrug.id);
|
||
showToast('Drug added successfully. Add the first variant next.', 'success');
|
||
} catch (error) {
|
||
console.error('Error adding drug:', error);
|
||
showToast('Failed to add drug. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Handle dispense drug form
|
||
async function handleDispenseDrug(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||
const dispenseMode = getSelectedDispenseMode();
|
||
const dispenseSource = getSelectedDispenseSource();
|
||
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
|
||
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
|
||
const animalName = document.getElementById('dispenseAnimal').value;
|
||
const vetName = document.getElementById('dispenseVet')?.value.trim() || '';
|
||
const notes = document.getElementById('dispenseNotes').value;
|
||
const printEnabled = document.getElementById('dispensePrintEnabled')?.checked;
|
||
const dosage = document.getElementById('dispenseDosage')?.value.trim() || '';
|
||
const legacyExpiryDate = document.getElementById('dispenseLegacyExpiry')?.value || '';
|
||
|
||
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
|
||
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
|
||
const variant = getVariantById(variantId);
|
||
const drugForVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
|
||
const legacyStockOnly = isLegacyDispenseSelected();
|
||
const selectedPack = variant && selectedPackId
|
||
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
|
||
: null;
|
||
|
||
if (!['subunit', 'pack'].includes(dispenseMode)) {
|
||
showToast('Please select a valid dispense mode.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (dispenseMode === 'pack') {
|
||
if (legacyStockOnly) {
|
||
showToast('Whole-pack dispensing is unavailable for stock that is not attached to batches.', 'warning');
|
||
return;
|
||
}
|
||
if (!selectedPack) {
|
||
showToast('Please select a pack type for whole-pack dispensing.', 'warning');
|
||
return;
|
||
}
|
||
if (selectedPackCount == null || Number.isNaN(selectedPackCount) || selectedPackCount <= 0) {
|
||
showToast('Please enter a valid pack count greater than zero.', 'warning');
|
||
return;
|
||
}
|
||
if (Math.abs(selectedPackCount - Math.round(selectedPackCount)) > 1e-6) {
|
||
showToast('Whole-pack dispensing requires a whole-number pack count.', 'warning');
|
||
return;
|
||
}
|
||
|
||
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
|
||
}
|
||
|
||
const allocationEntries = Array.from(document.querySelectorAll('.dispense-batch-allocation'))
|
||
.map(input => ({
|
||
batch_id: parseInt(input.dataset.batchId || '', 10),
|
||
entered_value: parseFloat(input.value || '0')
|
||
}))
|
||
.filter(entry => !Number.isNaN(entry.batch_id) && !Number.isNaN(entry.entered_value) && entry.entered_value > 0);
|
||
|
||
const allocations = allocationEntries
|
||
.map(entry => ({
|
||
batch_id: entry.batch_id,
|
||
quantity: dispenseMode === 'pack' && selectedPack
|
||
? entry.entered_value * selectedPack.pack_size_in_base_units
|
||
: entry.entered_value
|
||
}));
|
||
|
||
const allocatedTotal = allocations.reduce((sum, entry) => sum + entry.quantity, 0);
|
||
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(dispenseMode, selectedPack);
|
||
const totalAvailablePacks = dispenseMode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||
|
||
if (!variantId || isNaN(quantity) || quantity <= 0) {
|
||
showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (quantity - totalAvailableQuantity > 1e-6) {
|
||
if (dispenseMode === 'pack' && selectedPack) {
|
||
showToast(`Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`, 'warning');
|
||
} else {
|
||
showToast(`Requested quantity exceeds available stock (${formatDisplayNumber(totalAvailableQuantity)} available).`, 'warning');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!legacyStockOnly && allocations.length === 0) {
|
||
showToast('Allocate quantity against at least one batch.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (dispenseMode === 'pack' && selectedPack) {
|
||
const invalidPackAllocation = allocationEntries.find(entry => {
|
||
const batch = currentDispenseBatches.find(row => row.id === entry.batch_id);
|
||
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||
: 0;
|
||
return !batch
|
||
|| entry.entered_value - availableFullPacks > 1e-6
|
||
|| Math.abs(entry.entered_value - Math.round(entry.entered_value)) > 1e-6;
|
||
});
|
||
|
||
if (invalidPackAllocation) {
|
||
showToast('Whole-pack allocations must use batches with available full packs and whole-pack multiples only.', 'warning');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!legacyStockOnly && Math.abs(allocatedTotal - quantity) > 1e-6) {
|
||
showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning');
|
||
return;
|
||
}
|
||
|
||
const printExpiryDate = printEnabled
|
||
? (legacyStockOnly ? legacyExpiryDate : getLatestAllocatedBatchExpiryDate(allocationEntries))
|
||
: null;
|
||
|
||
if (printEnabled && (!animalName.trim() || !dosage)) {
|
||
showToast('Animal name/ID and dosage instructions are required to print a label.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (printEnabled && !printExpiryDate) {
|
||
showToast(legacyStockOnly
|
||
? 'Enter an expiry date to print a label for legacy stock.'
|
||
: 'Unable to determine a batch expiry date for the selected allocation.', 'warning');
|
||
return;
|
||
}
|
||
|
||
const dispensingData = {
|
||
drug_variant_id: variantId,
|
||
quantity: quantity,
|
||
dispense_mode: dispenseMode,
|
||
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
|
||
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
|
||
dispense_source: dispenseSource,
|
||
animal_name: animalName || null,
|
||
prescribing_vet: vetName || null,
|
||
notes: notes || null,
|
||
allocations
|
||
};
|
||
|
||
if (drugForVariant && drugForVariant.is_controlled && !vetName) {
|
||
showToast('Prescribing vet name is required for controlled drugs.', 'warning');
|
||
document.getElementById('dispenseVet')?.focus();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/dispense', {
|
||
method: 'POST',
|
||
body: JSON.stringify(dispensingData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to dispense drug');
|
||
}
|
||
|
||
let successMessage = 'Drug dispensed successfully!';
|
||
let toastType = 'success';
|
||
|
||
if (printEnabled) {
|
||
try {
|
||
const { drug } = getDrugContextForVariant(variantId);
|
||
const labelResult = await requestLabelPrint({
|
||
animalName: animalName.trim(),
|
||
drugName: drug?.name || 'Unknown drug',
|
||
variantStrength: variant?.strength || '',
|
||
quantity,
|
||
unit: variant?.unit || 'units',
|
||
dosage,
|
||
expiryDate: printExpiryDate
|
||
});
|
||
|
||
if (!labelResult.success) {
|
||
successMessage = `Drug dispensed, but label printing failed: ${labelResult.message}`;
|
||
toastType = 'warning';
|
||
} else {
|
||
successMessage = 'Drug dispensed and label printed successfully!';
|
||
}
|
||
} catch (printError) {
|
||
console.error('Error printing label after dispensing:', printError);
|
||
successMessage = 'Drug dispensed, but label printing failed: ' + printError.message;
|
||
toastType = 'warning';
|
||
}
|
||
}
|
||
|
||
document.getElementById('dispenseForm').reset();
|
||
resetDispensePrintFields();
|
||
closeModal(document.getElementById('dispenseModal'));
|
||
loadedVariantBatches.delete(variantId); // invalidate level-3 batch cache
|
||
await loadDrugs();
|
||
showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined);
|
||
} catch (error) {
|
||
console.error('Error dispensing drug:', error);
|
||
showToast('Failed to dispense drug: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleDisposeInventory(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('disposeDrugSelect').value);
|
||
let quantity = parseFloat(document.getElementById('disposeQuantity').value);
|
||
const disposeMode = getSelectedDisposeMode();
|
||
const disposeSource = getSelectedDisposeSource();
|
||
const requestedPackIdValue = document.getElementById('disposePackSelect').value;
|
||
const requestedPackCountValue = document.getElementById('disposePackCount').value;
|
||
const notes = document.getElementById('disposeNotes')?.value.trim() || '';
|
||
|
||
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
|
||
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
|
||
const variant = getVariantById(variantId);
|
||
const legacyStockOnly = isLegacyDisposeSelected();
|
||
const selectedPack = variant && selectedPackId
|
||
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
|
||
: null;
|
||
|
||
if (!['subunit', 'pack'].includes(disposeMode)) {
|
||
showToast('Please select a valid disposal mode.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (disposeMode === 'pack') {
|
||
if (legacyStockOnly) {
|
||
showToast('Whole-pack disposal is unavailable for stock that is not attached to batches.', 'warning');
|
||
return;
|
||
}
|
||
if (!selectedPack) {
|
||
showToast('Please select a pack type for whole-pack disposal.', '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 disposal requires a whole-number pack count.', 'warning');
|
||
return;
|
||
}
|
||
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
|
||
}
|
||
|
||
const allocationEntries = Array.from(document.querySelectorAll('.dispose-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: disposeMode === '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 = getTotalAvailableDisposeQuantity(disposeMode, selectedPack);
|
||
const totalAvailablePacks = disposeMode === 'pack' ? getTotalAvailableDisposePackCount(selectedPack) : 0;
|
||
|
||
if (!variantId || Number.isNaN(quantity) || quantity <= 0) {
|
||
showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (quantity - totalAvailableQuantity > 1e-6) {
|
||
if (disposeMode === '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 (disposeMode === 'pack' && selectedPack) {
|
||
const invalidPackAllocation = allocationEntries.find(entry => {
|
||
const batch = currentDisposeBatches.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 disposal quantity.', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Dispose ${formatDisplayNumber(quantity)} ${variant?.unit || 'units'} from inventory?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/dispose', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
drug_variant_id: variantId,
|
||
quantity,
|
||
dispense_mode: disposeMode,
|
||
requested_pack_id: disposeMode === 'pack' ? selectedPackId : null,
|
||
requested_pack_count: disposeMode === 'pack' ? selectedPackCount : null,
|
||
dispense_source: disposeSource,
|
||
notes: notes || null,
|
||
allocations
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to dispose inventory');
|
||
}
|
||
|
||
document.getElementById('disposeInventoryForm').reset();
|
||
closeModal(document.getElementById('disposeInventoryModal'));
|
||
loadedVariantBatches.delete(variantId);
|
||
await loadDrugs();
|
||
showToast('Inventory disposed successfully.', 'success');
|
||
} catch (error) {
|
||
console.error('Error disposing inventory:', error);
|
||
showToast('Failed to dispose inventory: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Open edit modal
|
||
function openEditModal(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
|
||
document.getElementById('editDrugId').value = drug.id;
|
||
document.getElementById('editDrugName').value = drug.name;
|
||
document.getElementById('editDrugDescription').value = drug.description || '';
|
||
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
|
||
|
||
openModal(document.getElementById('editModal'));
|
||
}
|
||
|
||
// Close edit modal
|
||
function closeEditModal() {
|
||
closeModal(document.getElementById('editModal'));
|
||
document.getElementById('editForm').reset();
|
||
}
|
||
|
||
// Show variants for a drug
|
||
async function toggleDrugExpansion(drugId) {
|
||
if (expandedDrugs.has(drugId)) {
|
||
expandedDrugs.delete(drugId);
|
||
const collapsedDrug = allDrugs.find(drug => drug.id === drugId);
|
||
if (collapsedDrug) {
|
||
(collapsedDrug.variants || []).forEach(variant => expandedVariants.delete(variant.id));
|
||
}
|
||
renderDrugs();
|
||
} else {
|
||
expandedDrugs.add(drugId);
|
||
renderDrugs(); // show loading state immediately
|
||
await ensureDrugDetailLoaded(drugId);
|
||
renderDrugs();
|
||
}
|
||
}
|
||
|
||
async function toggleVariantExpansion(variantId, event) {
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
|
||
if (expandedVariants.has(variantId)) {
|
||
expandedVariants.delete(variantId);
|
||
renderDrugs();
|
||
} else {
|
||
expandedVariants.add(variantId);
|
||
if (!loadedVariantBatches.has(variantId)) {
|
||
renderDrugs(); // show loading state immediately
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}/batches`);
|
||
if (response.ok) {
|
||
loadedVariantBatches.set(variantId, await response.json());
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to load batches for variant ${variantId}:`, error);
|
||
}
|
||
}
|
||
renderDrugs();
|
||
}
|
||
}
|
||
|
||
// Open add variant modal
|
||
function openAddVariantModal(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
|
||
currentDrug = drug;
|
||
const form = document.getElementById('variantForm');
|
||
if (form) form.reset();
|
||
document.getElementById('variantDrugId').value = drug.id;
|
||
initializeVariantPackRows();
|
||
openModal(document.getElementById('addVariantModal'));
|
||
}
|
||
|
||
function inferBaseUnitFromStrength(strength) {
|
||
const value = String(strength || '').toLowerCase();
|
||
if (value.includes('/ml') || value.includes('ml')) return 'ml';
|
||
if (value.includes('tablet')) return 'tablets';
|
||
if (value.includes('capsule')) return 'capsules';
|
||
return 'tablets';
|
||
}
|
||
|
||
function getVariantPackRowsContainer() {
|
||
return document.getElementById('variantPackRows');
|
||
}
|
||
|
||
function getEditVariantPackRowsContainer() {
|
||
return document.getElementById('editVariantPackRows');
|
||
}
|
||
|
||
function refreshVariantPackRowLabels() {
|
||
const container = getVariantPackRowsContainer();
|
||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||
if (!container) return;
|
||
|
||
container.querySelectorAll('.variant-pack-row').forEach(row => {
|
||
const packUnit = row.querySelector('.variant-pack-unit')?.value || 'pack';
|
||
const label = row.querySelector('.variant-pack-size-label');
|
||
if (!label) return;
|
||
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
|
||
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
|
||
});
|
||
}
|
||
|
||
function refreshEditVariantPackRowLabels() {
|
||
const container = getEditVariantPackRowsContainer();
|
||
const baseUnit = document.getElementById('editVariantUnit')?.value || 'units';
|
||
if (!container) return;
|
||
|
||
container.querySelectorAll('.edit-variant-pack-row').forEach(row => {
|
||
const packUnit = row.querySelector('.edit-variant-pack-unit')?.value || 'pack';
|
||
const label = row.querySelector('.edit-variant-pack-size-label');
|
||
if (!label) return;
|
||
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
|
||
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
|
||
});
|
||
}
|
||
|
||
function appendVariantPackRow(prefill = {}) {
|
||
const container = getVariantPackRowsContainer();
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'delivery-line variant-pack-row';
|
||
|
||
const selectedPackUnit = prefill.packUnit || 'box';
|
||
const selectedSize = prefill.packSize || '';
|
||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||
|
||
row.innerHTML = `
|
||
<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="1" step="1" 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: 'box' });
|
||
}
|
||
|
||
function appendEditVariantPackRow(prefill = {}) {
|
||
const container = getEditVariantPackRowsContainer();
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'delivery-line edit-variant-pack-row';
|
||
|
||
const selectedPackUnit = prefill.packUnit || 'bottle';
|
||
const selectedSize = prefill.packSize || '';
|
||
const baseUnit = document.getElementById('editVariantUnit')?.value || 'units';
|
||
|
||
row.innerHTML = `
|
||
<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'));
|
||
loadedDrugDetails.delete(drugId); // invalidate level-2 cache for this drug
|
||
await loadDrugs();
|
||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||
refreshDeliveryVariantSelects();
|
||
}
|
||
renderDrugs();
|
||
showToast('Variant added successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error adding variant:', error);
|
||
showToast('Failed to add variant. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Open edit variant modal
|
||
function openEditVariantModal(variantId) {
|
||
// Find the variant from all drugs
|
||
let variant = null;
|
||
for (const drug of allDrugs) {
|
||
variant = drug.variants.find(v => v.id === variantId);
|
||
if (variant) break;
|
||
}
|
||
|
||
if (!variant) return;
|
||
|
||
document.getElementById('editVariantId').value = variant.id;
|
||
document.getElementById('editVariantStrength').value = variant.strength;
|
||
document.getElementById('editVariantQuantity').value = variant.quantity;
|
||
document.getElementById('editVariantUnit').value = variant.unit;
|
||
document.getElementById('editVariantThreshold').value = variant.low_stock_threshold;
|
||
|
||
const hasInventoryContext = Boolean(variant.has_inventory_history);
|
||
setEditVariantFieldLockState(hasInventoryContext);
|
||
initializeEditVariantPackRows();
|
||
|
||
openModal(document.getElementById('editVariantModal'));
|
||
}
|
||
|
||
// Handle edit variant form
|
||
async function handleEditVariant(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('editVariantId').value);
|
||
const strengthInput = document.getElementById('editVariantStrength');
|
||
const quantityInput = document.getElementById('editVariantQuantity');
|
||
const unitSelect = document.getElementById('editVariantUnit');
|
||
const baseUnit = unitSelect.value;
|
||
const variantData = {
|
||
low_stock_threshold: parseFloat(document.getElementById('editVariantThreshold').value)
|
||
};
|
||
|
||
if (!strengthInput.disabled && !quantityInput.disabled && !unitSelect.disabled) {
|
||
const quantityValue = parseFloat(quantityInput.value);
|
||
if (Number.isNaN(quantityValue) || quantityValue < 0) {
|
||
showToast('Please enter a valid quantity (0 or greater)', 'warning');
|
||
return;
|
||
}
|
||
|
||
variantData.strength = strengthInput.value;
|
||
variantData.quantity = quantityValue;
|
||
variantData.unit = baseUnit;
|
||
variantData.base_unit = baseUnit;
|
||
}
|
||
|
||
const packRows = Array.from(document.querySelectorAll('#editVariantPackRows .edit-variant-pack-row'));
|
||
const newPackPayloads = [];
|
||
for (let i = 0; i < packRows.length; i += 1) {
|
||
const row = packRows[i];
|
||
const packUnitRaw = row.querySelector('.edit-variant-pack-unit')?.value || '';
|
||
const packSizeRaw = row.querySelector('.edit-variant-pack-size')?.value || '';
|
||
|
||
if (!packUnitRaw && !packSizeRaw) {
|
||
continue;
|
||
}
|
||
|
||
const packSize = parseFloat(packSizeRaw);
|
||
if (!packUnitRaw || Number.isNaN(packSize) || packSize <= 0) {
|
||
showToast(`Pack row ${i + 1} is incomplete`, 'warning');
|
||
return;
|
||
}
|
||
|
||
const normalizedPackUnit = packUnitRaw.trim().toLowerCase();
|
||
const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1);
|
||
newPackPayloads.push({
|
||
label: `${titleCasePack} ${packSize} ${baseUnit}`,
|
||
pack_unit_name: normalizedPackUnit,
|
||
pack_size_in_base_units: packSize,
|
||
is_active: true
|
||
});
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(variantData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to update variant');
|
||
}
|
||
|
||
for (const packPayload of newPackPayloads) {
|
||
const packResponse = await apiCall(`/variants/${variantId}/packs`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(packPayload)
|
||
});
|
||
|
||
if (!packResponse.ok) {
|
||
const packError = await packResponse.json();
|
||
throw new Error(packError.detail || 'Variant updated but failed to add one or more pack sizes');
|
||
}
|
||
}
|
||
|
||
closeModal(document.getElementById('editVariantModal'));
|
||
const variantDrugId = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId))?.id;
|
||
if (variantDrugId) loadedDrugDetails.delete(variantDrugId); // invalidate level-2 cache
|
||
await loadDrugs();
|
||
renderDrugs();
|
||
const message = newPackPayloads.length > 0
|
||
? `Variant updated and ${newPackPayloads.length} pack size${newPackPayloads.length === 1 ? '' : 's'} added`
|
||
: 'Variant updated successfully!';
|
||
showToast(message, 'success');
|
||
} catch (error) {
|
||
console.error('Error updating variant:', error);
|
||
showToast('Failed to update variant: ' + error.message, 'error');
|
||
}
|
||
}
|
||
// Dispense from variant
|
||
function dispenseVariant(variantId) {
|
||
// Update the dropdown display with all variants
|
||
updateDispenseDrugSelect();
|
||
|
||
// Pre-select the variant in the dispense modal
|
||
const drugSelect = document.getElementById('dispenseDrugSelect');
|
||
drugSelect.value = variantId;
|
||
|
||
// Update batch info for selected variant
|
||
updateBatchInfo();
|
||
|
||
// Open dispense modal
|
||
openModal(document.getElementById('dispenseModal'));
|
||
}
|
||
|
||
function disposeVariant(variantId) {
|
||
if (currentUser?.role !== 'admin') {
|
||
showToast('Only admin users can dispose inventory.', 'warning');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('disposeInventoryForm')?.reset();
|
||
updateDisposeDrugSelect();
|
||
|
||
const drugSelect = document.getElementById('disposeDrugSelect');
|
||
drugSelect.value = variantId;
|
||
|
||
updateDisposeBatchInfo();
|
||
openModal(document.getElementById('disposeInventoryModal'));
|
||
}
|
||
|
||
// 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.prescribing_vet ? `
|
||
<div class="history-row">
|
||
<span class="history-label">Prescribing Vet:</span>
|
||
<span class="history-value">${escapeHtml(item.prescribing_vet)}</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();
|
||
loadedDrugDetails.delete(drugId); // invalidate level-2 cache
|
||
await loadDrugs();
|
||
showToast('Drug updated successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error updating drug:', error);
|
||
showToast('Failed to update drug. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Delete drug
|
||
async function deleteDrug(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (drug && drug.variants.some(v => v.has_inventory_history)) {
|
||
showToast('Cannot delete drug with variants that have batch or dispensing history', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Are you sure you want to delete this drug?')) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/drugs/${drugId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to delete drug');
|
||
}
|
||
|
||
loadedDrugDetails.delete(drugId); // invalidate level-2 cache
|
||
loadedVariantBatches.clear(); // variants of this drug are gone
|
||
await loadDrugs();
|
||
showToast('Drug deleted successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error deleting drug:', error);
|
||
showToast('Failed to delete drug: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Password Management
|
||
function openChangePasswordModal() {
|
||
const modal = document.getElementById('changePasswordModal');
|
||
document.getElementById('changePasswordForm').reset();
|
||
|
||
// Close dropdown
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) dropdown.style.display = 'none';
|
||
|
||
openModal(modal);
|
||
}
|
||
|
||
async function handleChangePassword(e) {
|
||
e.preventDefault();
|
||
|
||
const currentPassword = document.getElementById('currentPassword').value;
|
||
const newPassword = document.getElementById('newPassword').value;
|
||
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
showToast('New passwords do not match!', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 1) {
|
||
showToast('New password cannot be empty!', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/auth/change-password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
current_password: currentPassword,
|
||
new_password: newPassword
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to change password');
|
||
}
|
||
|
||
showToast('Password changed successfully!', 'success');
|
||
closeModal(document.getElementById('changePasswordModal'));
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showToast('Failed to change password: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function openAdminChangePasswordModal(userId, username) {
|
||
const modal = document.getElementById('adminChangePasswordModal');
|
||
document.getElementById('adminChangePasswordForm').reset();
|
||
document.getElementById('adminChangePasswordUserId').value = userId;
|
||
document.getElementById('adminChangePasswordUsername').value = username;
|
||
openModal(modal);
|
||
}
|
||
|
||
async function handleAdminChangePassword(e) {
|
||
e.preventDefault();
|
||
|
||
const userId = document.getElementById('adminChangePasswordUserId').value;
|
||
const newPassword = document.getElementById('adminChangePasswordNewPassword').value;
|
||
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
showToast('Passwords do not match!', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 1) {
|
||
showToast('Password cannot be empty!', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/users/${userId}/change-password`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
new_password: newPassword
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to change password');
|
||
}
|
||
|
||
showToast('Password changed successfully!', 'success');
|
||
closeModal(document.getElementById('adminChangePasswordModal'));
|
||
openUserManagement();
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showToast('Failed to change password: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Update filter button states
|
||
function updateFilterButtons() {
|
||
document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly);
|
||
document.getElementById('showLowStockBtn').classList.toggle('active', showLowStockOnly);
|
||
}
|
||
|
||
// Escape HTML to prevent XSS
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function toTitleCase(str) {
|
||
return str.replace(/\S+/g, word => word.charAt(0).toUpperCase() + word.slice(1));
|
||
}
|
||
|
||
async function openReportsPage() {
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) dropdown.style.display = 'none';
|
||
|
||
window.location.href = 'reports.html';
|
||
}
|
||
|
||
// User Management
|
||
async function openUserManagement() {
|
||
const modal = document.getElementById('userManagementModal');
|
||
document.getElementById('newUsername').value = '';
|
||
document.getElementById('newUserPassword').value = '';
|
||
document.getElementById('newUserRole').value = '';
|
||
|
||
const usersList = document.getElementById('usersList');
|
||
usersList.innerHTML = '<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 roleOptions = [
|
||
{ value: 'admin', label: 'Admin' },
|
||
{ value: 'user', label: 'Regular User' },
|
||
{ value: 'readonly', label: 'Read-Only' }
|
||
];
|
||
|
||
const usersHtml = `
|
||
<h3>Users</h3>
|
||
<div class="users-table">
|
||
${users.map(user => {
|
||
const roleBadge = user.role === 'admin' ? '👑 Admin' :
|
||
user.role === 'readonly' ? '👁️ Read-Only' : '👤 Regular';
|
||
const isCurrentUser = user.id === currentUser.id;
|
||
return `
|
||
<div class="user-item">
|
||
<div class="user-identity">
|
||
<strong>${escapeHtml(user.username)}</strong>
|
||
${isCurrentUser ? '<span class="current-user-label">You</span>' : ''}
|
||
</div>
|
||
<span class="admin-badge role-${escapeHtml(user.role)}">${roleBadge}</span>
|
||
<label class="role-control">
|
||
<span>Role</span>
|
||
<select class="user-role-select" data-user-id="${user.id}" ${isCurrentUser ? 'disabled' : ''}>
|
||
${roleOptions.map(role => `<option value="${role.value}" ${role.value === user.role ? 'selected' : ''}>${role.label}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<div class="user-actions">
|
||
${!isCurrentUser ? `
|
||
<button class="btn btn-secondary btn-small admin-password-btn" data-user-id="${user.id}" data-username="${escapeHtml(user.username)}">Password</button>
|
||
<button class="btn btn-danger btn-small delete-user-btn" data-user-id="${user.id}">Delete</button>
|
||
` : '<span class="self-note">Use account menu for your password</span>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
usersList.innerHTML = usersHtml;
|
||
usersList.querySelectorAll('.user-role-select').forEach(select => {
|
||
select.addEventListener('change', (e) => updateUserRole(e.target.dataset.userId, e.target.value));
|
||
});
|
||
usersList.querySelectorAll('.admin-password-btn').forEach(button => {
|
||
button.addEventListener('click', () => openAdminChangePasswordModal(button.dataset.userId, button.dataset.username));
|
||
});
|
||
usersList.querySelectorAll('.delete-user-btn').forEach(button => {
|
||
button.addEventListener('click', () => deleteUser(button.dataset.userId));
|
||
});
|
||
} 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');
|
||
}
|
||
}
|
||
|
||
// Update user role
|
||
async function updateUserRole(userId, role) {
|
||
try {
|
||
const response = await apiCall(`/users/${userId}/role`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ role })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to update user role');
|
||
}
|
||
|
||
showToast('User role updated successfully!', 'success');
|
||
openUserManagement();
|
||
} catch (error) {
|
||
console.error('Error updating user role:', error);
|
||
showToast('Failed to update user role: ' + error.message, 'error');
|
||
openUserManagement();
|
||
}
|
||
}
|
||
|
||
// 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'));
|
||
loadedVariantBatches.delete(variantId); // invalidate level-3 cache
|
||
await loadDrugs();
|
||
showToast('Batch received successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error receiving batch:', error);
|
||
showToast('Failed to receive batch: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function getActiveDeliveryDrug() {
|
||
return allDrugs.find(d => d.id === deliveryDrugId);
|
||
}
|
||
|
||
function getVariantById(variantId) {
|
||
// Prefer loaded drug details (have packs)
|
||
for (const detail of loadedDrugDetails.values()) {
|
||
const found = (detail.variants || []).find(v => v.id === variantId);
|
||
if (found) return found;
|
||
}
|
||
// Fall back to summary variant (no packs)
|
||
for (const drug of allDrugs) {
|
||
const found = (drug.variants || []).find(v => v.id === variantId);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function ensureDrugDetailLoaded(drugId) {
|
||
if (!drugId || loadedDrugDetails.has(drugId)) return;
|
||
try {
|
||
const response = await apiCall(`/drugs/${drugId}`);
|
||
if (response.ok) {
|
||
loadedDrugDetails.set(drugId, await response.json());
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to load drug detail for drug ${drugId}:`, error);
|
||
}
|
||
}
|
||
|
||
function buildDeliveryVariantOptions(drug, selectedVariantId = '') {
|
||
if (!drug || !drug.variants || drug.variants.length === 0) {
|
||
return '<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 = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
|
||
})].join('');
|
||
}
|
||
|
||
function buildDeliveryLocationOptions(selectedLocationId = '') {
|
||
const fallbackLocationId = selectedLocationId || (deliveryLocations.length > 0 ? String(deliveryLocations[0].id) : '');
|
||
return [`<option value="">-- Select location --</option>`, ...deliveryLocations.map(location => {
|
||
const selected = String(location.id) === String(fallbackLocationId) ? ' 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 drugSelect = line.querySelector('.delivery-drug-select');
|
||
const variantSelect = line.querySelector('.delivery-variant-select');
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
const packCountInput = line.querySelector('.delivery-pack-count');
|
||
|
||
if (drugSelect && variantSelect) {
|
||
drugSelect.addEventListener('change', async () => {
|
||
const drugId = parseInt(drugSelect.value || '', 10);
|
||
await ensureDrugDetailLoaded(drugId);
|
||
const drug = allDrugs.find(d => d.id === drugId) || null;
|
||
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, '');
|
||
if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, '');
|
||
if (packCountInput) packCountInput.value = '';
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
if (variantSelect && packSelect) {
|
||
variantSelect.addEventListener('change', () => {
|
||
const variantId = parseInt(variantSelect.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const activePacks = getActivePacksForVariant(variant);
|
||
const nextPackId = activePacks.length === 1 ? activePacks[0].id : '';
|
||
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
|
||
if (packCountInput) packCountInput.value = '';
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
if (packSelect) {
|
||
packSelect.addEventListener('change', () => {
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
if (packCountInput) {
|
||
// Prevent accidental value changes when users scroll while focused.
|
||
packCountInput.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
packCountInput.addEventListener('input', () => {
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── GS1 barcode scanning ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a GS1-128 / DataMatrix scan string.
|
||
* Handles fixed-length AIs: 01 (GTIN-14), 17 (expiry YYMMDD), then 10 (lot).
|
||
* Returns { gtin, expiry (Date), lot } or null if the string doesn't match.
|
||
*/
|
||
// GS1 AI fixed-length lookup (number of data digits after the AI prefix).
|
||
// AIs not listed here are treated as variable-length (terminated by GS/FNC1 or end of string).
|
||
const GS1_FIXED_LENGTHS = {
|
||
'00': 18, '01': 14, '02': 14,
|
||
'11': 6, '12': 6, '13': 6, '14': 6, '15': 6, '16': 6, '17': 6, '18': 6, '19': 6,
|
||
'20': 2,
|
||
'31': 6, '32': 6, '33': 6, '34': 6, '35': 6, '36': 6,
|
||
'41': 13,
|
||
};
|
||
|
||
// 2-digit AI prefixes we recognise enough to skip over.
|
||
const GS1_KNOWN_AI_PREFIXES = new Set([
|
||
'00','01','02','10','11','12','13','14','15','16','17','18','19',
|
||
'20','21','22','23','24','25','26',
|
||
'30','31','32','33','34','35','36','37',
|
||
'40','41','42','43',
|
||
'70','71','72','73','74','75','76','77','78','79',
|
||
'80','81','82','83','84','85','86','87','88','89',
|
||
'90','91','92','93','94','95','96','97','98','99',
|
||
]);
|
||
|
||
/**
|
||
* Parse a GS1-128 / DataMatrix scan string.
|
||
* Fixed-length AIs are consumed exactly. Variable-length AIs are terminated
|
||
* by a GS (FNC1, \x1d) character — if no GS is present they run to end of string
|
||
* (per GS1 spec: variable-length fields must be FNC1-terminated unless last).
|
||
* Returns { gtin, expiry (Date), lot } or null if required fields not found.
|
||
*/
|
||
function parseGS1(raw) {
|
||
if (!raw || raw.length < 16) return null;
|
||
|
||
// Strip any leading AIM symbology identifier e.g. "]d2", "]Q3"
|
||
const aimPrefix = raw.match(/^\][a-zA-Z]\d/);
|
||
let data = aimPrefix ? raw.substring(3) : raw;
|
||
|
||
// Normalise semicolons to the standard GS/FNC1 character — some scanners
|
||
// emit ';' as the group separator for variable-length field termination.
|
||
data = data.replace(/;/g, '\x1d');
|
||
|
||
const GS = '\x1d'; // FNC1 separator
|
||
const hasGS = data.includes(GS);
|
||
|
||
let pos = 0;
|
||
let gtin = null, expiry = null, lot = null;
|
||
|
||
while (pos < data.length) {
|
||
if (data[pos] === GS) { pos++; continue; }
|
||
if (pos + 2 > data.length) break;
|
||
|
||
const ai = data.substring(pos, pos + 2);
|
||
if (!GS1_KNOWN_AI_PREFIXES.has(ai)) break;
|
||
|
||
pos += 2; // consume AI
|
||
|
||
if (GS1_FIXED_LENGTHS[ai] !== undefined) {
|
||
// Fixed-length: consume exactly N chars
|
||
const len = GS1_FIXED_LENGTHS[ai];
|
||
const value = data.substring(pos, pos + len);
|
||
pos += len;
|
||
|
||
if (ai === '01') {
|
||
if (value.length === 14 && /^\d{14}$/.test(value)) gtin = value;
|
||
} else if (ai === '17') {
|
||
const yy = parseInt(value.substring(0, 2), 10);
|
||
const mm = parseInt(value.substring(2, 4), 10);
|
||
const dd = parseInt(value.substring(4, 6), 10);
|
||
expiry = dd === 0
|
||
? new Date(yy + 2000, mm, 0) // last day of month
|
||
: new Date(yy + 2000, mm - 1, dd);
|
||
}
|
||
} else {
|
||
// Variable-length: terminated by GS if present, otherwise end of string
|
||
let end;
|
||
if (hasGS) {
|
||
const gsIdx = data.indexOf(GS, pos);
|
||
end = gsIdx !== -1 ? gsIdx : data.length;
|
||
} else {
|
||
end = data.length;
|
||
}
|
||
const value = data.substring(pos, end);
|
||
pos = end;
|
||
|
||
if (ai === '10') lot = value;
|
||
// ai 21 (serial), 22, etc. ignored
|
||
}
|
||
}
|
||
|
||
if (!gtin || !expiry || !lot) return null;
|
||
return { gtin, expiry, lot };
|
||
}
|
||
|
||
/** Format a Date as YYYY-MM-DD for use in <input type="date"> */
|
||
function formatDateForInput(d) {
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(d.getDate()).padStart(2, '0');
|
||
return `${yyyy}-${mm}-${dd}`;
|
||
}
|
||
|
||
// Buffer for keyboard-wedge barcode detection
|
||
let _scanBuffer = [];
|
||
let _scanBufferTimer = null;
|
||
let _activeScanLineEl = null; // last delivery line that received focus
|
||
let _preScanFocusedInput = null; // input that had focus when scan started
|
||
let _preScanFocusedValue = null; // its value before any scan chars were typed
|
||
const SCAN_MAX_GAP_MS = 50;
|
||
const SCAN_MIN_LENGTH = 20;
|
||
|
||
// Separate buffer for main-screen scan (does not interfere with delivery modal)
|
||
let _mainScanBuffer = [];
|
||
let _mainScanBufferTimer = null;
|
||
let _mainPreScanFocusedInput = null;
|
||
let _mainPreScanFocusedValue = null;
|
||
|
||
function _onMainScreenKeydown(e) {
|
||
// Only act when no modal is open
|
||
if (document.querySelector('.modal.show')) return;
|
||
|
||
const now = Date.now();
|
||
|
||
if (e.key === 'Enter') {
|
||
const raw = _mainScanBuffer.map(x => x.char).join('');
|
||
_mainScanBuffer = [];
|
||
if (_mainScanBufferTimer) { clearTimeout(_mainScanBufferTimer); _mainScanBufferTimer = null; }
|
||
|
||
if (raw.length >= SCAN_MIN_LENGTH) {
|
||
e.preventDefault();
|
||
if (_mainPreScanFocusedInput) {
|
||
_mainPreScanFocusedInput.value = _mainPreScanFocusedValue || '';
|
||
}
|
||
_mainPreScanFocusedInput = null;
|
||
_mainPreScanFocusedValue = null;
|
||
handleMainScreenBarcodeScan(raw);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key.length === 1) {
|
||
const gap = _mainScanBuffer.length > 0 ? now - _mainScanBuffer[_mainScanBuffer.length - 1].time : 0;
|
||
|
||
if (_mainScanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
|
||
_mainScanBuffer = [];
|
||
_mainPreScanFocusedInput = null;
|
||
_mainPreScanFocusedValue = null;
|
||
}
|
||
|
||
if (_mainScanBuffer.length === 0) {
|
||
const active = document.activeElement;
|
||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
||
_mainPreScanFocusedInput = active;
|
||
_mainPreScanFocusedValue = active.value;
|
||
} else {
|
||
_mainPreScanFocusedInput = null;
|
||
_mainPreScanFocusedValue = null;
|
||
}
|
||
} else {
|
||
e.preventDefault();
|
||
}
|
||
|
||
_mainScanBuffer.push({ char: e.key, time: now });
|
||
|
||
if (_mainScanBufferTimer) clearTimeout(_mainScanBufferTimer);
|
||
_mainScanBufferTimer = setTimeout(() => {
|
||
_mainScanBuffer = [];
|
||
_mainPreScanFocusedInput = null;
|
||
_mainPreScanFocusedValue = null;
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
function _onDeliveryModalKeydown(e) {
|
||
// Only act when the receive delivery modal is open
|
||
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
|
||
|
||
// Stop intercepting if the GTIN mapping modal is open on top
|
||
if (document.getElementById('gtinMappingModal')?.classList.contains('show')) return;
|
||
|
||
// Track which delivery line last had focus
|
||
const focusedLine = document.activeElement?.closest('.delivery-line');
|
||
if (focusedLine) _activeScanLineEl = focusedLine;
|
||
|
||
const now = Date.now();
|
||
|
||
if (e.key === 'Enter') {
|
||
const raw = _scanBuffer.map(x => x.char).join('');
|
||
_scanBuffer = [];
|
||
if (_scanBufferTimer) { clearTimeout(_scanBufferTimer); _scanBufferTimer = null; }
|
||
|
||
// Only treat as a scan if it arrived very fast
|
||
if (raw.length >= SCAN_MIN_LENGTH) {
|
||
e.preventDefault();
|
||
// Restore the focused input to its pre-scan value (remove the 1 char that slipped in)
|
||
if (_preScanFocusedInput) {
|
||
_preScanFocusedInput.value = _preScanFocusedValue || '';
|
||
}
|
||
_preScanFocusedInput = null;
|
||
_preScanFocusedValue = null;
|
||
handleBarcodeScan(raw);
|
||
} else {
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Single printable character
|
||
if (e.key.length === 1) {
|
||
const gap = _scanBuffer.length > 0 ? now - _scanBuffer[_scanBuffer.length - 1].time : 0;
|
||
|
||
// If gap is too large, start fresh (human typed slowly)
|
||
if (_scanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
|
||
_scanBuffer = [];
|
||
_preScanFocusedInput = null;
|
||
_preScanFocusedValue = null;
|
||
}
|
||
|
||
// Save the focused input + its value before the first scan char lands
|
||
if (_scanBuffer.length === 0) {
|
||
const active = document.activeElement;
|
||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
||
_preScanFocusedInput = active;
|
||
_preScanFocusedValue = active.value;
|
||
} else {
|
||
_preScanFocusedInput = null;
|
||
_preScanFocusedValue = null;
|
||
}
|
||
} else {
|
||
// Subsequent rapid chars — suppress them from going into the focused input
|
||
e.preventDefault();
|
||
}
|
||
|
||
_scanBuffer.push({ char: e.key, time: now });
|
||
|
||
// Auto-clear buffer if Enter never comes
|
||
if (_scanBufferTimer) clearTimeout(_scanBufferTimer);
|
||
_scanBufferTimer = setTimeout(() => {
|
||
_scanBuffer = [];
|
||
_preScanFocusedInput = null;
|
||
_preScanFocusedValue = null;
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
async function handleMainScreenBarcodeScan(raw) {
|
||
const parsed = parseGS1(raw);
|
||
if (!parsed) {
|
||
showToast('Barcode not recognised as a GS1 code', 'warning');
|
||
return;
|
||
}
|
||
|
||
const { gtin, lot } = parsed;
|
||
|
||
let mapping = null;
|
||
try {
|
||
const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`);
|
||
if (resp.ok) {
|
||
mapping = await resp.json();
|
||
} else if (resp.status !== 404) {
|
||
throw new Error(`Server error ${resp.status}`);
|
||
}
|
||
} catch (err) {
|
||
showToast('Failed to look up barcode: ' + err.message, 'error');
|
||
return;
|
||
}
|
||
|
||
if (!mapping) {
|
||
showToast('Unknown barcode — scan it in a delivery first to map it to a drug', 'warning');
|
||
return;
|
||
}
|
||
|
||
const drugId = mapping.drug_id;
|
||
const variantId = mapping.drug_variant_id;
|
||
|
||
// Expand drug + variant
|
||
expandedDrugs.add(drugId);
|
||
expandedVariants.add(variantId);
|
||
|
||
// Ensure detail + batch data are loaded
|
||
await ensureDrugDetailLoaded(drugId);
|
||
if (!loadedVariantBatches.has(variantId)) {
|
||
try {
|
||
const resp = await apiCall(`/variants/${variantId}/batches`);
|
||
if (resp.ok) loadedVariantBatches.set(variantId, await resp.json());
|
||
} catch (_) { /* non-fatal */ }
|
||
}
|
||
|
||
// Set highlight
|
||
if (_highlightClearTimer) { clearTimeout(_highlightClearTimer); _highlightClearTimer = null; }
|
||
_highlightedBatchLot = lot;
|
||
_highlightedVariantId = variantId;
|
||
|
||
renderDrugs();
|
||
|
||
requestAnimationFrame(() => scrollToHighlightedBatch());
|
||
|
||
// Auto-clear highlight after 5 seconds
|
||
_highlightClearTimer = setTimeout(() => {
|
||
_highlightedBatchLot = null;
|
||
_highlightedVariantId = null;
|
||
_highlightClearTimer = null;
|
||
renderDrugs();
|
||
}, 5000);
|
||
|
||
// Confirm to user
|
||
const batches = loadedVariantBatches.get(variantId) || [];
|
||
const found = batches.some(b => b.batch_number === lot);
|
||
if (found) {
|
||
showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
|
||
} else {
|
||
showToast(`${mapping.drug_name} ${mapping.variant_strength} — lot ${lot} (no active stock)`, 'warning');
|
||
}
|
||
}
|
||
|
||
function scrollToHighlightedBatch() {
|
||
if (!_highlightedBatchLot || _highlightedVariantId == null) return;
|
||
const el = document.querySelector(`[data-batch-number="${CSS.escape(_highlightedBatchLot)}"][data-variant-id="${_highlightedVariantId}"]`);
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
async function handleBarcodeScan(raw) {
|
||
const parsed = parseGS1(raw);
|
||
if (!parsed) {
|
||
showToast('Barcode not recognised as a GS1 code', 'warning');
|
||
return;
|
||
}
|
||
|
||
const { gtin, expiry, lot } = parsed;
|
||
const expiryStr = formatDateForInput(expiry);
|
||
|
||
// Look up GTIN mapping
|
||
let mapping = null;
|
||
try {
|
||
const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`);
|
||
if (resp.ok) {
|
||
mapping = await resp.json();
|
||
} else if (resp.status !== 404) {
|
||
throw new Error(`Server error ${resp.status}`);
|
||
}
|
||
} catch (err) {
|
||
showToast('Failed to look up barcode: ' + err.message, 'error');
|
||
return;
|
||
}
|
||
|
||
if (!mapping) {
|
||
// Unknown GTIN — open mapping modal then re-process
|
||
openGtinMappingModal(gtin, expiryStr, lot);
|
||
return;
|
||
}
|
||
|
||
await _applyBarcodeScanToLines(mapping, lot, expiryStr);
|
||
}
|
||
|
||
async function _applyBarcodeScanToLines(mapping, lot, expiryStr) {
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
if (!container) return;
|
||
|
||
const lines = Array.from(container.querySelectorAll('.delivery-line'));
|
||
|
||
// 1. Find an existing line with the same variant + lot + expiry → increment pack count
|
||
for (const line of lines) {
|
||
const variantId = line.querySelector('.delivery-variant-select')?.value;
|
||
const batchVal = line.querySelector('.delivery-batch-number')?.value?.trim();
|
||
const expiryVal = line.querySelector('.delivery-expiry-date')?.value;
|
||
|
||
if (
|
||
String(variantId) === String(mapping.drug_variant_id) &&
|
||
batchVal === lot &&
|
||
expiryVal === expiryStr
|
||
) {
|
||
const countInput = line.querySelector('.delivery-pack-count');
|
||
const current = parseFloat(countInput.value) || 0;
|
||
countInput.value = current + 1;
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
showToast(`Pack count incremented to ${current + 1} for lot ${lot}`, 'success');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 2. Find any existing empty line (lot and expiry both blank) — never overwrite a filled line
|
||
const emptyLine = lines.find(l => {
|
||
const batch = l.querySelector('.delivery-batch-number')?.value?.trim();
|
||
const expiry = l.querySelector('.delivery-expiry-date')?.value;
|
||
return !batch && !expiry;
|
||
}) || null;
|
||
|
||
if (emptyLine) {
|
||
await _populateDeliveryLine(emptyLine, mapping, lot, expiryStr);
|
||
return;
|
||
}
|
||
|
||
// 3. Append a new line
|
||
appendDeliveryLine();
|
||
const newLine = container.querySelector('.delivery-line:last-child');
|
||
if (newLine) await _populateDeliveryLine(newLine, mapping, lot, expiryStr);
|
||
}
|
||
|
||
async function _populateDeliveryLine(line, mapping, lot, expiryStr) {
|
||
const drugSelect = line.querySelector('.delivery-drug-select');
|
||
const variantSelect = line.querySelector('.delivery-variant-select');
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
const batchInput = line.querySelector('.delivery-batch-number');
|
||
const expiryInput = line.querySelector('.delivery-expiry-date');
|
||
const packCountInput = line.querySelector('.delivery-pack-count');
|
||
|
||
if (drugSelect) {
|
||
drugSelect.innerHTML = buildDeliveryDrugOptions(mapping.drug_id);
|
||
drugSelect.value = String(mapping.drug_id);
|
||
}
|
||
|
||
// Ensure drug detail (with packs) is loaded before trying to populate pack select
|
||
await ensureDrugDetailLoaded(mapping.drug_id);
|
||
|
||
if (variantSelect) {
|
||
const drug = allDrugs.find(d => d.id === mapping.drug_id) || null;
|
||
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, mapping.drug_variant_id);
|
||
variantSelect.value = String(mapping.drug_variant_id);
|
||
const variant = getVariantById(mapping.drug_variant_id);
|
||
if (packSelect) {
|
||
packSelect.innerHTML = buildDeliveryPackOptions(variant, mapping.variant_pack_id);
|
||
packSelect.value = String(mapping.variant_pack_id);
|
||
}
|
||
}
|
||
|
||
if (batchInput) batchInput.value = lot;
|
||
if (expiryInput) expiryInput.value = expiryStr;
|
||
if (packCountInput && !packCountInput.value) packCountInput.value = 1;
|
||
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
showToast(`Populated: ${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
|
||
}
|
||
|
||
function _detachDeliveryBarcodeListener() {
|
||
const modalEl = document.getElementById('receiveDeliveryModal');
|
||
if (modalEl?._barcodeListener) {
|
||
document.removeEventListener('keydown', modalEl._barcodeListener);
|
||
modalEl._barcodeListener = null;
|
||
}
|
||
_scanBuffer = [];
|
||
_activeScanLineEl = null;
|
||
}
|
||
|
||
function _refreshGtinMappingSelects() {
|
||
// Kept for compatibility — delegates to reinit with current selections
|
||
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect')?.value || '', 10) || null;
|
||
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect')?.value || '', 10) || null;
|
||
const packId = parseInt(document.getElementById('gtinMappingPackSelect')?.value || '', 10) || null;
|
||
_reinitGtinMappingModal({ drugId, variantId, packId });
|
||
}
|
||
|
||
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
|
||
// optionally pre-selecting specific drug/variant/pack IDs.
|
||
async function _reinitGtinMappingModal(restore) {
|
||
const drugSelect = document.getElementById('gtinMappingDrugSelect');
|
||
const variantSelect = document.getElementById('gtinMappingVariantSelect');
|
||
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||
if (!drugSelect) return;
|
||
|
||
// Rebuild drug list
|
||
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
|
||
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
|
||
|
||
const drugId = restore?.drugId || null;
|
||
const variantId = restore?.variantId || null;
|
||
const packId = restore?.packId || null;
|
||
|
||
// If no drug to restore, clear cascades and stop
|
||
if (!drugId) {
|
||
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
return;
|
||
}
|
||
|
||
drugSelect.value = String(drugId);
|
||
await ensureDrugDetailLoaded(drugId);
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
|
||
// Rebuild variant list
|
||
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||
if (drug) {
|
||
variantSelect.innerHTML += drug.variants.map(v =>
|
||
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||
).join('');
|
||
}
|
||
|
||
if (!variantId) {
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
return;
|
||
}
|
||
|
||
variantSelect.value = String(variantId);
|
||
const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs)
|
||
|
||
// Rebuild pack list
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
if (variant) {
|
||
const packs = getActivePacksForVariant(variant);
|
||
packSelect.innerHTML += packs.map(p =>
|
||
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
|
||
).join('');
|
||
if (packId) packSelect.value = String(packId);
|
||
}
|
||
}
|
||
|
||
function gtinMappingAddDrug() {
|
||
// Snapshot current drug IDs. handleAddDrug will call loadDrugs() once (we intercept it
|
||
// to find the new drug ID), then open addVariantModal. We hook the *subsequent* loadDrugs
|
||
// call (from handleAddVariant) to reinit the GTIN modal with drug+variant selected.
|
||
_gtinMappingWaitingForNewDrug = new Set(allDrugs.map(d => d.id));
|
||
openModal(document.getElementById('addModal'));
|
||
}
|
||
|
||
function gtinMappingAddVariant() {
|
||
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||
if (!drugId) { showToast('Select a drug first', 'warning'); return; }
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
const existingVariantIds = new Set((drug?.variants || []).map(v => v.id));
|
||
// After reload, find the new variant by diffing
|
||
_gtinMappingPendingRestore = { drugId, variantId: null, packId: null, _existingVariantIds: existingVariantIds };
|
||
_gtinMappingPendingRefresh = true;
|
||
openAddVariantModal(drugId);
|
||
}
|
||
|
||
function gtinMappingAddPack() {
|
||
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||
if (!variantId) { showToast('Select a variant first', 'warning'); return; }
|
||
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||
if (!drugId) return;
|
||
// Use getVariantById which returns the detail variant (with packs) if loaded
|
||
const variant = getVariantById(variantId);
|
||
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
|
||
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
|
||
_gtinMappingPendingRefresh = true;
|
||
deliveryDrugId = drugId;
|
||
_gtinMappingPendingVariantId = variantId;
|
||
openAddPackSizeFromDeliveryModal();
|
||
}
|
||
|
||
// ─── GTIN mapping modal logic ──────────────────────────────────────────────
|
||
|
||
let _pendingGtinScan = null; // { gtin, expiryStr, lot } while mapping modal is open
|
||
|
||
function openGtinMappingModal(gtin, expiryStr, lot) {
|
||
_pendingGtinScan = { gtin, expiryStr, lot };
|
||
|
||
document.getElementById('gtinMappingGtin').value = gtin;
|
||
|
||
// Populate drug selector from allDrugs
|
||
const drugSelect = document.getElementById('gtinMappingDrugSelect');
|
||
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
|
||
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
|
||
|
||
document.getElementById('gtinMappingVariantSelect').innerHTML = '<option value="">-- Select variant --</option>';
|
||
document.getElementById('gtinMappingPackSelect').innerHTML = '<option value="">-- Select pack --</option>';
|
||
|
||
openModal(document.getElementById('gtinMappingModal'));
|
||
}
|
||
|
||
async function onGtinMappingDrugChange() {
|
||
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||
const variantSelect = document.getElementById('gtinMappingVariantSelect');
|
||
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||
|
||
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
|
||
if (!drugId) return;
|
||
await ensureDrugDetailLoaded(drugId);
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
variantSelect.innerHTML += drug.variants.map(v =>
|
||
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||
).join('');
|
||
}
|
||
|
||
function onGtinMappingVariantChange() {
|
||
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||
const variant = getVariantById(variantId); // checks loadedDrugDetails first (has packs)
|
||
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
if (!variant) return;
|
||
|
||
const packs = getActivePacksForVariant(variant);
|
||
packSelect.innerHTML += packs.map(p =>
|
||
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
|
||
).join('');
|
||
}
|
||
|
||
async function handleSaveGtinMapping() {
|
||
if (!_pendingGtinScan) return;
|
||
|
||
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||
const packId = parseInt(document.getElementById('gtinMappingPackSelect').value || '', 10);
|
||
|
||
if (!variantId || !packId) {
|
||
showToast('Please select a variant and pack', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await apiCall('/gtin', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
gtin: _pendingGtinScan.gtin,
|
||
drug_variant_id: variantId,
|
||
variant_pack_id: packId,
|
||
}),
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
throw new Error(err.detail || 'Failed to save GTIN mapping');
|
||
}
|
||
|
||
const mapping = await resp.json();
|
||
closeModal(document.getElementById('gtinMappingModal'));
|
||
showToast(`GTIN mapped to ${mapping.drug_name} ${mapping.variant_strength}`, 'success');
|
||
|
||
// Now apply the scan that triggered this
|
||
_applyBarcodeScanToLines(mapping, _pendingGtinScan.lot, _pendingGtinScan.expiryStr);
|
||
_pendingGtinScan = null;
|
||
} catch (err) {
|
||
showToast('Error saving GTIN: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function buildDeliveryDrugOptions(selectedDrugId = '') {
|
||
return [
|
||
'<option value="">-- Select drug --</option>',
|
||
...allDrugs.map(d => {
|
||
const sel = String(d.id) === String(selectedDrugId) ? ' selected' : '';
|
||
return `<option value="${d.id}"${sel}>${escapeHtml(d.name)}</option>`;
|
||
})
|
||
].join('');
|
||
}
|
||
|
||
function appendDeliveryLine(prefill = {}) {
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
if (!container) return;
|
||
|
||
deliveryLineCounter += 1;
|
||
const lineId = `delivery-line-${deliveryLineCounter}`;
|
||
|
||
const line = document.createElement('div');
|
||
line.className = 'delivery-line';
|
||
line.dataset.lineId = lineId;
|
||
|
||
const initialDrug = prefill.drugId ? allDrugs.find(d => String(d.id) === String(prefill.drugId)) || null : null;
|
||
const initialDrugId = prefill.drugId || '';
|
||
const initialVariant = prefill.variantId && initialDrug
|
||
? initialDrug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
|
||
: null;
|
||
const initialVariantId = prefill.variantId || '';
|
||
const initialPackId = prefill.packId || (initialVariant && getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
|
||
const initialPackCount = prefill.packCount || '';
|
||
|
||
line.innerHTML = `
|
||
<div class="delivery-line-grid">
|
||
<div class="form-group">
|
||
<label>Drug</label>
|
||
<select class="delivery-drug-select" required>
|
||
${buildDeliveryDrugOptions(initialDrugId)}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Variant</label>
|
||
<select class="delivery-variant-select" required>
|
||
${buildDeliveryVariantOptions(initialDrug, 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 container = document.getElementById('deliveryLinesContainer');
|
||
if (!container) return;
|
||
|
||
container.querySelectorAll('.delivery-line').forEach(line => {
|
||
const drugSelect = line.querySelector('.delivery-drug-select');
|
||
const variantSelect = line.querySelector('.delivery-variant-select');
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
if (!variantSelect) return;
|
||
|
||
const drugId = parseInt(drugSelect?.value || '', 10);
|
||
const drug = allDrugs.find(d => d.id === drugId) || null;
|
||
const currentVariantId = variantSelect.value;
|
||
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
|
||
|
||
const variant = getVariantById(parseInt(variantSelect.value || '', 10));
|
||
if (packSelect) {
|
||
const currentPackId = packSelect.value;
|
||
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
|
||
}
|
||
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
async function initializeDeliveryLocations() {
|
||
try {
|
||
const response = await apiCall('/locations');
|
||
if (!response.ok) throw new Error('Failed to load locations');
|
||
const locations = await response.json();
|
||
deliveryLocations = locations.filter(location => location.is_active);
|
||
} catch (error) {
|
||
console.error('Error loading delivery locations:', error);
|
||
showToast('Failed to load storage locations', 'error');
|
||
deliveryLocations = [];
|
||
}
|
||
}
|
||
|
||
async function openReceiveDeliveryModal() {
|
||
deliveryDrugId = null;
|
||
|
||
const form = document.getElementById('receiveDeliveryForm');
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
const label = document.getElementById('receiveDeliveryDrugLabel');
|
||
|
||
if (form) form.reset();
|
||
if (container) container.innerHTML = '';
|
||
if (label) label.textContent = 'Scan items or add lines manually';
|
||
|
||
await initializeDeliveryLocations();
|
||
appendDeliveryLine();
|
||
|
||
// Attach barcode scanner listener
|
||
_activeScanLineEl = null;
|
||
_scanBuffer = [];
|
||
const modalEl = document.getElementById('receiveDeliveryModal');
|
||
if (modalEl._barcodeListener) document.removeEventListener('keydown', modalEl._barcodeListener);
|
||
modalEl._barcodeListener = _onDeliveryModalKeydown;
|
||
document.addEventListener('keydown', modalEl._barcodeListener);
|
||
|
||
openModal(modalEl);
|
||
}
|
||
|
||
function handleAddVariantFromDelivery() {
|
||
if (!deliveryDrugId) {
|
||
const deliveryContainer = document.getElementById('deliveryLinesContainer');
|
||
const firstDrugIdStr = deliveryContainer
|
||
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
|
||
: null;
|
||
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
|
||
}
|
||
if (!deliveryDrugId) {
|
||
showToast('Select a drug on a delivery line first', 'warning');
|
||
return;
|
||
}
|
||
openAddVariantModal(deliveryDrugId);
|
||
}
|
||
|
||
function openAddPackSizeFromDeliveryModal() {
|
||
if (!deliveryDrugId) {
|
||
// In multi-drug mode, get drug from the first line that has one selected
|
||
const deliveryContainer = document.getElementById('deliveryLinesContainer');
|
||
const firstDrugIdStr = deliveryContainer
|
||
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
|
||
: null;
|
||
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
|
||
}
|
||
|
||
if (!deliveryDrugId) {
|
||
showToast('Select a drug on a delivery line first', 'warning');
|
||
return;
|
||
}
|
||
const drug = allDrugs.find(d => d.id === deliveryDrugId);
|
||
if (!drug) {
|
||
showToast('Drug not found', 'error');
|
||
return;
|
||
}
|
||
|
||
const form = document.getElementById('addPackSizeForm');
|
||
if (form) form.reset();
|
||
|
||
const label = document.getElementById('addPackSizeDrugLabel');
|
||
if (label) label.textContent = `Drug: ${drug.name}`;
|
||
|
||
// Try to pre-select variant from first delivery line that has one chosen
|
||
const deliveryContainer = document.getElementById('deliveryLinesContainer');
|
||
const selectedDeliveryVariantId = deliveryContainer
|
||
? (Array.from(deliveryContainer.querySelectorAll('.delivery-variant-select'))
|
||
.map(s => s.value)
|
||
.find(v => v)) || ''
|
||
: '';
|
||
|
||
const variantSelect = document.getElementById('addPackSizeVariantSelect');
|
||
if (variantSelect) {
|
||
variantSelect.innerHTML = [
|
||
'<option value="">-- Select variant --</option>',
|
||
...drug.variants.map(v =>
|
||
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||
)
|
||
].join('');
|
||
if (selectedDeliveryVariantId) {
|
||
variantSelect.value = selectedDeliveryVariantId;
|
||
} else if (drug.variants.length === 1) {
|
||
variantSelect.value = String(drug.variants[0].id);
|
||
}
|
||
}
|
||
|
||
// Update the hint for the pre-selected variant
|
||
const selectedId = parseInt(variantSelect?.value || '', 10);
|
||
const selectedVariant = getVariantById(selectedId);
|
||
const hint = document.getElementById('addPackSizeHint');
|
||
if (hint) hint.textContent = selectedVariant ? `Base unit: ${selectedVariant.unit}` : '';
|
||
|
||
// Reset pack type default to box
|
||
const packTypeSelect = document.getElementById('addPackSizeType');
|
||
if (packTypeSelect) packTypeSelect.value = 'box';
|
||
|
||
openModal(document.getElementById('addPackSizeModal'));
|
||
}
|
||
|
||
async function handleAddPackSize(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10);
|
||
const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim();
|
||
const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || '');
|
||
|
||
if (!variantId) {
|
||
showToast('Please select a variant', 'warning');
|
||
return;
|
||
}
|
||
if (!packSize || packSize <= 0) {
|
||
showToast('Please enter a valid pack size greater than zero', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}/packs`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
pack_unit_name: packType,
|
||
pack_size_in_base_units: packSize,
|
||
is_active: true
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to add pack size');
|
||
}
|
||
|
||
closeModal(document.getElementById('addPackSizeModal'));
|
||
// Invalidate drug detail cache so the new pack will be re-fetched on next expand
|
||
const packDrugId = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId))?.id;
|
||
if (packDrugId) loadedDrugDetails.delete(packDrugId);
|
||
await loadDrugs();
|
||
// Refresh delivery line pack selects so the new pack is immediately available
|
||
refreshDeliveryVariantSelects();
|
||
showToast('Pack size added successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error adding pack size:', error);
|
||
showToast('Failed to add pack size: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function handleReceiveDelivery(e) {
|
||
e.preventDefault();
|
||
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
if (!container) {
|
||
showToast('Delivery context unavailable', 'error');
|
||
return;
|
||
}
|
||
|
||
const lines = Array.from(container.querySelectorAll('.delivery-line'));
|
||
if (lines.length === 0) {
|
||
showToast('Add at least one delivery line', 'warning');
|
||
return;
|
||
}
|
||
|
||
const payloads = [];
|
||
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
|
||
const packIdRaw = line.querySelector('.delivery-pack-select')?.value || '';
|
||
const packId = packIdRaw ? parseInt(packIdRaw, 10) : null;
|
||
const packCountRaw = line.querySelector('.delivery-pack-count')?.value || '';
|
||
const packCount = packCountRaw ? parseFloat(packCountRaw) : null;
|
||
const batchNumber = (line.querySelector('.delivery-batch-number')?.value || '').trim();
|
||
const expiryDate = line.querySelector('.delivery-expiry-date')?.value || '';
|
||
const locationId = parseInt(line.querySelector('.delivery-location-select')?.value || '', 10);
|
||
|
||
if (!variantId || !packId || packCount === null || Number.isNaN(packCount) || packCount <= 0 || !batchNumber || !expiryDate || !locationId) {
|
||
showToast(`Delivery line ${i + 1} is incomplete`, 'warning');
|
||
return;
|
||
}
|
||
|
||
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
||
showToast(`Delivery line ${i + 1} pack count must be a whole number`, 'warning');
|
||
return;
|
||
}
|
||
|
||
const variant = getVariantById(variantId);
|
||
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
|
||
if (!selectedPack) {
|
||
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
|
||
return;
|
||
}
|
||
const computedQuantity = packCount * selectedPack.pack_size_in_base_units;
|
||
|
||
payloads.push({
|
||
variantId,
|
||
payload: {
|
||
batch_number: batchNumber,
|
||
received_pack_id: packId,
|
||
received_pack_count: packCount,
|
||
expiry_date: expiryDate,
|
||
location_id: locationId,
|
||
notes: `Received ${packCount} ${selectedPack.pack_unit_name}(s), total ${computedQuantity} ${variant ? variant.unit : 'units'}`
|
||
}
|
||
});
|
||
}
|
||
|
||
try {
|
||
for (let i = 0; i < payloads.length; i += 1) {
|
||
const entry = payloads[i];
|
||
const response = await apiCall(`/variants/${entry.variantId}/batches`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(entry.payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(`Line ${i + 1}: ${error.detail || 'Failed to receive delivery line'}`);
|
||
}
|
||
}
|
||
|
||
closeModal(document.getElementById('receiveDeliveryModal'));
|
||
_detachDeliveryBarcodeListener();
|
||
// Invalidate batch cache for all delivered variants
|
||
payloads.forEach(entry => loadedVariantBatches.delete(entry.variantId));
|
||
await loadDrugs();
|
||
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
||
} catch (error) {
|
||
console.error('Error receiving delivery:', error);
|
||
showToast('Failed to receive delivery: ' + error.message, 'error');
|
||
}
|
||
}
|