Better dispensing

This commit is contained in:
2026-04-06 09:15:38 -04:00
parent 664a3189bd
commit 5b5e17ec3e
5 changed files with 773 additions and 269 deletions
+454 -181
View File
@@ -11,6 +11,7 @@ let accessToken = null;
let deliveryDrugId = null;
let deliveryLineCounter = 0;
let deliveryLocations = [];
let currentDispenseBatches = [];
// Toast notification system
function showToast(message, type = 'info', duration = 3000) {
@@ -235,7 +236,7 @@ function setupEventListeners() {
const variantUnitSelect = document.getElementById('variantUnit');
const variantStrengthInput = document.getElementById('variantStrength');
const editVariantUnitSelect = document.getElementById('editVariantUnit');
const dispenseModeSelect = document.getElementById('dispenseMode');
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn');
const locationFilterSelect = document.getElementById('locationFilterSelect');
@@ -284,7 +285,7 @@ function setupEventListeners() {
refreshVariantPackRowLabels();
});
}
if (dispenseModeSelect) dispenseModeSelect.addEventListener('change', updateDispenseModeUi);
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
@@ -376,7 +377,7 @@ function setupEventListeners() {
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
if (dispenseQuantityInput) {
dispenseQuantityInput.addEventListener('input', () => {
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
const mode = getSelectedDispenseMode();
if (mode !== 'subunit') {
return;
}
@@ -392,6 +393,8 @@ function setupEventListeners() {
if (packPreview && variant) {
packPreview.textContent = `Enter direct quantity in ${variant.unit}.`;
}
autoAllocateDispenseBatches();
});
}
@@ -464,23 +467,29 @@ function updateDispenseDrugSelect() {
const packSelect = document.getElementById('dispensePackSelect');
const packCount = document.getElementById('dispensePackCount');
const packPreview = document.getElementById('dispensePackPreview');
const modeSelect = document.getElementById('dispenseMode');
const quantityModeRadio = document.getElementById('dispenseModeQuantity');
if (packSelect) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
}
if (packCount) {
packCount.value = '';
}
if (modeSelect) {
modeSelect.value = 'subunit';
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');
@@ -506,7 +515,7 @@ function populateDispensePackSelect(variant) {
}
function updateDispenseModeUi() {
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
const mode = getSelectedDispenseMode();
const quantityGroup = document.getElementById('dispenseQuantityGroup');
const packRow = document.getElementById('dispensePackRow');
const quantityInput = document.getElementById('dispenseQuantity');
@@ -530,11 +539,14 @@ function updateDispenseModeUi() {
packCount.required = mode === 'pack';
}
updateAllocationPreview();
if (currentDispenseBatches.length > 0) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
}
autoAllocateDispenseBatches();
}
function updateDispenseQuantityFromPack() {
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
const mode = getSelectedDispenseMode();
if (mode !== 'pack') return;
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
@@ -547,21 +559,35 @@ function updateDispenseQuantityFromPack() {
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}`;
updateAllocationPreview();
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
autoAllocateDispenseBatches();
return;
}
preview.textContent = selectedPack
? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
? `${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) {
@@ -583,6 +609,17 @@ function formatDisplayNumber(value) {
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 || [])]
@@ -674,27 +711,204 @@ function updateLocationFilterOptions() {
}
}
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;
function getDispenseRequestedQuantity() {
const quantity = parseFloat(document.getElementById('dispenseQuantity')?.value || '');
return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity;
}
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;
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
@@ -702,13 +916,14 @@ 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>';
currentDispenseBatches = [];
renderExpiredDispenseBatches([]);
updateDispenseAllocationSummary();
return;
}
@@ -720,6 +935,7 @@ async function updateBatchInfo() {
batchInfoSection.style.display = 'block';
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
renderExpiredDispenseBatches([]);
try {
const response = await apiCall(`/variants/${variantId}/batches`);
@@ -727,165 +943,172 @@ async function updateBatchInfo() {
const batches = await response.json();
// Filter out empty batches
const activeBatches = batches.filter(b => b.quantity > 0);
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) {
populateDispenseBatchSelect([]);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
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));
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;
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();
}
// 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';
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;
}
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>';
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;
}
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;
}
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;
});
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>';
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
@@ -1061,14 +1284,11 @@ async function handleDispenseDrug(e) {
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 dispenseMode = getSelectedDispenseMode();
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;
@@ -1099,8 +1319,63 @@ async function handleDispenseDrug(e) {
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');
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;
}
@@ -1108,13 +1383,11 @@ async function handleDispenseDrug(e) {
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
allocations
};
try {