Files
mt-drugs/frontend/app.js
T
2026-03-29 11:13:56 -04:00

2663 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_URL = '/api';
let allDrugs = [];
let currentDrug = null;
let showLowStockOnly = false;
let selectedLocationFilter = '';
let searchTerm = '';
let expandedDrugs = new Set();
let expandedVariants = new Set();
let currentUser = null;
let accessToken = null;
let deliveryDrugId = null;
let deliveryLineCounter = 0;
let deliveryLocations = [];
// 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 dispenseModeSelect = document.getElementById('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();
});
}
if (dispenseModeSelect) dispenseModeSelect.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 = document.getElementById('dispenseMode')?.value || 'subunit';
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}.`;
}
});
}
// 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 modeSelect = document.getElementById('dispenseMode');
if (packSelect) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
}
if (packCount) {
packCount.value = '';
}
if (modeSelect) {
modeSelect.value = 'subunit';
}
if (packPreview) {
packPreview.textContent = 'Select a pack and whole-number count.';
}
updateDispenseModeUi();
}
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 = document.getElementById('dispenseMode')?.value || 'subunit';
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';
}
updateAllocationPreview();
}
function updateDispenseQuantityFromPack() {
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
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);
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
return;
}
const quantity = packCount * selectedPack.pack_size_in_base_units;
quantityInput.value = String(quantity);
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit}`;
updateAllocationPreview();
return;
}
preview.textContent = selectedPack
? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
: `Select a pack to calculate quantity.`;
}
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 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 populateDispenseBatchSelect(activeBatches) {
const batchSelect = document.getElementById('dispenseBatchSelect');
const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
const unitLabel = getVariantById(selectedVariantId)?.unit || 'units';
const previousValue = batchSelect.value;
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
activeBatches.forEach((batch, index) => {
const option = document.createElement('option');
const expiryLabel = formatDisplayDate(batch.expiry_date);
const locationLabel = getBatchLocationLabel(batch);
const fefoLabel = index === 0 ? ' [FEFO default]' : '';
option.value = batch.id;
option.textContent = `${batch.batch_number} | ${batch.quantity} ${unitLabel} | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`;
batchSelect.appendChild(option);
});
if (previousValue && activeBatches.some(batch => String(batch.id) === previousValue)) {
batchSelect.value = previousValue;
}
}
// 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');
const batchSelect = document.getElementById('dispenseBatchSelect');
if (!variantId) {
batchInfoSection.style.display = 'none';
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
const packSelect = document.getElementById('dispensePackSelect');
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
return;
}
const variant = getVariantById(variantId);
if (variant) {
populateDispensePackSelect(variant);
}
updateDispenseModeUi();
batchInfoSection.style.display = 'block';
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (!response.ok) throw new Error('Failed to load batches');
const batches = await response.json();
// Filter out empty batches
const activeBatches = batches.filter(b => b.quantity > 0);
if (activeBatches.length === 0) {
populateDispenseBatchSelect([]);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
return;
}
// Sort by expiry date (FEFO order)
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
populateDispenseBatchSelect(activeBatches);
const batchHtml = 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 isFEFO = index === 0;
return `
<div style="padding: 8px; margin: 5px 0; background: white; border: 1px solid #e0e0e0; border-radius: 3px; ${isFEFO ? 'border-left: 3px solid #2196F3; background: #f0f8ff;' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${batch.batch_number}</strong> ${isFEFO ? '<span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FIRST</span>' : ''}
<div style="font-size: 0.9em; color: #666; margin-top: 3px;">
Qty: <strong>${batch.quantity}</strong> |
Location: <strong>${escapeHtml(locationLabel)}</strong> |
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
</div>
</div>
</div>
</div>
`;
}).join('');
batchInfoContent.innerHTML = batchHtml;
} catch (error) {
console.error('Error loading batches:', error);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
}
// Update allocation preview when batches load
updateAllocationPreview();
}
// Update allocation preview based on quantity and allow_split flag
async function updateAllocationPreview() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const unitLabel = getVariantById(variantId)?.unit || 'units';
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value);
const allocationPreviewSection = document.getElementById('allocationPreviewSection');
const allocationPreviewContent = document.getElementById('allocationPreviewContent');
if (!variantId || isNaN(quantity) || quantity <= 0) {
allocationPreviewSection.style.display = 'none';
return;
}
allocationPreviewSection.style.display = 'block';
allocationPreviewContent.innerHTML = '<p class="loading">Calculating allocation...</p>';
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (!response.ok) throw new Error('Failed to load batches');
const batches = await response.json();
let activeBatches = batches.filter(b => b.quantity > 0)
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
if (activeBatches.length === 0) {
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available</p>';
return;
}
if (!Number.isNaN(preferredBatchId)) {
const preferredBatch = activeBatches.find(batch => batch.id === preferredBatchId);
if (!preferredBatch) {
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">✕ Selected preferred batch is no longer available.</p>';
return;
}
activeBatches = [preferredBatch, ...activeBatches.filter(batch => batch.id !== preferredBatchId)];
}
// Simulate FEFO allocation
const allocations = [];
let remainingQty = quantity;
for (const batch of activeBatches) {
if (remainingQty <= 0) break;
const allocQty = Math.min(remainingQty, batch.quantity);
allocations.push({
batchNumber: batch.batch_number,
batchId: batch.id,
quantity: allocQty,
location: getBatchLocationLabel(batch),
expiryDate: batch.expiry_date,
preferred: !Number.isNaN(preferredBatchId) && batch.id === preferredBatchId
});
remainingQty -= allocQty;
if (!allowSplit) break;
}
if (remainingQty > 0 && !allowSplit) {
const failureContext = !Number.isNaN(preferredBatchId)
? 'Preferred batch cannot fully satisfy this request. Enable split to fall through to FEFO batches.'
: 'Insufficient stock in first batch. Check "Allow Split" to use multiple batches.';
allocationPreviewContent.innerHTML = `<p style="color: #d32f2f; margin: 0;">✕ ${failureContext}</p>`;
return;
}
if (remainingQty > 0 && allowSplit) {
allocationPreviewContent.innerHTML = `
<p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} ${escapeHtml(unitLabel)} available across all batches (${remainingQty} short)</p>
<div>${allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div>
`).join('')}</div>
`;
return;
}
const allocationHtml = allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div>
`).join('');
const pluralText = allocations.length === 1 ? 'batch' : 'batches';
const introText = !Number.isNaN(preferredBatchId)
? `✓ Will start from your preferred batch, then use FEFO for any remainder across <strong>${allocations.length} ${pluralText}</strong>:`
: `✓ Will dispense from <strong>${allocations.length} ${pluralText}</strong>:`;
allocationPreviewContent.innerHTML = `
<p style="margin: 0 0 8px 0; color: #333;">${introText}</p>
<div>${allocationHtml}</div>
`;
} catch (error) {
console.error('Error calculating allocation:', error);
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error calculating allocation</p>';
}
}
// 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 = (document.getElementById('dispenseMode').value || 'subunit').toLowerCase();
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
const animalName = document.getElementById('dispenseAnimal').value;
const userName = document.getElementById('dispenseUser').value;
const notes = document.getElementById('dispenseNotes').value;
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
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;
}
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
return;
}
const dispensingData = {
drug_variant_id: variantId,
quantity: quantity,
dispense_mode: dispenseMode,
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
animal_name: animalName || null,
user_name: userName,
notes: notes || null,
allow_split: allowSplit
};
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');
}
}