2936 lines
119 KiB
JavaScript
2936 lines
119 KiB
JavaScript
const API_URL = '/api';
|
||
let allDrugs = [];
|
||
let currentDrug = null;
|
||
let showLowStockOnly = false;
|
||
let selectedLocationFilter = '';
|
||
let searchTerm = '';
|
||
let expandedDrugs = new Set();
|
||
let expandedVariants = new Set();
|
||
let currentUser = null;
|
||
let accessToken = null;
|
||
let deliveryDrugId = null;
|
||
let deliveryLineCounter = 0;
|
||
let deliveryLocations = [];
|
||
let currentDispenseBatches = [];
|
||
|
||
// Toast notification system
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
const container = document.getElementById('toastContainer');
|
||
if (!container) return;
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icons = {
|
||
success: '✓',
|
||
error: '✕',
|
||
warning: '⚠',
|
||
info: 'ℹ'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${icons[type] || icons.info}</span>
|
||
<span class="toast-message">${message}</span>
|
||
`;
|
||
|
||
container.appendChild(toast);
|
||
|
||
// Auto remove after duration
|
||
setTimeout(() => {
|
||
toast.classList.add('fade-out');
|
||
setTimeout(() => {
|
||
container.removeChild(toast);
|
||
}, 300);
|
||
}, duration);
|
||
}
|
||
|
||
// Initialize on page load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
checkAuth();
|
||
});
|
||
|
||
// Check if user is already logged in
|
||
function checkAuth() {
|
||
const token = localStorage.getItem('accessToken');
|
||
const user = localStorage.getItem('currentUser');
|
||
|
||
if (token && user) {
|
||
accessToken = token;
|
||
currentUser = JSON.parse(user);
|
||
showMainApp();
|
||
} else {
|
||
showLoginPage();
|
||
}
|
||
}
|
||
|
||
// Show login page
|
||
function showLoginPage() {
|
||
document.getElementById('loginPage').style.display = 'flex';
|
||
document.getElementById('mainApp').style.display = 'none';
|
||
|
||
const loginForm = document.getElementById('loginForm');
|
||
if (loginForm) loginForm.addEventListener('submit', handleLogin);
|
||
}
|
||
|
||
// Show main app
|
||
function showMainApp() {
|
||
document.getElementById('loginPage').style.display = 'none';
|
||
document.getElementById('mainApp').style.display = 'block';
|
||
|
||
// Handle backward compatibility: convert old is_admin format to role
|
||
if (!currentUser.role && currentUser.is_admin !== undefined) {
|
||
currentUser.role = currentUser.is_admin ? 'admin' : 'user';
|
||
}
|
||
// Default to 'user' if role is still undefined
|
||
if (!currentUser.role) {
|
||
currentUser.role = 'user';
|
||
}
|
||
|
||
const userDisplay = document.getElementById('currentUser');
|
||
if (userDisplay) {
|
||
const roleLabel = currentUser.role.charAt(0).toUpperCase() + currentUser.role.slice(1);
|
||
userDisplay.textContent = `👤 ${currentUser.username} [${roleLabel}]`;
|
||
}
|
||
|
||
const adminBtn = document.getElementById('adminBtn');
|
||
if (adminBtn) {
|
||
adminBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
|
||
}
|
||
|
||
const locationsBtn = document.getElementById('locationsBtn');
|
||
if (locationsBtn) {
|
||
locationsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
|
||
}
|
||
|
||
const reportsBtn = document.getElementById('reportsBtn');
|
||
if (reportsBtn) {
|
||
reportsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
|
||
}
|
||
|
||
// Hide action buttons for read-only users
|
||
const isReadOnly = currentUser.role === 'readonly';
|
||
const addDrugBtn = document.getElementById('addDrugBtn');
|
||
const dispenseBtn = document.getElementById('dispenseBtn');
|
||
const printNotesBtn = document.getElementById('printNotesBtn');
|
||
|
||
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
|
||
|
||
setupEventListeners();
|
||
loadDrugs();
|
||
}
|
||
|
||
// Handle login
|
||
async function handleLogin(e) {
|
||
e.preventDefault();
|
||
const username = document.getElementById('loginUsername').value;
|
||
const password = document.getElementById('loginPassword').value;
|
||
|
||
try {
|
||
const response = await fetch(`${API_URL}/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Invalid credentials');
|
||
}
|
||
|
||
const data = await response.json();
|
||
accessToken = data.access_token;
|
||
currentUser = data.user;
|
||
|
||
localStorage.setItem('accessToken', accessToken);
|
||
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||
|
||
document.getElementById('loginForm').reset();
|
||
const errorDiv = document.getElementById('loginError');
|
||
if (errorDiv) errorDiv.style.display = 'none';
|
||
showMainApp();
|
||
} catch (error) {
|
||
const errorDiv = document.getElementById('loginError');
|
||
if (errorDiv) {
|
||
errorDiv.textContent = error.message;
|
||
errorDiv.style.display = 'block';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle register
|
||
// Logout
|
||
function handleLogout() {
|
||
localStorage.removeItem('accessToken');
|
||
localStorage.removeItem('currentUser');
|
||
accessToken = null;
|
||
currentUser = null;
|
||
const loginForm = document.getElementById('loginForm');
|
||
if (loginForm) loginForm.reset();
|
||
const registerForm = document.getElementById('registerForm');
|
||
if (registerForm) {
|
||
registerForm.style.display = 'none';
|
||
}
|
||
const form = document.getElementById('loginForm');
|
||
if (form) form.style.display = 'block';
|
||
showLoginPage();
|
||
}
|
||
|
||
// API helper with authentication
|
||
async function apiCall(endpoint, options = {}) {
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
...options.headers
|
||
};
|
||
|
||
if (accessToken) {
|
||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||
}
|
||
|
||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||
...options,
|
||
headers
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
handleLogout();
|
||
throw new Error('Authentication expired');
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
// Setup event listeners
|
||
function setupEventListeners() {
|
||
const drugForm = document.getElementById('drugForm');
|
||
const variantForm = document.getElementById('variantForm');
|
||
const editVariantForm = document.getElementById('editVariantForm');
|
||
const dispenseForm = document.getElementById('dispenseForm');
|
||
const prescribeForm = document.getElementById('prescribeForm');
|
||
const editForm = document.getElementById('editForm');
|
||
const printNotesForm = document.getElementById('printNotesForm');
|
||
const addModal = document.getElementById('addModal');
|
||
const addVariantModal = document.getElementById('addVariantModal');
|
||
const editVariantModal = document.getElementById('editVariantModal');
|
||
const dispenseModal = document.getElementById('dispenseModal');
|
||
const prescribeModal = document.getElementById('prescribeModal');
|
||
const editModal = document.getElementById('editModal');
|
||
const printNotesModal = document.getElementById('printNotesModal');
|
||
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 cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
|
||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
|
||
const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn');
|
||
const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn');
|
||
const addVariantFromDeliveryBtn = document.getElementById('addVariantFromDeliveryBtn');
|
||
const addVariantPackRowBtn = document.getElementById('addVariantPackRowBtn');
|
||
const addEditVariantPackRowBtn = document.getElementById('addEditVariantPackRowBtn');
|
||
const variantUnitSelect = document.getElementById('variantUnit');
|
||
const variantStrengthInput = document.getElementById('variantStrength');
|
||
const editVariantUnitSelect = document.getElementById('editVariantUnit');
|
||
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
|
||
const showAllBtn = document.getElementById('showAllBtn');
|
||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||
const userMenuBtn = document.getElementById('userMenuBtn');
|
||
const adminBtn = document.getElementById('adminBtn');
|
||
const locationsBtn = document.getElementById('locationsBtn');
|
||
const reportsBtn = document.getElementById('reportsBtn');
|
||
const logoutBtn = document.getElementById('logoutBtn');
|
||
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||
|
||
// Modal close buttons
|
||
const closeButtons = document.querySelectorAll('.close');
|
||
|
||
if (drugForm) drugForm.addEventListener('submit', handleAddDrug);
|
||
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
|
||
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
|
||
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
|
||
if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug);
|
||
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
||
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
|
||
|
||
const batchReceiveForm = document.getElementById('batchReceiveForm');
|
||
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
|
||
if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal));
|
||
|
||
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
|
||
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
|
||
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => closeModal(receiveDeliveryModal));
|
||
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
|
||
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
|
||
if (addVariantPackRowBtn) addVariantPackRowBtn.addEventListener('click', () => appendVariantPackRow());
|
||
if (addEditVariantPackRowBtn) addEditVariantPackRowBtn.addEventListener('click', () => appendEditVariantPackRow());
|
||
if (variantUnitSelect) {
|
||
variantUnitSelect.addEventListener('change', () => {
|
||
refreshVariantPackRowLabels();
|
||
});
|
||
}
|
||
if (editVariantUnitSelect) {
|
||
editVariantUnitSelect.addEventListener('change', () => {
|
||
refreshEditVariantPackRowLabels();
|
||
});
|
||
}
|
||
if (variantStrengthInput && variantUnitSelect) {
|
||
variantStrengthInput.addEventListener('blur', () => {
|
||
variantUnitSelect.value = inferBaseUnitFromStrength(variantStrengthInput.value);
|
||
refreshVariantPackRowLabels();
|
||
});
|
||
}
|
||
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
|
||
|
||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||
updateDispenseDrugSelect();
|
||
updateDispenseModeUi();
|
||
openModal(dispenseModal);
|
||
});
|
||
|
||
if (cancelAddBtn) cancelAddBtn.addEventListener('click', () => closeModal(addModal));
|
||
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
||
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
||
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||
if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal));
|
||
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
||
|
||
const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn');
|
||
if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal));
|
||
|
||
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||
|
||
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
|
||
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal')));
|
||
|
||
const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn');
|
||
if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal')));
|
||
|
||
const createLocationForm = document.getElementById('createLocationForm');
|
||
if (createLocationForm) createLocationForm.addEventListener('submit', createLocation);
|
||
|
||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||
if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword);
|
||
|
||
const cancelChangePasswordBtn = document.getElementById('cancelChangePasswordBtn');
|
||
if (cancelChangePasswordBtn) cancelChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('changePasswordModal')));
|
||
|
||
const adminChangePasswordForm = document.getElementById('adminChangePasswordForm');
|
||
if (adminChangePasswordForm) adminChangePasswordForm.addEventListener('submit', handleAdminChangePassword);
|
||
|
||
const cancelAdminChangePasswordBtn = document.getElementById('cancelAdminChangePasswordBtn');
|
||
if (cancelAdminChangePasswordBtn) cancelAdminChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('adminChangePasswordModal')));
|
||
|
||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||
const modal = e.target.closest('.modal');
|
||
closeModal(modal);
|
||
}));
|
||
|
||
if (showAllBtn) showAllBtn.addEventListener('click', () => {
|
||
showLowStockOnly = false;
|
||
updateFilterButtons();
|
||
renderDrugs();
|
||
});
|
||
if (showLowStockBtn) showLowStockBtn.addEventListener('click', () => {
|
||
showLowStockOnly = true;
|
||
updateFilterButtons();
|
||
renderDrugs();
|
||
});
|
||
if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => {
|
||
selectedLocationFilter = e.target.value;
|
||
renderDrugs();
|
||
});
|
||
|
||
// User menu
|
||
if (userMenuBtn) userMenuBtn.addEventListener('click', () => {
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||
});
|
||
|
||
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
|
||
if (adminBtn) adminBtn.addEventListener('click', openUserManagement);
|
||
if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement);
|
||
if (reportsBtn) reportsBtn.addEventListener('click', openReportsPage);
|
||
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
|
||
|
||
// Search functionality
|
||
const drugSearch = document.getElementById('drugSearch');
|
||
if (drugSearch) {
|
||
let searchTimeout;
|
||
drugSearch.addEventListener('input', (e) => {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
searchTerm = e.target.value.toLowerCase().trim();
|
||
renderDrugs();
|
||
}, 150);
|
||
});
|
||
}
|
||
|
||
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
|
||
if (dispenseQuantityInput) {
|
||
dispenseQuantityInput.addEventListener('input', () => {
|
||
const mode = getSelectedDispenseMode();
|
||
if (mode !== 'subunit') {
|
||
return;
|
||
}
|
||
|
||
const packSelect = document.getElementById('dispensePackSelect');
|
||
const packCount = document.getElementById('dispensePackCount');
|
||
const packPreview = document.getElementById('dispensePackPreview');
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
|
||
if (packSelect) packSelect.value = '';
|
||
if (packCount) packCount.value = '';
|
||
if (packPreview && variant) {
|
||
packPreview.textContent = `Enter direct quantity in ${variant.unit}.`;
|
||
}
|
||
|
||
autoAllocateDispenseBatches();
|
||
});
|
||
}
|
||
|
||
// Close modal when clicking outside
|
||
window.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('modal')) {
|
||
closeModal(e.target);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load drugs from API
|
||
async function loadDrugs() {
|
||
try {
|
||
const response = await apiCall('/drugs');
|
||
if (!response.ok) throw new Error('Failed to load drugs');
|
||
allDrugs = await response.json();
|
||
|
||
updateLocationFilterOptions();
|
||
renderDrugs();
|
||
updateDispenseDrugSelect();
|
||
} catch (error) {
|
||
console.error('Error loading drugs:', error);
|
||
document.getElementById('drugsList').innerHTML =
|
||
'<p class="empty">Error loading drugs. Make sure the backend is running.</p>';
|
||
}
|
||
}
|
||
|
||
// Modal utility functions
|
||
function openModal(modal) {
|
||
// Find the highest z-index among currently visible modals
|
||
const visibleModals = document.querySelectorAll('.modal.show');
|
||
let maxZIndex = 1000;
|
||
|
||
visibleModals.forEach(m => {
|
||
const zIndex = parseInt(window.getComputedStyle(m).zIndex, 10) || 1000;
|
||
if (zIndex > maxZIndex) {
|
||
maxZIndex = zIndex;
|
||
}
|
||
});
|
||
|
||
// Set the new modal's z-index higher than any existing modal
|
||
modal.style.zIndex = (maxZIndex + 100).toString();
|
||
modal.classList.add('show');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeModal(modal) {
|
||
modal.classList.remove('show');
|
||
modal.style.zIndex = '1000';
|
||
document.body.style.overflow = 'auto';
|
||
}
|
||
|
||
function closeEditModal() {
|
||
closeModal(document.getElementById('editModal'));
|
||
}
|
||
function updateDispenseDrugSelect() {
|
||
const select = document.getElementById('dispenseDrugSelect');
|
||
select.innerHTML = '<option value="">-- Select a drug variant --</option>';
|
||
|
||
allDrugs.forEach(drug => {
|
||
drug.variants.forEach(variant => {
|
||
const option = document.createElement('option');
|
||
option.value = variant.id;
|
||
option.textContent = `${drug.name} ${variant.strength} (${variant.quantity} ${variant.unit})`;
|
||
select.appendChild(option);
|
||
});
|
||
});
|
||
|
||
const packSelect = document.getElementById('dispensePackSelect');
|
||
const packCount = document.getElementById('dispensePackCount');
|
||
const packPreview = document.getElementById('dispensePackPreview');
|
||
const quantityModeRadio = document.getElementById('dispenseModeQuantity');
|
||
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.';
|
||
}
|
||
|
||
currentDispenseBatches = [];
|
||
|
||
updateDispenseModeUi();
|
||
}
|
||
|
||
function getSelectedDispenseMode() {
|
||
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit';
|
||
}
|
||
|
||
function populateDispensePackSelect(variant) {
|
||
const packSelect = document.getElementById('dispensePackSelect');
|
||
const packCount = document.getElementById('dispensePackCount');
|
||
const packPreview = document.getElementById('dispensePackPreview');
|
||
if (!packSelect) return;
|
||
|
||
const activePacks = getActivePacksForVariant(variant);
|
||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||
|
||
activePacks.forEach(pack => {
|
||
const option = document.createElement('option');
|
||
option.value = String(pack.id);
|
||
option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||
packSelect.appendChild(option);
|
||
});
|
||
|
||
if (packCount) packCount.value = '';
|
||
if (packPreview) {
|
||
packPreview.textContent = activePacks.length > 0
|
||
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
||
: `No active packs for this variant.`;
|
||
}
|
||
}
|
||
|
||
function updateDispenseModeUi() {
|
||
const mode = getSelectedDispenseMode();
|
||
const quantityGroup = document.getElementById('dispenseQuantityGroup');
|
||
const packRow = document.getElementById('dispensePackRow');
|
||
const quantityInput = document.getElementById('dispenseQuantity');
|
||
const packSelect = document.getElementById('dispensePackSelect');
|
||
const packCount = document.getElementById('dispensePackCount');
|
||
|
||
if (quantityGroup) {
|
||
quantityGroup.style.display = mode === 'subunit' ? '' : 'none';
|
||
}
|
||
if (packRow) {
|
||
packRow.style.display = mode === 'pack' ? '' : 'none';
|
||
}
|
||
|
||
if (quantityInput) {
|
||
quantityInput.required = mode === 'subunit';
|
||
}
|
||
if (packSelect) {
|
||
packSelect.required = mode === 'pack';
|
||
}
|
||
if (packCount) {
|
||
packCount.required = mode === 'pack';
|
||
}
|
||
|
||
if (currentDispenseBatches.length > 0) {
|
||
renderDispenseBatchAllocationRows(currentDispenseBatches);
|
||
}
|
||
autoAllocateDispenseBatches();
|
||
}
|
||
|
||
function updateDispenseQuantityFromPack() {
|
||
const mode = getSelectedDispenseMode();
|
||
if (mode !== 'pack') return;
|
||
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||
const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10);
|
||
const packCount = parseFloat(document.getElementById('dispensePackCount')?.value || '');
|
||
const quantityInput = document.getElementById('dispenseQuantity');
|
||
const preview = document.getElementById('dispensePackPreview');
|
||
|
||
const variant = getVariantById(variantId);
|
||
if (!quantityInput || !preview || !variant) return;
|
||
|
||
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
||
const totalAvailablePacks = selectedPack ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
|
||
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
||
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
|
||
return;
|
||
}
|
||
if (totalAvailablePacks <= 0) {
|
||
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||
preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
if (packCount > totalAvailablePacks) {
|
||
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||
preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
||
quantityInput.value = String(quantity);
|
||
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
|
||
autoAllocateDispenseBatches();
|
||
return;
|
||
}
|
||
|
||
preview.textContent = selectedPack
|
||
? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available | 1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
|
||
: `Select a pack to calculate quantity.`;
|
||
autoAllocateDispenseBatches();
|
||
}
|
||
|
||
function formatDisplayDate(value) {
|
||
if (!value) {
|
||
return 'Unknown';
|
||
}
|
||
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return parsed.toLocaleDateString();
|
||
}
|
||
|
||
function formatDisplayNumber(value) {
|
||
const numeric = Number(value);
|
||
if (Number.isNaN(numeric)) return '0';
|
||
return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3)));
|
||
}
|
||
|
||
function isBatchExpired(batch) {
|
||
if (!batch?.expiry_date) {
|
||
return false;
|
||
}
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const expiryDate = new Date(`${batch.expiry_date}T00:00:00`);
|
||
return expiryDate < today;
|
||
}
|
||
|
||
function renderVariantInventoryDetails(variant) {
|
||
const activePacks = getActivePacksForVariant(variant);
|
||
const batches = [...(variant.batches || [])]
|
||
.filter(batch => Number(batch.quantity) > 0)
|
||
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||
|
||
const packsHtml = activePacks.length > 0
|
||
? activePacks.map(pack => `
|
||
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
||
<strong>${escapeHtml(pack.label)}</strong>
|
||
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
|
||
</div>
|
||
`).join('')
|
||
: '<div style="padding: 6px 8px; background: #ffffff; border: 1px dashed #cfd8e3; border-radius: 5px; font-size: 0.9em; color: #6b7280;">No active packs configured</div>';
|
||
|
||
const batchesHtml = batches.length > 0
|
||
? batches.map(batch => {
|
||
const locationLabel = getBatchLocationLabel(batch);
|
||
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label;
|
||
const stocktakeLabel = hasPackState
|
||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
||
|
||
return `
|
||
<div style="padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
||
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||
<span style="color: #4b5563;">Expires ${formatDisplayDate(batch.expiry_date)}</span>
|
||
</div>
|
||
<div style="margin-top: 4px; color: #374151;">${escapeHtml(locationLabel)} | ${stocktakeLabel}</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 getBatchLocationLabel(batch) {
|
||
return batch.location_name || batch.location?.name || `Location #${batch.location_id}`;
|
||
}
|
||
|
||
function updateLocationFilterOptions() {
|
||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||
if (!locationFilterSelect) return;
|
||
|
||
const previousValue = selectedLocationFilter;
|
||
const locations = new Set();
|
||
|
||
allDrugs.forEach(drug => {
|
||
drug.variants.forEach(variant => {
|
||
(variant.batches || []).forEach(batch => {
|
||
if (batch.quantity > 0) {
|
||
locations.add(getBatchLocationLabel(batch));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
locationFilterSelect.innerHTML = '<option value="">All Locations</option>';
|
||
Array.from(locations)
|
||
.sort((a, b) => a.localeCompare(b))
|
||
.forEach(location => {
|
||
const option = document.createElement('option');
|
||
option.value = location;
|
||
option.textContent = location;
|
||
locationFilterSelect.appendChild(option);
|
||
});
|
||
|
||
if (previousValue && locations.has(previousValue)) {
|
||
selectedLocationFilter = previousValue;
|
||
locationFilterSelect.value = previousValue;
|
||
} else {
|
||
selectedLocationFilter = '';
|
||
locationFilterSelect.value = '';
|
||
}
|
||
}
|
||
|
||
function getDispenseRequestedQuantity() {
|
||
const quantity = parseFloat(document.getElementById('dispenseQuantity')?.value || '');
|
||
return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity;
|
||
}
|
||
|
||
function getSelectedDispensePack() {
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||
const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
if (!variant || Number.isNaN(packId)) {
|
||
return null;
|
||
}
|
||
|
||
return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null;
|
||
}
|
||
|
||
function batchMatchesSelectedPack(batch, selectedPack) {
|
||
if (!batch || !selectedPack) {
|
||
return false;
|
||
}
|
||
|
||
if (Number(batch.received_pack_id) === Number(selectedPack.id)) {
|
||
return true;
|
||
}
|
||
|
||
const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase();
|
||
const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase();
|
||
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
|
||
return true;
|
||
}
|
||
|
||
const batchPackSize = Number(batch.received_pack_size_snapshot || 0);
|
||
const selectedPackSize = Number(selectedPack.pack_size_in_base_units || 0);
|
||
if (batchPackSize > 0 && selectedPackSize > 0 && Math.abs(batchPackSize - selectedPackSize) <= 1e-6) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||
if (mode !== 'pack') {
|
||
return Number(batch.quantity || 0);
|
||
}
|
||
|
||
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||
return 0;
|
||
}
|
||
|
||
const fullPackCount = Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||
return fullPackCount * Number(selectedPack.pack_size_in_base_units || 0);
|
||
}
|
||
|
||
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
|
||
}
|
||
|
||
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
|
||
if (!selectedPack) {
|
||
return 0;
|
||
}
|
||
|
||
return currentDispenseBatches.reduce((sum, batch) => {
|
||
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||
return sum;
|
||
}
|
||
return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||
}, 0);
|
||
}
|
||
|
||
function renderDispenseBatchAllocationRows(activeBatches) {
|
||
const batchInfoContent = document.getElementById('batchInfoContent');
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const unitLabel = variant?.unit || 'units';
|
||
|
||
if (!batchInfoContent) return;
|
||
|
||
if (!activeBatches.length) {
|
||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
|
||
return;
|
||
}
|
||
|
||
const mode = getSelectedDispenseMode();
|
||
const selectedPack = getSelectedDispensePack();
|
||
|
||
batchInfoContent.innerHTML = activeBatches.map((batch, index) => {
|
||
const expiryDate = new Date(batch.expiry_date);
|
||
const locationLabel = getBatchLocationLabel(batch);
|
||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||
const today = new Date();
|
||
const isExpired = expiryDate < today;
|
||
const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
|
||
|
||
let expiryStatus = 'OK';
|
||
let statusColor = '#4caf50';
|
||
if (isExpired) {
|
||
expiryStatus = 'EXPIRED';
|
||
statusColor = '#d32f2f';
|
||
} else if (daysToExpiry <= 7) {
|
||
expiryStatus = `${daysToExpiry}d left`;
|
||
statusColor = '#ff9800';
|
||
}
|
||
|
||
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||
: 0;
|
||
const allocationLabel = mode === 'pack' ? 'Allocate Packs' : 'Allocate';
|
||
const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDispenseQuantity(batch, mode, selectedPack);
|
||
const allocationStep = mode === 'pack' ? 1 : 0.1;
|
||
const batchAvailabilityNote = mode === 'pack'
|
||
? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0
|
||
? 'No full packs available in this batch'
|
||
: '')
|
||
: `Available to allocate: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||
|
||
return `
|
||
<div style="padding: 10px; margin: 6px 0; background: white; border: 1px solid #e0e0e0; border-radius: 4px; ${index === 0 ? 'border-left: 3px solid #2196F3; background: #f8fbff;' : ''}">
|
||
<div style="display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) 140px; gap: 12px; align-items: end;">
|
||
<div>
|
||
<div><strong>${escapeHtml(batch.batch_number)}</strong>${index === 0 ? ' <span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FEFO</span>' : ''}</div>
|
||
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||
Available: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||
Location: <strong>${escapeHtml(locationLabel)}</strong> |
|
||
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
|
||
</div>
|
||
</div>
|
||
<div style="font-size: 0.9em; color: #374151;">
|
||
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
||
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: ''}
|
||
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 0;">
|
||
<label for="dispenseBatchAllocation-${batch.id}">${allocationLabel}</label>
|
||
<input
|
||
type="number"
|
||
id="dispenseBatchAllocation-${batch.id}"
|
||
class="dispense-batch-allocation"
|
||
data-batch-id="${batch.id}"
|
||
data-allocation-mode="${mode}"
|
||
data-pack-size="${mode === 'pack' && selectedPack ? selectedPack.pack_size_in_base_units : ''}"
|
||
min="0"
|
||
max="${allocationMax}"
|
||
step="${allocationStep}"
|
||
value="0"
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
batchInfoContent.querySelectorAll('.dispense-batch-allocation').forEach(input => {
|
||
input.addEventListener('input', updateDispenseAllocationSummary);
|
||
});
|
||
}
|
||
|
||
function renderExpiredDispenseBatches(expiredBatches) {
|
||
const expiredDetails = document.getElementById('expiredBatchDetails');
|
||
const expiredContent = document.getElementById('expiredBatchContent');
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
const unitLabel = variant?.unit || 'units';
|
||
|
||
if (!expiredDetails || !expiredContent) {
|
||
return;
|
||
}
|
||
|
||
if (!expiredBatches.length) {
|
||
expiredDetails.style.display = 'none';
|
||
expiredDetails.open = false;
|
||
expiredContent.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
expiredDetails.style.display = 'block';
|
||
expiredContent.innerHTML = expiredBatches.map(batch => {
|
||
const locationLabel = getBatchLocationLabel(batch);
|
||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||
|
||
return `
|
||
<div style="padding: 8px; margin: 6px 0; background: white; border: 1px solid #f0d7a1; border-radius: 4px;">
|
||
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||
<span style="color: #b45309; font-weight: 600;">Expired ${expiryLabel}</span>
|
||
</div>
|
||
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||
Qty: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||
Location: <strong>${escapeHtml(locationLabel)}</strong>
|
||
</div>
|
||
<div style="font-size: 0.9em; color: #374151; margin-top: 4px;">${stocktakeLabel}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// 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 = [];
|
||
renderExpiredDispenseBatches([]);
|
||
updateDispenseAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
const variant = getVariantById(variantId);
|
||
if (variant) {
|
||
populateDispensePackSelect(variant);
|
||
}
|
||
updateDispenseModeUi();
|
||
|
||
batchInfoSection.style.display = 'block';
|
||
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
|
||
renderExpiredDispenseBatches([]);
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}/batches`);
|
||
if (!response.ok) throw new Error('Failed to load batches');
|
||
|
||
const batches = await response.json();
|
||
|
||
const stockedBatches = batches.filter(b => b.quantity > 0);
|
||
const expiredBatches = stockedBatches.filter(isBatchExpired);
|
||
const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch));
|
||
currentDispenseBatches = activeBatches;
|
||
renderExpiredDispenseBatches(expiredBatches);
|
||
|
||
if (activeBatches.length === 0) {
|
||
batchInfoContent.innerHTML = expiredBatches.length > 0
|
||
? '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>'
|
||
: '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
|
||
updateDispenseAllocationSummary();
|
||
return;
|
||
}
|
||
|
||
// Sort by expiry date (FEFO order)
|
||
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||
currentDispenseBatches = activeBatches;
|
||
renderDispenseBatchAllocationRows(activeBatches);
|
||
autoAllocateDispenseBatches();
|
||
} catch (error) {
|
||
console.error('Error loading batches:', error);
|
||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
|
||
currentDispenseBatches = [];
|
||
renderExpiredDispenseBatches([]);
|
||
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;
|
||
}
|
||
|
||
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 totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
|
||
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||
|
||
if (!summarySection || !summaryContent || !variantId || !inputs.length) {
|
||
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 = `<span style="color: #666;">Enter a dispense amount to allocate batches.</span>`;
|
||
return;
|
||
}
|
||
|
||
if (mode === 'pack' && selectedPack) {
|
||
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
|
||
if (totalAvailablePacks <= 0) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispense.</span>`;
|
||
return;
|
||
}
|
||
if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.</span>`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (requestedQuantity - totalAvailableQuantity > 1e-6) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.</span>`;
|
||
return;
|
||
}
|
||
|
||
if (invalidInput) {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">One or more batch allocations exceed available stock or are not valid full-pack amounts.</span>`;
|
||
return;
|
||
}
|
||
|
||
if (Math.abs(difference) <= 1e-6) {
|
||
if (mode === 'pack' && selectedPack) {
|
||
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
|
||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).</span>`;
|
||
} else {
|
||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.</span>`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (difference > 0) {
|
||
if (mode === 'pack' && selectedPack) {
|
||
const differencePacks = difference / selectedPack.pack_size_in_base_units;
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(differencePacks)} more pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
|
||
} else {
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (mode === 'pack' && selectedPack) {
|
||
const differencePacks = Math.abs(difference) / selectedPack.pack_size_in_base_units;
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(differencePacks)} pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
|
||
return;
|
||
}
|
||
|
||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||
}
|
||
|
||
// Render drugs list
|
||
function renderDrugs() {
|
||
const drugsList = document.getElementById('drugsList');
|
||
let drugsToShow = allDrugs;
|
||
|
||
// Apply search filter
|
||
if (searchTerm) {
|
||
drugsToShow = drugsToShow.filter(drug =>
|
||
drug.name.toLowerCase().includes(searchTerm) ||
|
||
(drug.description && drug.description.toLowerCase().includes(searchTerm)) ||
|
||
drug.variants.some(variant => variant.strength.toLowerCase().includes(searchTerm))
|
||
);
|
||
}
|
||
|
||
// Apply stock filter
|
||
if (showLowStockOnly) {
|
||
drugsToShow = drugsToShow.filter(drug =>
|
||
drug.variants.some(variant => variant.quantity <= variant.low_stock_threshold)
|
||
);
|
||
}
|
||
|
||
// Apply location filter
|
||
if (selectedLocationFilter) {
|
||
drugsToShow = drugsToShow.filter(drug =>
|
||
drug.variants.some(variant =>
|
||
(variant.batches || []).some(batch =>
|
||
batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// Sort alphabetically by drug name
|
||
drugsToShow = drugsToShow.sort((a, b) =>
|
||
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||
);
|
||
|
||
if (drugsToShow.length === 0) {
|
||
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
|
||
return;
|
||
}
|
||
|
||
drugsList.innerHTML = drugsToShow.map(drug => {
|
||
const totalVariants = drug.variants.length;
|
||
const lowStockVariants = drug.variants.filter(v => v.quantity <= v.low_stock_threshold).length;
|
||
const totalQuantity = drug.variants.reduce((sum, v) => sum + v.quantity, 0);
|
||
const isLowStock = lowStockVariants > 0;
|
||
const isExpanded = expandedDrugs.has(drug.id);
|
||
const isReadOnly = currentUser.role === 'readonly';
|
||
const isControlled = drug.is_controlled;
|
||
|
||
const variantsHtml = isExpanded ? `
|
||
${drug.variants.map(variant => {
|
||
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold;
|
||
const variantExpanded = expandedVariants.has(variant.id);
|
||
return `
|
||
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}" onclick="toggleVariantExpansion(${variant.id}, event)">
|
||
<div class="variant-info">
|
||
<div class="variant-details">
|
||
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div>
|
||
<div class="variant-quantity">${variant.quantity} ${escapeHtml(variant.unit)}</div>
|
||
</div>
|
||
<div class="variant-status">
|
||
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
|
||
${variantIsLowStock ? 'Low Stock' : 'OK'}
|
||
</span>
|
||
<span style="margin-left: 8px; font-size: 0.85em; color: #475569;">Inventory ${variantExpanded ? '▼' : '▶'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="variant-actions">
|
||
${!isReadOnly ? `
|
||
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button>
|
||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${variant.id})">💊 Dispense</button>
|
||
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${variant.id})">Edit</button>
|
||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${variant.id})" title="${variant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
|
||
` : ''}
|
||
</div>
|
||
${variantExpanded ? renderVariantInventoryDetails(variant) : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}` : '';
|
||
|
||
return `
|
||
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
|
||
<div class="drug-info">
|
||
<div class="drug-name">
|
||
${escapeHtml(drug.name)}
|
||
${isControlled ? '<span style="background: #d32f2f; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.75em; margin-left: 8px; display: inline-block;">⚠️ CONTROLLED</span>' : ''}
|
||
</div>
|
||
<div class="drug-description">${drug.description ? escapeHtml(drug.description) : 'No description'}</div>
|
||
<div class="drug-quantity">${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)</div>
|
||
<div class="drug-status">
|
||
<span class="drug-badge ${isLowStock ? 'badge-low' : 'badge-normal'}">
|
||
${isLowStock ? `${lowStockVariants} low` : 'All OK'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="drug-actions">
|
||
${!isReadOnly ? `
|
||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
|
||
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})">➕ Add</button>
|
||
` : ''}
|
||
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
|
||
${!isReadOnly ? `
|
||
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditModal(${drug.id})">Edit Drug</button>
|
||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteDrug(${drug.id})" title="${drug.variants.some(v => v.has_inventory_history) ? 'Drug has variants with history and cannot be deleted' : ''}">Delete</button>
|
||
` : ''}
|
||
<span class="expand-icon">${isExpanded ? '▼' : '▶'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="drug-variants ${isExpanded ? 'expanded' : ''}" id="variants-${drug.id}">
|
||
${variantsHtml}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Handle add drug form
|
||
async function handleAddDrug(e) {
|
||
e.preventDefault();
|
||
|
||
const drugData = {
|
||
name: document.getElementById('drugName').value,
|
||
description: document.getElementById('drugDescription').value,
|
||
is_controlled: document.getElementById('drugIsControlled').checked
|
||
};
|
||
|
||
try {
|
||
// Create the drug first
|
||
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();
|
||
|
||
// Check if initial variant data was provided
|
||
const variantStrength = document.getElementById('initialVariantStrength').value.trim();
|
||
if (variantStrength) {
|
||
const variantData = {
|
||
strength: variantStrength,
|
||
quantity: parseFloat(document.getElementById('initialVariantQuantity').value) || 0,
|
||
unit: document.getElementById('initialVariantUnit').value || 'units',
|
||
low_stock_threshold: parseFloat(document.getElementById('initialVariantThreshold').value) || 10
|
||
};
|
||
|
||
const variantResponse = await apiCall(`/drugs/${createdDrug.id}/variants`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(variantData)
|
||
});
|
||
|
||
if (!variantResponse.ok) throw new Error('Failed to add variant');
|
||
}
|
||
|
||
document.getElementById('drugForm').reset();
|
||
document.getElementById('initialVariantUnit').value = 'units';
|
||
document.getElementById('initialVariantThreshold').value = '10';
|
||
closeModal(document.getElementById('addModal'));
|
||
await loadDrugs();
|
||
showToast('Drug added successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error adding drug:', error);
|
||
showToast('Failed to add drug. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Handle dispense drug form
|
||
async function handleDispenseDrug(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||
const dispenseMode = getSelectedDispenseMode();
|
||
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
|
||
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
|
||
const animalName = document.getElementById('dispenseAnimal').value;
|
||
const notes = document.getElementById('dispenseNotes').value;
|
||
|
||
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
|
||
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
|
||
const variant = getVariantById(variantId);
|
||
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 (!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 (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 (Math.abs(allocatedTotal - quantity) > 1e-6) {
|
||
showToast('Batch allocations must exactly match the requested dispense quantity.', '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,
|
||
animal_name: animalName || null,
|
||
notes: notes || null,
|
||
allocations
|
||
};
|
||
|
||
try {
|
||
const response = await apiCall('/dispense', {
|
||
method: 'POST',
|
||
body: JSON.stringify(dispensingData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to dispense drug');
|
||
}
|
||
|
||
document.getElementById('dispenseForm').reset();
|
||
closeModal(document.getElementById('dispenseModal'));
|
||
await loadDrugs();
|
||
showToast('Drug dispensed successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error dispensing drug:', error);
|
||
showToast('Failed to dispense drug: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Open edit modal
|
||
function openEditModal(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
|
||
document.getElementById('editDrugId').value = drug.id;
|
||
document.getElementById('editDrugName').value = drug.name;
|
||
document.getElementById('editDrugDescription').value = drug.description || '';
|
||
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
|
||
|
||
document.getElementById('editModal').classList.add('show');
|
||
}
|
||
|
||
// Close edit modal
|
||
function closeEditModal() {
|
||
document.getElementById('editModal').classList.remove('show');
|
||
document.getElementById('editForm').reset();
|
||
}
|
||
|
||
// Show variants for a drug
|
||
function toggleDrugExpansion(drugId) {
|
||
if (expandedDrugs.has(drugId)) {
|
||
expandedDrugs.delete(drugId);
|
||
const collapsedDrug = allDrugs.find(drug => drug.id === drugId);
|
||
if (collapsedDrug) {
|
||
(collapsedDrug.variants || []).forEach(variant => expandedVariants.delete(variant.id));
|
||
}
|
||
} else {
|
||
expandedDrugs.add(drugId);
|
||
}
|
||
renderDrugs();
|
||
}
|
||
|
||
function toggleVariantExpansion(variantId, event) {
|
||
if (event) {
|
||
event.stopPropagation();
|
||
}
|
||
|
||
if (expandedVariants.has(variantId)) {
|
||
expandedVariants.delete(variantId);
|
||
} else {
|
||
expandedVariants.add(variantId);
|
||
}
|
||
renderDrugs();
|
||
}
|
||
|
||
// Open add variant modal
|
||
function openAddVariantModal(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
|
||
currentDrug = drug;
|
||
const form = document.getElementById('variantForm');
|
||
if (form) form.reset();
|
||
document.getElementById('variantDrugId').value = drug.id;
|
||
initializeVariantPackRows();
|
||
document.getElementById('addVariantModal').classList.add('show');
|
||
}
|
||
|
||
function inferBaseUnitFromStrength(strength) {
|
||
const value = String(strength || '').toLowerCase();
|
||
if (value.includes('/ml') || value.includes('ml')) return 'ml';
|
||
if (value.includes('tablet')) return 'tablets';
|
||
if (value.includes('capsule')) return 'capsules';
|
||
return 'units';
|
||
}
|
||
|
||
function getVariantPackRowsContainer() {
|
||
return document.getElementById('variantPackRows');
|
||
}
|
||
|
||
function getEditVariantPackRowsContainer() {
|
||
return document.getElementById('editVariantPackRows');
|
||
}
|
||
|
||
function refreshVariantPackRowLabels() {
|
||
const container = getVariantPackRowsContainer();
|
||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||
if (!container) return;
|
||
|
||
container.querySelectorAll('.variant-pack-row').forEach(row => {
|
||
const packUnit = row.querySelector('.variant-pack-unit')?.value || 'pack';
|
||
const label = row.querySelector('.variant-pack-size-label');
|
||
if (!label) return;
|
||
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
|
||
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
|
||
});
|
||
}
|
||
|
||
function refreshEditVariantPackRowLabels() {
|
||
const container = getEditVariantPackRowsContainer();
|
||
const baseUnit = document.getElementById('editVariantUnit')?.value || 'units';
|
||
if (!container) return;
|
||
|
||
container.querySelectorAll('.edit-variant-pack-row').forEach(row => {
|
||
const packUnit = row.querySelector('.edit-variant-pack-unit')?.value || 'pack';
|
||
const label = row.querySelector('.edit-variant-pack-size-label');
|
||
if (!label) return;
|
||
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
|
||
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
|
||
});
|
||
}
|
||
|
||
function appendVariantPackRow(prefill = {}) {
|
||
const container = getVariantPackRowsContainer();
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'delivery-line variant-pack-row';
|
||
|
||
const selectedPackUnit = prefill.packUnit || 'bottle';
|
||
const selectedSize = prefill.packSize || '';
|
||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||
|
||
row.innerHTML = `
|
||
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
|
||
<div class="form-group">
|
||
<label>Pack Type *</label>
|
||
<select class="variant-pack-unit" required>
|
||
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
|
||
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
|
||
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
|
||
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
|
||
<input type="number" class="variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}" required>
|
||
</div>
|
||
<button type="button" class="btn btn-danger btn-small variant-pack-remove-btn">Remove</button>
|
||
</div>
|
||
`;
|
||
|
||
const removeBtn = row.querySelector('.variant-pack-remove-btn');
|
||
const unitSelect = row.querySelector('.variant-pack-unit');
|
||
if (removeBtn) {
|
||
removeBtn.addEventListener('click', () => {
|
||
if (container.querySelectorAll('.variant-pack-row').length <= 1) {
|
||
showToast('At least one pack size is required', 'warning');
|
||
return;
|
||
}
|
||
row.remove();
|
||
});
|
||
}
|
||
|
||
if (unitSelect) {
|
||
unitSelect.addEventListener('change', refreshVariantPackRowLabels);
|
||
}
|
||
|
||
container.appendChild(row);
|
||
refreshVariantPackRowLabels();
|
||
}
|
||
|
||
function initializeVariantPackRows() {
|
||
const container = getVariantPackRowsContainer();
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
const strengthValue = document.getElementById('variantStrength')?.value || '';
|
||
const inferredBaseUnit = inferBaseUnitFromStrength(strengthValue);
|
||
const variantUnitSelect = document.getElementById('variantUnit');
|
||
if (variantUnitSelect) {
|
||
variantUnitSelect.value = inferredBaseUnit;
|
||
}
|
||
|
||
appendVariantPackRow({ packUnit: 'bottle' });
|
||
}
|
||
|
||
function appendEditVariantPackRow(prefill = {}) {
|
||
const container = getEditVariantPackRowsContainer();
|
||
if (!container) return;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'delivery-line edit-variant-pack-row';
|
||
|
||
const selectedPackUnit = prefill.packUnit || 'bottle';
|
||
const selectedSize = prefill.packSize || '';
|
||
const baseUnit = document.getElementById('editVariantUnit')?.value || 'units';
|
||
|
||
row.innerHTML = `
|
||
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
|
||
<div class="form-group">
|
||
<label>Pack Type *</label>
|
||
<select class="edit-variant-pack-unit">
|
||
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
|
||
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
|
||
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
|
||
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="edit-variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
|
||
<input type="number" class="edit-variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}">
|
||
</div>
|
||
<button type="button" class="btn btn-danger btn-small edit-variant-pack-remove-btn">Remove</button>
|
||
</div>
|
||
`;
|
||
|
||
const removeBtn = row.querySelector('.edit-variant-pack-remove-btn');
|
||
const unitSelect = row.querySelector('.edit-variant-pack-unit');
|
||
if (removeBtn) {
|
||
removeBtn.addEventListener('click', () => {
|
||
row.remove();
|
||
});
|
||
}
|
||
|
||
if (unitSelect) {
|
||
unitSelect.addEventListener('change', refreshEditVariantPackRowLabels);
|
||
}
|
||
|
||
container.appendChild(row);
|
||
refreshEditVariantPackRowLabels();
|
||
}
|
||
|
||
function initializeEditVariantPackRows() {
|
||
const container = getEditVariantPackRowsContainer();
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
appendEditVariantPackRow({ packUnit: 'bottle' });
|
||
}
|
||
|
||
function setEditVariantFieldLockState(isLocked) {
|
||
const strengthInput = document.getElementById('editVariantStrength');
|
||
const quantityInput = document.getElementById('editVariantQuantity');
|
||
const unitSelect = document.getElementById('editVariantUnit');
|
||
const lockNotice = document.getElementById('editVariantLockNotice');
|
||
|
||
if (strengthInput) strengthInput.disabled = isLocked;
|
||
if (quantityInput) quantityInput.disabled = isLocked;
|
||
if (unitSelect) unitSelect.disabled = isLocked;
|
||
if (lockNotice) lockNotice.style.display = isLocked ? 'block' : 'none';
|
||
}
|
||
|
||
// Handle add variant form
|
||
async function handleAddVariant(e) {
|
||
e.preventDefault();
|
||
|
||
const drugId = parseInt(document.getElementById('variantDrugId').value);
|
||
const baseUnit = document.getElementById('variantUnit').value;
|
||
const rows = Array.from(document.querySelectorAll('#variantPackRows .variant-pack-row'));
|
||
|
||
if (rows.length === 0) {
|
||
showToast('Please add at least one pack size', 'warning');
|
||
return;
|
||
}
|
||
|
||
const packPayloads = [];
|
||
for (let i = 0; i < rows.length; i += 1) {
|
||
const row = rows[i];
|
||
const packUnit = row.querySelector('.variant-pack-unit')?.value;
|
||
const packSize = parseFloat(row.querySelector('.variant-pack-size')?.value || '');
|
||
|
||
if (!packUnit || Number.isNaN(packSize) || packSize <= 0) {
|
||
showToast(`Pack row ${i + 1} is incomplete`, 'warning');
|
||
return;
|
||
}
|
||
|
||
const normalizedPackUnit = packUnit.trim().toLowerCase();
|
||
const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1);
|
||
packPayloads.push({
|
||
label: `${titleCasePack} ${packSize} ${baseUnit}`,
|
||
pack_unit_name: normalizedPackUnit,
|
||
pack_size_in_base_units: packSize,
|
||
is_active: true
|
||
});
|
||
}
|
||
|
||
const variantData = {
|
||
strength: document.getElementById('variantStrength').value,
|
||
quantity: 0,
|
||
unit: baseUnit,
|
||
base_unit: baseUnit,
|
||
low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value)
|
||
};
|
||
|
||
try {
|
||
const response = await apiCall(`/drugs/${drugId}/variants`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(variantData)
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to add variant');
|
||
|
||
const createdVariant = await response.json();
|
||
|
||
for (const packPayload of packPayloads) {
|
||
const packResponse = await apiCall(`/variants/${createdVariant.id}/packs`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(packPayload)
|
||
});
|
||
|
||
if (!packResponse.ok) {
|
||
const packError = await packResponse.json();
|
||
throw new Error(packError.detail || 'Variant created but pack size creation failed');
|
||
}
|
||
}
|
||
|
||
// Archive the auto-created default 1:1 pack when custom pack sizes are configured.
|
||
const packsResponse = await apiCall(`/variants/${createdVariant.id}/packs`);
|
||
if (packsResponse.ok) {
|
||
const packs = await packsResponse.json();
|
||
const defaultPack = packs.find(
|
||
p => p.is_active && Number(p.pack_size_in_base_units) === 1 && (p.pack_unit_name || '').toLowerCase() === baseUnit.toLowerCase()
|
||
);
|
||
if (defaultPack && packs.filter(p => p.is_active).length > 1) {
|
||
await apiCall(`/variant-packs/${defaultPack.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ is_active: false })
|
||
});
|
||
}
|
||
}
|
||
|
||
document.getElementById('variantForm').reset();
|
||
closeModal(document.getElementById('addVariantModal'));
|
||
await loadDrugs();
|
||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||
refreshDeliveryVariantSelects();
|
||
}
|
||
renderDrugs();
|
||
showToast('Variant added successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error adding variant:', error);
|
||
showToast('Failed to add variant. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Open edit variant modal
|
||
function openEditVariantModal(variantId) {
|
||
// Find the variant from all drugs
|
||
let variant = null;
|
||
for (const drug of allDrugs) {
|
||
variant = drug.variants.find(v => v.id === variantId);
|
||
if (variant) break;
|
||
}
|
||
|
||
if (!variant) return;
|
||
|
||
document.getElementById('editVariantId').value = variant.id;
|
||
document.getElementById('editVariantStrength').value = variant.strength;
|
||
document.getElementById('editVariantQuantity').value = variant.quantity;
|
||
document.getElementById('editVariantUnit').value = variant.unit;
|
||
document.getElementById('editVariantThreshold').value = variant.low_stock_threshold;
|
||
|
||
const hasInventoryContext = Boolean(variant.has_inventory_history);
|
||
setEditVariantFieldLockState(hasInventoryContext);
|
||
initializeEditVariantPackRows();
|
||
|
||
document.getElementById('editVariantModal').classList.add('show');
|
||
}
|
||
|
||
// Handle edit variant form
|
||
async function handleEditVariant(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('editVariantId').value);
|
||
const strengthInput = document.getElementById('editVariantStrength');
|
||
const quantityInput = document.getElementById('editVariantQuantity');
|
||
const unitSelect = document.getElementById('editVariantUnit');
|
||
const baseUnit = unitSelect.value;
|
||
const variantData = {
|
||
low_stock_threshold: parseFloat(document.getElementById('editVariantThreshold').value)
|
||
};
|
||
|
||
if (!strengthInput.disabled && !quantityInput.disabled && !unitSelect.disabled) {
|
||
const quantityValue = parseFloat(quantityInput.value);
|
||
if (Number.isNaN(quantityValue) || quantityValue < 0) {
|
||
showToast('Please enter a valid quantity (0 or greater)', 'warning');
|
||
return;
|
||
}
|
||
|
||
variantData.strength = strengthInput.value;
|
||
variantData.quantity = quantityValue;
|
||
variantData.unit = baseUnit;
|
||
variantData.base_unit = baseUnit;
|
||
}
|
||
|
||
const packRows = Array.from(document.querySelectorAll('#editVariantPackRows .edit-variant-pack-row'));
|
||
const newPackPayloads = [];
|
||
for (let i = 0; i < packRows.length; i += 1) {
|
||
const row = packRows[i];
|
||
const packUnitRaw = row.querySelector('.edit-variant-pack-unit')?.value || '';
|
||
const packSizeRaw = row.querySelector('.edit-variant-pack-size')?.value || '';
|
||
|
||
if (!packUnitRaw && !packSizeRaw) {
|
||
continue;
|
||
}
|
||
|
||
const packSize = parseFloat(packSizeRaw);
|
||
if (!packUnitRaw || Number.isNaN(packSize) || packSize <= 0) {
|
||
showToast(`Pack row ${i + 1} is incomplete`, 'warning');
|
||
return;
|
||
}
|
||
|
||
const normalizedPackUnit = packUnitRaw.trim().toLowerCase();
|
||
const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1);
|
||
newPackPayloads.push({
|
||
label: `${titleCasePack} ${packSize} ${baseUnit}`,
|
||
pack_unit_name: normalizedPackUnit,
|
||
pack_size_in_base_units: packSize,
|
||
is_active: true
|
||
});
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(variantData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to update variant');
|
||
}
|
||
|
||
for (const packPayload of newPackPayloads) {
|
||
const packResponse = await apiCall(`/variants/${variantId}/packs`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(packPayload)
|
||
});
|
||
|
||
if (!packResponse.ok) {
|
||
const packError = await packResponse.json();
|
||
throw new Error(packError.detail || 'Variant updated but failed to add one or more pack sizes');
|
||
}
|
||
}
|
||
|
||
closeModal(document.getElementById('editVariantModal'));
|
||
await loadDrugs();
|
||
renderDrugs();
|
||
const message = newPackPayloads.length > 0
|
||
? `Variant updated and ${newPackPayloads.length} pack size${newPackPayloads.length === 1 ? '' : 's'} added`
|
||
: 'Variant updated successfully!';
|
||
showToast(message, 'success');
|
||
} catch (error) {
|
||
console.error('Error updating variant:', error);
|
||
showToast('Failed to update variant: ' + error.message, 'error');
|
||
}
|
||
}
|
||
// Dispense from variant
|
||
function dispenseVariant(variantId) {
|
||
// Update the dropdown display with all variants
|
||
updateDispenseDrugSelect();
|
||
|
||
// Pre-select the variant in the dispense modal
|
||
const drugSelect = document.getElementById('dispenseDrugSelect');
|
||
drugSelect.value = variantId;
|
||
|
||
// Update batch info for selected variant
|
||
updateBatchInfo();
|
||
|
||
// Open dispense modal
|
||
openModal(document.getElementById('dispenseModal'));
|
||
}
|
||
|
||
// Prescribe variant and print label
|
||
function prescribeVariant(variantId, drugName, variantStrength, unit) {
|
||
// Set hidden fields
|
||
document.getElementById('prescribeVariantId').value = variantId;
|
||
document.getElementById('prescribeDrugName').value = drugName;
|
||
document.getElementById('prescribeVariantStrength').value = variantStrength;
|
||
document.getElementById('prescribeUnit').value = unit || 'units';
|
||
|
||
// Pre-fill user name if available
|
||
if (currentUser) {
|
||
document.getElementById('prescribeUser').value = currentUser.username;
|
||
}
|
||
|
||
// Set default expiry date to 1 month from now
|
||
const defaultExpiry = new Date();
|
||
defaultExpiry.setMonth(defaultExpiry.getMonth() + 1);
|
||
document.getElementById('prescribeExpiry').value = defaultExpiry.toISOString().split('T')[0];
|
||
|
||
// Open prescribe modal
|
||
openModal(document.getElementById('prescribeModal'));
|
||
}
|
||
|
||
// Handle prescribe drug form submission
|
||
async function handlePrescribeDrug(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('prescribeVariantId').value);
|
||
const drugName = document.getElementById('prescribeDrugName').value;
|
||
const variantStrength = document.getElementById('prescribeVariantStrength').value;
|
||
const unit = document.getElementById('prescribeUnit').value;
|
||
const quantity = parseFloat(document.getElementById('prescribeQuantity').value);
|
||
const animalName = document.getElementById('prescribeAnimal').value;
|
||
const dosage = document.getElementById('prescribeDosage').value;
|
||
const expiryDate = document.getElementById('prescribeExpiry').value;
|
||
const userName = document.getElementById('prescribeUser').value;
|
||
const notes = document.getElementById('prescribeNotes').value;
|
||
|
||
if (!variantId || isNaN(quantity) || quantity <= 0 || !animalName || !dosage || !expiryDate || !userName) {
|
||
showToast('Please fill in all required fields', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Convert expiry date to DD/MM/YYYY format
|
||
const expiryParts = expiryDate.split('-');
|
||
const formattedExpiry = `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`;
|
||
|
||
try {
|
||
// First, print the label
|
||
const labelData = {
|
||
variables: {
|
||
practice_name: "Many Tears Animal Rescue",
|
||
animal_name: animalName,
|
||
drug_name: `${drugName} ${variantStrength}`,
|
||
dosage: dosage,
|
||
quantity: `${quantity} ${unit}`,
|
||
expiry_date: formattedExpiry
|
||
}
|
||
};
|
||
|
||
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');
|
||
}
|
||
|
||
const labelResult = await labelResponse.json();
|
||
console.log('Label print result:', labelResult);
|
||
|
||
if (!labelResult.success) {
|
||
// Label printing failed - don't dispense the drug
|
||
const isError = labelResult.message && (
|
||
labelResult.message.includes('not found') ||
|
||
labelResult.message.includes('error') ||
|
||
labelResult.message.includes('failed')
|
||
);
|
||
const toastType = isError ? 'error' : 'warning';
|
||
showToast('Cannot dispense: ' + labelResult.message, toastType, 5000);
|
||
return;
|
||
}
|
||
|
||
// Label printed successfully, now dispense the drug
|
||
const dispensingData = {
|
||
drug_variant_id: variantId,
|
||
quantity: quantity,
|
||
animal_name: animalName,
|
||
user_name: userName,
|
||
notes: notes || null
|
||
};
|
||
|
||
const dispenseResponse = await apiCall('/dispense', {
|
||
method: 'POST',
|
||
body: JSON.stringify(dispensingData)
|
||
});
|
||
|
||
if (!dispenseResponse.ok) {
|
||
const error = await dispenseResponse.json();
|
||
throw new Error(error.detail || 'Failed to dispense drug');
|
||
}
|
||
|
||
// Both operations succeeded
|
||
showToast('Drug prescribed and label printed successfully!', 'success');
|
||
|
||
document.getElementById('prescribeForm').reset();
|
||
closeModal(document.getElementById('prescribeModal'));
|
||
await loadDrugs();
|
||
} catch (error) {
|
||
console.error('Error prescribing drug:', error);
|
||
showToast('Failed to prescribe drug: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Handle print notes form submission
|
||
async function handlePrintNotes(e) {
|
||
e.preventDefault();
|
||
|
||
const animalName = document.getElementById('notesAnimalName').value.trim();
|
||
const notes = document.getElementById('notesContent').value.trim();
|
||
|
||
if (!animalName || !notes) {
|
||
showToast('Please fill in all required fields', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Send notes to print endpoint
|
||
const notesData = {
|
||
variables: {
|
||
animal_name: animalName,
|
||
notes: notes
|
||
}
|
||
};
|
||
|
||
const response = await apiCall('/notes/print', {
|
||
method: 'POST',
|
||
body: JSON.stringify(notesData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Notes printing request failed');
|
||
}
|
||
|
||
const result = await response.json();
|
||
console.log('Notes print result:', result);
|
||
|
||
if (!result.success) {
|
||
// Printing failed
|
||
const isError = result.message && (
|
||
result.message.includes('not found') ||
|
||
result.message.includes('error') ||
|
||
result.message.includes('failed')
|
||
);
|
||
const toastType = isError ? 'error' : 'warning';
|
||
showToast(result.message, toastType, 5000);
|
||
return;
|
||
}
|
||
|
||
// Printing succeeded
|
||
showToast('Notes printed successfully!', 'success');
|
||
|
||
document.getElementById('printNotesForm').reset();
|
||
closeModal(document.getElementById('printNotesModal'));
|
||
} catch (error) {
|
||
console.error('Error printing notes:', error);
|
||
showToast('Failed to print notes: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Delete variant
|
||
async function deleteVariant(variantId) {
|
||
const variant = getVariantById(variantId);
|
||
if (variant && variant.has_inventory_history) {
|
||
showToast('Cannot delete variant with batch or dispensing history', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Are you sure you want to delete this variant?')) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to delete variant');
|
||
}
|
||
|
||
await loadDrugs();
|
||
renderDrugs();
|
||
showToast('Variant deleted successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error deleting variant:', error);
|
||
showToast('Failed to delete variant: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Show dispensing history for a drug
|
||
async function showDrugHistory(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (!drug) return;
|
||
|
||
const historyModal = document.getElementById('historyModal');
|
||
const historyContent = document.getElementById('historyContent');
|
||
document.getElementById('historyDrugName').textContent = drug.name;
|
||
|
||
historyContent.innerHTML = '<p class="loading">Loading history...</p>';
|
||
openModal(historyModal);
|
||
|
||
try {
|
||
const response = await apiCall(`/dispense/history`);
|
||
if (!response.ok) throw new Error('Failed to fetch history');
|
||
|
||
const allHistory = await response.json();
|
||
|
||
// Filter history for this drug's variants
|
||
const variantIds = drug.variants.map(v => v.id);
|
||
const drugHistory = allHistory.filter(item => variantIds.includes(item.drug_variant_id));
|
||
|
||
if (drugHistory.length === 0) {
|
||
historyContent.innerHTML = '<p class="empty">No dispensing history for this drug.</p>';
|
||
return;
|
||
}
|
||
|
||
// Sort by dispensed_at descending (most recent first)
|
||
drugHistory.sort((a, b) => new Date(b.dispensed_at) - new Date(a.dispensed_at));
|
||
|
||
const historyHtml = drugHistory.map(item => {
|
||
const variant = drug.variants.find(v => v.id === item.drug_variant_id);
|
||
const date = new Date(item.dispensed_at).toLocaleDateString();
|
||
const time = new Date(item.dispensed_at).toLocaleTimeString();
|
||
|
||
return `
|
||
<div class="history-item">
|
||
<div class="history-header">
|
||
<div class="history-variant">${drug.name} ${variant.strength}</div>
|
||
<div class="history-datetime">${date} ${time}</div>
|
||
</div>
|
||
<div class="history-details">
|
||
<div class="history-row">
|
||
<span class="history-label">Quantity:</span>
|
||
<span class="history-value">${item.quantity} ${variant.unit}</span>
|
||
</div>
|
||
<div class="history-row">
|
||
<span class="history-label">Animal:</span>
|
||
<span class="history-value">${escapeHtml(item.animal_name)}</span>
|
||
</div>
|
||
<div class="history-row">
|
||
<span class="history-label">User:</span>
|
||
<span class="history-value">${escapeHtml(item.user_name)}</span>
|
||
</div>
|
||
${item.notes ? `
|
||
<div class="history-row">
|
||
<span class="history-label">Notes:</span>
|
||
<span class="history-value">${escapeHtml(item.notes)}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
historyContent.innerHTML = historyHtml;
|
||
} catch (error) {
|
||
console.error('Error fetching history:', error);
|
||
historyContent.innerHTML = '<p class="error">Failed to load history. Check the console for details.</p>';
|
||
}
|
||
}
|
||
|
||
// Handle edit drug form
|
||
async function handleEditDrug(e) {
|
||
e.preventDefault();
|
||
|
||
const drugId = parseInt(document.getElementById('editDrugId').value);
|
||
const drugData = {
|
||
name: document.getElementById('editDrugName').value,
|
||
description: document.getElementById('editDrugDescription').value,
|
||
is_controlled: document.getElementById('editDrugIsControlled').checked
|
||
};
|
||
|
||
try {
|
||
const response = await apiCall(`/drugs/${drugId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(drugData)
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Failed to update drug');
|
||
|
||
closeEditModal();
|
||
await loadDrugs();
|
||
showToast('Drug updated successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error updating drug:', error);
|
||
showToast('Failed to update drug. Check the console for details.', 'error');
|
||
}
|
||
}
|
||
|
||
// Delete drug
|
||
async function deleteDrug(drugId) {
|
||
const drug = allDrugs.find(d => d.id === drugId);
|
||
if (drug && drug.variants.some(v => v.has_inventory_history)) {
|
||
showToast('Cannot delete drug with variants that have batch or dispensing history', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Are you sure you want to delete this drug?')) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/drugs/${drugId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to delete drug');
|
||
}
|
||
|
||
await loadDrugs();
|
||
showToast('Drug deleted successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error deleting drug:', error);
|
||
showToast('Failed to delete drug: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Password Management
|
||
function openChangePasswordModal() {
|
||
const modal = document.getElementById('changePasswordModal');
|
||
document.getElementById('changePasswordForm').reset();
|
||
|
||
// Close dropdown
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) dropdown.style.display = 'none';
|
||
|
||
openModal(modal);
|
||
}
|
||
|
||
async function handleChangePassword(e) {
|
||
e.preventDefault();
|
||
|
||
const currentPassword = document.getElementById('currentPassword').value;
|
||
const newPassword = document.getElementById('newPassword').value;
|
||
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
showToast('New passwords do not match!', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 1) {
|
||
showToast('New password cannot be empty!', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/auth/change-password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
current_password: currentPassword,
|
||
new_password: newPassword
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to change password');
|
||
}
|
||
|
||
showToast('Password changed successfully!', 'success');
|
||
closeModal(document.getElementById('changePasswordModal'));
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showToast('Failed to change password: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function openAdminChangePasswordModal(userId, username) {
|
||
const modal = document.getElementById('adminChangePasswordModal');
|
||
document.getElementById('adminChangePasswordForm').reset();
|
||
document.getElementById('adminChangePasswordUserId').value = userId;
|
||
document.getElementById('adminChangePasswordUsername').value = username;
|
||
openModal(modal);
|
||
}
|
||
|
||
async function handleAdminChangePassword(e) {
|
||
e.preventDefault();
|
||
|
||
const userId = document.getElementById('adminChangePasswordUserId').value;
|
||
const newPassword = document.getElementById('adminChangePasswordNewPassword').value;
|
||
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
showToast('Passwords do not match!', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (newPassword.length < 1) {
|
||
showToast('Password cannot be empty!', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall(`/users/${userId}/change-password`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
new_password: newPassword
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to change password');
|
||
}
|
||
|
||
showToast('Password changed successfully!', 'success');
|
||
closeModal(document.getElementById('adminChangePasswordModal'));
|
||
openUserManagement();
|
||
} catch (error) {
|
||
console.error('Error changing password:', error);
|
||
showToast('Failed to change password: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Update filter button states
|
||
function updateFilterButtons() {
|
||
document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly);
|
||
document.getElementById('showLowStockBtn').classList.toggle('active', showLowStockOnly);
|
||
}
|
||
|
||
// Escape HTML to prevent XSS
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async function openReportsPage() {
|
||
const dropdown = document.getElementById('userDropdown');
|
||
if (dropdown) dropdown.style.display = 'none';
|
||
|
||
window.location.href = 'reports.html';
|
||
}
|
||
|
||
// User Management
|
||
async function openUserManagement() {
|
||
const modal = document.getElementById('userManagementModal');
|
||
document.getElementById('newUsername').value = '';
|
||
document.getElementById('newUserPassword').value = '';
|
||
document.getElementById('newUserRole').value = '';
|
||
|
||
const usersList = document.getElementById('usersList');
|
||
usersList.innerHTML = '<h3>Users</h3><p class="loading">Loading users...</p>';
|
||
|
||
try {
|
||
const response = await apiCall('/users');
|
||
if (!response.ok) throw new Error('Failed to load users');
|
||
|
||
const users = await response.json();
|
||
|
||
const usersHtml = `
|
||
<h3>Users</h3>
|
||
<div class="users-table">
|
||
${users.map(user => {
|
||
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
||
const roleBadge = user.role === 'admin' ? '👑 Admin' :
|
||
user.role === 'readonly' ? '👁️ Read-Only' : '👤 Regular';
|
||
return `
|
||
<div class="user-item">
|
||
<span>${user.username}</span>
|
||
<span class="admin-badge">${roleBadge}</span>
|
||
<button class="btn btn-secondary btn-small" onclick="openAdminChangePasswordModal(${user.id}, '${escapeHtml(user.username)}')">🔑 Password</button>
|
||
${user.id !== currentUser.id ? `
|
||
<button class="btn btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
usersList.innerHTML = usersHtml;
|
||
} catch (error) {
|
||
console.error('Error loading users:', error);
|
||
usersList.innerHTML = '<h3>Users</h3><p class="empty">Error loading users</p>';
|
||
}
|
||
|
||
const createUserForm = document.getElementById('createUserForm');
|
||
if (createUserForm) {
|
||
createUserForm.onsubmit = createUser;
|
||
}
|
||
|
||
openModal(modal);
|
||
}
|
||
|
||
// Create user
|
||
async function createUser(e) {
|
||
e.preventDefault();
|
||
|
||
const username = document.getElementById('newUsername').value;
|
||
const password = document.getElementById('newUserPassword').value;
|
||
const role = document.getElementById('newUserRole').value;
|
||
|
||
if (!role) {
|
||
showToast('Please select a role', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ username, password, role })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to create user');
|
||
}
|
||
|
||
document.getElementById('newUsername').value = '';
|
||
document.getElementById('newUserPassword').value = '';
|
||
document.getElementById('newUserRole').value = '';
|
||
showToast('User created successfully!', 'success');
|
||
openUserManagement();
|
||
} catch (error) {
|
||
console.error('Error creating user:', error);
|
||
showToast('Failed to create user: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Delete user
|
||
async function deleteUser(userId) {
|
||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/users/${userId}`, { method: 'DELETE' });
|
||
|
||
if (!response.ok) throw new Error('Failed to delete user');
|
||
|
||
showToast('User deleted successfully!', 'success');
|
||
openUserManagement();
|
||
} catch (error) {
|
||
console.error('Error deleting user:', error);
|
||
showToast('Failed to delete user: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Location Management
|
||
async function openLocationManagement() {
|
||
const modal = document.getElementById('locationManagementModal');
|
||
document.getElementById('newLocationName').value = '';
|
||
|
||
const locationsList = document.getElementById('locationsList');
|
||
locationsList.innerHTML = '<h3>Active Locations</h3><p class="loading">Loading locations...</p>';
|
||
|
||
try {
|
||
const response = await apiCall('/locations');
|
||
if (!response.ok) throw new Error('Failed to load locations');
|
||
|
||
const locations = await response.json();
|
||
|
||
const activeLocations = locations.filter(loc => loc.is_active);
|
||
const inactiveLocations = locations.filter(loc => !loc.is_active);
|
||
|
||
let locationsHtml = '<h3>Active Locations</h3>';
|
||
if (activeLocations.length === 0) {
|
||
locationsHtml += '<p class="empty">No active locations</p>';
|
||
} else {
|
||
locationsHtml += `<div class="locations-table">
|
||
${activeLocations.map(location => `
|
||
<div class="location-item">
|
||
<div style="flex: 1;">
|
||
<strong>${location.name}</strong>
|
||
<div style="font-size: 0.85em; color: #666;">Created: ${new Date(location.created_at).toLocaleDateString()}</div>
|
||
</div>
|
||
<button class="btn btn-danger btn-small" onclick="archiveLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Archive</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
if (inactiveLocations.length > 0) {
|
||
locationsHtml += `
|
||
<h3 style="margin-top: 20px;">Archived Locations</h3>
|
||
<div class="locations-table">
|
||
${inactiveLocations.map(location => `
|
||
<div class="location-item" style="opacity: 0.6;">
|
||
<div style="flex: 1;">
|
||
<strong>${location.name}</strong> <span style="color: #999;">(archived)</span>
|
||
</div>
|
||
<button class="btn btn-secondary btn-small" onclick="restoreLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Restore</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
locationsList.innerHTML = locationsHtml;
|
||
} catch (error) {
|
||
console.error('Error loading locations:', error);
|
||
locationsList.innerHTML = '<h3>Active Locations</h3><p class="empty">Error loading locations</p>';
|
||
}
|
||
|
||
openModal(modal);
|
||
}
|
||
|
||
// Create location
|
||
async function createLocation(e) {
|
||
e.preventDefault();
|
||
|
||
const name = document.getElementById('newLocationName').value.trim();
|
||
|
||
if (!name) {
|
||
showToast('Please enter a location name', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiCall('/locations', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to create location');
|
||
}
|
||
|
||
document.getElementById('newLocationName').value = '';
|
||
showToast('Location created successfully!', 'success');
|
||
openLocationManagement();
|
||
} catch (error) {
|
||
console.error('Error creating location:', error);
|
||
showToast('Failed to create location: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Archive location
|
||
async function archiveLocation(locationId, locationName) {
|
||
if (!confirm(`Archive location "${locationName}"?\n\nYou can restore it later if needed.`)) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/locations/${locationId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ is_active: false })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to archive location');
|
||
}
|
||
|
||
showToast('Location archived successfully!', 'success');
|
||
openLocationManagement();
|
||
} catch (error) {
|
||
console.error('Error archiving location:', error);
|
||
showToast('Failed to archive location: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Restore location
|
||
async function restoreLocation(locationId, locationName) {
|
||
if (!confirm(`Restore location "${locationName}"?`)) return;
|
||
|
||
try {
|
||
const response = await apiCall(`/locations/${locationId}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ is_active: true })
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to restore location');
|
||
}
|
||
|
||
showToast('Location restored successfully!', 'success');
|
||
openLocationManagement();
|
||
} catch (error) {
|
||
console.error('Error restoring location:', error);
|
||
showToast('Failed to restore location: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Batch Management
|
||
async function openBatchReceiveModal(variantId) {
|
||
const batchReceiveModal = document.getElementById('batchReceiveModal');
|
||
document.getElementById('batchReceiveForm').reset();
|
||
document.getElementById('batchVariantId').value = variantId;
|
||
|
||
// Initialize locations
|
||
await initializeBatchLocations();
|
||
|
||
openModal(batchReceiveModal);
|
||
}
|
||
|
||
async function initializeBatchLocations() {
|
||
const locationSelect = document.getElementById('batchLocation');
|
||
|
||
try {
|
||
const response = await apiCall('/locations');
|
||
if (!response.ok) throw new Error('Failed to load locations');
|
||
|
||
const locations = await response.json();
|
||
|
||
locationSelect.innerHTML = '<option value="">-- Select location --</option>';
|
||
locations.forEach(location => {
|
||
if (location.is_active) {
|
||
const option = document.createElement('option');
|
||
option.value = location.id;
|
||
option.textContent = location.name;
|
||
locationSelect.appendChild(option);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Error loading locations:', error);
|
||
showToast('Failed to load storage locations', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleBatchReceive(e) {
|
||
e.preventDefault();
|
||
|
||
const variantId = parseInt(document.getElementById('batchVariantId').value);
|
||
const batchNumber = document.getElementById('batchNumber').value.trim();
|
||
const quantity = parseFloat(document.getElementById('batchQuantity').value);
|
||
const expiryDate = document.getElementById('batchExpiryDate').value;
|
||
const locationId = parseInt(document.getElementById('batchLocation').value);
|
||
const notes = document.getElementById('batchNotes').value.trim();
|
||
|
||
if (!batchNumber || isNaN(quantity) || quantity <= 0 || !expiryDate || !locationId) {
|
||
showToast('Please fill in all required fields', 'warning');
|
||
return;
|
||
}
|
||
|
||
const batchData = {
|
||
batch_number: batchNumber,
|
||
quantity: quantity,
|
||
expiry_date: expiryDate,
|
||
location_id: locationId,
|
||
notes: notes || null
|
||
};
|
||
|
||
try {
|
||
const response = await apiCall(`/variants/${variantId}/batches`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(batchData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Failed to receive batch');
|
||
}
|
||
|
||
document.getElementById('batchReceiveForm').reset();
|
||
closeModal(document.getElementById('batchReceiveModal'));
|
||
await loadDrugs();
|
||
showToast('Batch received successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Error receiving batch:', error);
|
||
showToast('Failed to receive batch: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function getActiveDeliveryDrug() {
|
||
return allDrugs.find(d => d.id === deliveryDrugId);
|
||
}
|
||
|
||
function getVariantById(variantId) {
|
||
for (const drug of allDrugs) {
|
||
const found = (drug.variants || []).find(v => v.id === variantId);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildDeliveryVariantOptions(drug, selectedVariantId = '') {
|
||
if (!drug || !drug.variants || drug.variants.length === 0) {
|
||
return '<option value="">-- No variants available --</option>';
|
||
}
|
||
|
||
return [`<option value="">-- Select variant --</option>`, ...drug.variants.map(v => {
|
||
const selected = String(v.id) === String(selectedVariantId) ? ' selected' : '';
|
||
return `<option value="${v.id}"${selected}>${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`;
|
||
})].join('');
|
||
}
|
||
|
||
function getActivePacksForVariant(variant) {
|
||
if (!variant || !Array.isArray(variant.packs)) return [];
|
||
return variant.packs.filter(pack => pack.is_active);
|
||
}
|
||
|
||
function buildDeliveryPackOptions(variant, selectedPackId = '') {
|
||
const packs = getActivePacksForVariant(variant);
|
||
if (packs.length === 0) {
|
||
return '<option value="">-- No active packs --</option>';
|
||
}
|
||
|
||
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
|
||
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
|
||
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
|
||
})].join('');
|
||
}
|
||
|
||
function buildDeliveryLocationOptions(selectedLocationId = '') {
|
||
return [`<option value="">-- Select location --</option>`, ...deliveryLocations.map(location => {
|
||
const selected = String(location.id) === String(selectedLocationId) ? ' selected' : '';
|
||
return `<option value="${location.id}"${selected}>${escapeHtml(location.name)}</option>`;
|
||
})].join('');
|
||
}
|
||
|
||
function updateDeliveryLineQuantityDisplay(line) {
|
||
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
|
||
const variant = getVariantById(variantId);
|
||
if (!variant || !packSelect) {
|
||
return;
|
||
}
|
||
|
||
const currentPackId = packSelect.value;
|
||
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
|
||
}
|
||
|
||
function wireDeliveryLineEvents(line) {
|
||
const variantSelect = line.querySelector('.delivery-variant-select');
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
const packCountInput = line.querySelector('.delivery-pack-count');
|
||
|
||
if (variantSelect && packSelect) {
|
||
variantSelect.addEventListener('change', () => {
|
||
const variantId = parseInt(variantSelect.value || '', 10);
|
||
const variant = getVariantById(variantId);
|
||
packSelect.innerHTML = buildDeliveryPackOptions(variant, '');
|
||
if (packCountInput) packCountInput.value = '';
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
if (packSelect) {
|
||
packSelect.addEventListener('change', () => {
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
|
||
if (packCountInput) {
|
||
packCountInput.addEventListener('input', () => {
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
});
|
||
}
|
||
}
|
||
|
||
function appendDeliveryLine(prefill = {}) {
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
const drug = getActiveDeliveryDrug();
|
||
if (!container || !drug) return;
|
||
|
||
deliveryLineCounter += 1;
|
||
const lineId = `delivery-line-${deliveryLineCounter}`;
|
||
|
||
const line = document.createElement('div');
|
||
line.className = 'delivery-line';
|
||
line.dataset.lineId = lineId;
|
||
|
||
const initialVariant = drug.variants.find(v => String(v.id) === String(prefill.variantId)) || drug.variants[0] || null;
|
||
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
|
||
const initialPackId = prefill.packId || '';
|
||
const initialPackCount = prefill.packCount || '';
|
||
|
||
line.innerHTML = `
|
||
<div class="delivery-line-grid">
|
||
<div class="form-group">
|
||
<label>Variant</label>
|
||
<select class="delivery-variant-select" required>
|
||
${buildDeliveryVariantOptions(drug, initialVariantId)}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Pack Type</label>
|
||
<select class="delivery-pack-select" required>
|
||
${buildDeliveryPackOptions(initialVariant, initialPackId)}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Pack Count</label>
|
||
<input type="number" class="delivery-pack-count" min="0.0001" step="0.0001" value="${initialPackCount}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Batch Number</label>
|
||
<input type="text" class="delivery-batch-number" value="${prefill.batchNumber || ''}" placeholder="e.g. ABC123" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Expiry</label>
|
||
<input type="date" class="delivery-expiry-date" value="${prefill.expiryDate || ''}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Location</label>
|
||
<select class="delivery-location-select" required>
|
||
${buildDeliveryLocationOptions(prefill.locationId || '')}
|
||
</select>
|
||
</div>
|
||
<button type="button" class="btn btn-danger btn-small delivery-remove-btn">Remove</button>
|
||
</div>
|
||
`;
|
||
|
||
const removeBtn = line.querySelector('.delivery-remove-btn');
|
||
if (removeBtn) {
|
||
removeBtn.addEventListener('click', () => {
|
||
if (container.children.length <= 1) {
|
||
showToast('At least one delivery line is required', 'warning');
|
||
return;
|
||
}
|
||
line.remove();
|
||
});
|
||
}
|
||
|
||
wireDeliveryLineEvents(line);
|
||
updateDeliveryLineQuantityDisplay(line);
|
||
|
||
container.appendChild(line);
|
||
}
|
||
|
||
function refreshDeliveryVariantSelects() {
|
||
const drug = getActiveDeliveryDrug();
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
if (!drug || !container) return;
|
||
|
||
container.querySelectorAll('.delivery-line').forEach(line => {
|
||
const select = line.querySelector('.delivery-variant-select');
|
||
const packSelect = line.querySelector('.delivery-pack-select');
|
||
if (!select) return;
|
||
|
||
const currentVariantId = select.value;
|
||
select.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
|
||
|
||
const variant = getVariantById(parseInt(select.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(drugId) {
|
||
deliveryDrugId = drugId;
|
||
const drug = getActiveDeliveryDrug();
|
||
if (!drug) {
|
||
showToast('Drug not found', 'error');
|
||
return;
|
||
}
|
||
|
||
const form = document.getElementById('receiveDeliveryForm');
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
const label = document.getElementById('receiveDeliveryDrugLabel');
|
||
|
||
if (form) form.reset();
|
||
if (container) container.innerHTML = '';
|
||
if (label) label.textContent = `Drug: ${drug.name}`;
|
||
|
||
await initializeDeliveryLocations();
|
||
appendDeliveryLine();
|
||
|
||
openModal(document.getElementById('receiveDeliveryModal'));
|
||
}
|
||
|
||
function handleAddVariantFromDelivery() {
|
||
if (!deliveryDrugId) {
|
||
showToast('Select a drug first', 'warning');
|
||
return;
|
||
}
|
||
openAddVariantModal(deliveryDrugId);
|
||
}
|
||
|
||
async function handleReceiveDelivery(e) {
|
||
e.preventDefault();
|
||
|
||
const drug = getActiveDeliveryDrug();
|
||
const container = document.getElementById('deliveryLinesContainer');
|
||
if (!drug || !container) {
|
||
showToast('Delivery context unavailable', 'error');
|
||
return;
|
||
}
|
||
|
||
const lines = Array.from(container.querySelectorAll('.delivery-line'));
|
||
if (lines.length === 0) {
|
||
showToast('Add at least one delivery line', 'warning');
|
||
return;
|
||
}
|
||
|
||
const payloads = [];
|
||
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
|
||
const packIdRaw = line.querySelector('.delivery-pack-select')?.value || '';
|
||
const packId = packIdRaw ? parseInt(packIdRaw, 10) : null;
|
||
const packCountRaw = line.querySelector('.delivery-pack-count')?.value || '';
|
||
const packCount = packCountRaw ? parseFloat(packCountRaw) : null;
|
||
const batchNumber = (line.querySelector('.delivery-batch-number')?.value || '').trim();
|
||
const expiryDate = line.querySelector('.delivery-expiry-date')?.value || '';
|
||
const locationId = parseInt(line.querySelector('.delivery-location-select')?.value || '', 10);
|
||
|
||
if (!variantId || !packId || packCount === null || Number.isNaN(packCount) || packCount <= 0 || !batchNumber || !expiryDate || !locationId) {
|
||
showToast(`Delivery line ${i + 1} is incomplete`, 'warning');
|
||
return;
|
||
}
|
||
|
||
const variant = drug.variants.find(v => v.id === variantId);
|
||
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
|
||
if (!selectedPack) {
|
||
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
|
||
return;
|
||
}
|
||
const computedQuantity = packCount * selectedPack.pack_size_in_base_units;
|
||
|
||
payloads.push({
|
||
variantId,
|
||
payload: {
|
||
batch_number: batchNumber,
|
||
received_pack_id: packId,
|
||
received_pack_count: packCount,
|
||
expiry_date: expiryDate,
|
||
location_id: locationId,
|
||
notes: `Received ${packCount} ${selectedPack.pack_unit_name}(s), total ${computedQuantity} ${variant ? variant.unit : 'units'}`
|
||
}
|
||
});
|
||
}
|
||
|
||
try {
|
||
for (let i = 0; i < payloads.length; i += 1) {
|
||
const entry = payloads[i];
|
||
const response = await apiCall(`/variants/${entry.variantId}/batches`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(entry.payload)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(`Line ${i + 1}: ${error.detail || 'Failed to receive delivery line'}`);
|
||
}
|
||
}
|
||
|
||
closeModal(document.getElementById('receiveDeliveryModal'));
|
||
await loadDrugs();
|
||
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
||
} catch (error) {
|
||
console.error('Error receiving delivery:', error);
|
||
showToast('Failed to receive delivery: ' + error.message, 'error');
|
||
}
|
||
}
|