Reporting and batch management

This commit is contained in:
2026-04-06 11:04:06 -04:00
parent b958ca493b
commit 36f0a5b07e
5 changed files with 392 additions and 36 deletions
+182 -34
View File
@@ -260,6 +260,7 @@ function setupEventListeners() {
const variantStrengthInput = document.getElementById('variantStrength');
const editVariantUnitSelect = document.getElementById('editVariantUnit');
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]');
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn');
@@ -311,6 +312,11 @@ function setupEventListeners() {
});
}
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
dispenseSourceInputs.forEach(input => input.addEventListener('change', () => {
renderDispenseInventorySourceView();
toggleDispensePrintFields();
updateDispenseAllocationSummary();
}));
if (dispensePrintEnabled) {
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
}
@@ -503,6 +509,8 @@ function updateDispenseDrugSelect() {
const packCount = document.getElementById('dispensePackCount');
const packPreview = document.getElementById('dispensePackPreview');
const quantityModeRadio = document.getElementById('dispenseModeQuantity');
const batchSourceRadio = document.getElementById('dispenseSourceBatch');
const legacySourceRadio = document.getElementById('dispenseSourceLegacy');
if (packSelect) {
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
}
@@ -515,6 +523,12 @@ function updateDispenseDrugSelect() {
if (packPreview) {
packPreview.textContent = 'Select a pack and whole-number count.';
}
if (batchSourceRadio) {
batchSourceRadio.checked = true;
}
if (legacySourceRadio) {
legacySourceRadio.checked = false;
}
resetDispensePrintFields();
@@ -528,8 +542,78 @@ function getSelectedDispenseMode() {
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit';
}
function hasLegacyDispenseStock() {
return currentDispenseBatches.length === 0 && currentDispenseLegacyQuantity > 0;
function hasLegacyDispenseQuantity() {
return currentDispenseLegacyQuantity > 0;
}
function hasBatchDispenseStock() {
return currentDispenseBatches.length > 0;
}
function getSelectedDispenseSource() {
if (getSelectedDispenseMode() === 'pack') {
return 'batch';
}
const selected = document.querySelector('input[name="dispenseSource"]:checked')?.value;
if (selected) {
return selected;
}
if (hasLegacyDispenseQuantity() && !hasBatchDispenseStock()) {
return 'legacy';
}
return 'batch';
}
function isLegacyDispenseSelected() {
return getSelectedDispenseMode() === 'subunit' && getSelectedDispenseSource() === 'legacy' && hasLegacyDispenseQuantity();
}
function updateDispenseSourceUi() {
const sourceGroup = document.getElementById('dispenseSourceGroup');
const sourceHelp = document.getElementById('dispenseSourceHelp');
const batchRadio = document.getElementById('dispenseSourceBatch');
const legacyRadio = document.getElementById('dispenseSourceLegacy');
const hasBatches = hasBatchDispenseStock();
const hasLegacy = hasLegacyDispenseQuantity();
if (!sourceGroup || !batchRadio || !legacyRadio) {
return;
}
if (getSelectedDispenseMode() === 'pack' || (!hasBatches && !hasLegacy)) {
sourceGroup.style.display = 'none';
batchRadio.checked = true;
batchRadio.disabled = !hasBatches;
legacyRadio.checked = false;
legacyRadio.disabled = true;
if (sourceHelp) sourceHelp.textContent = '';
return;
}
batchRadio.disabled = !hasBatches;
legacyRadio.disabled = !hasLegacy;
if (hasLegacy && !hasBatches) {
legacyRadio.checked = true;
} else if (!hasLegacy && hasBatches) {
batchRadio.checked = true;
} else if (!batchRadio.checked && !legacyRadio.checked) {
batchRadio.checked = true;
}
sourceGroup.style.display = hasLegacy ? '' : 'none';
if (sourceHelp) {
if (hasLegacy && hasBatches) {
sourceHelp.textContent = `Batch stock available alongside ${formatDisplayNumber(currentDispenseLegacyQuantity)} loose legacy units.`;
} else if (hasLegacy) {
sourceHelp.textContent = `Legacy loose stock available: ${formatDisplayNumber(currentDispenseLegacyQuantity)}.`;
} else {
sourceHelp.textContent = '';
}
}
}
function getDefaultLabelExpiryDate() {
@@ -546,7 +630,7 @@ function toggleDispensePrintFields() {
const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup');
const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry');
const isEnabled = Boolean(printEnabled?.checked);
const legacyStockOnly = hasLegacyDispenseStock();
const legacyStockOnly = isLegacyDispenseSelected();
if (printFields) {
printFields.style.display = isEnabled ? '' : 'none';
@@ -691,10 +775,9 @@ function updateDispenseModeUi() {
packCount.required = mode === 'pack';
}
if (currentDispenseBatches.length > 0) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
}
autoAllocateDispenseBatches();
updateDispenseSourceUi();
renderDispenseInventorySourceView();
toggleDispensePrintFields();
}
function updateDispenseQuantityFromPack() {
@@ -982,14 +1065,14 @@ function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode
}
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) {
if (getSelectedDispenseSource() === 'legacy') {
return mode === 'pack' ? 0 : currentDispenseLegacyQuantity;
}
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
}
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) {
if (getSelectedDispenseSource() === 'legacy') {
return 0;
}
if (!selectedPack) {
@@ -1134,6 +1217,52 @@ function renderExpiredDispenseBatches(expiredBatches) {
}).join('');
}
function renderDispenseInventorySourceView() {
const batchInfoContent = document.getElementById('batchInfoContent');
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
const variant = getVariantById(variantId);
if (!batchInfoContent || !variant) {
return;
}
if (getSelectedDispenseMode() === 'pack') {
if (hasBatchDispenseStock()) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
autoAllocateDispenseBatches();
} else if (hasLegacyDispenseQuantity()) {
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack dispensing is unavailable.</div>`;
updateDispenseAllocationSummary();
} else {
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
updateDispenseAllocationSummary();
}
return;
}
if (isLegacyDispenseSelected()) {
const extraText = hasBatchDispenseStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispense by quantity only.';
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy loose stock selected.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}</div>`;
updateDispenseAllocationSummary();
return;
}
if (hasBatchDispenseStock()) {
renderDispenseBatchAllocationRows(currentDispenseBatches);
autoAllocateDispenseBatches();
return;
}
if (hasLegacyDispenseQuantity()) {
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispense by quantity only.</div>`;
updateDispenseAllocationSummary();
return;
}
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
updateDispenseAllocationSummary();
}
// Update batch info display when variant is selected
async function updateBatchInfo() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
@@ -1147,6 +1276,7 @@ async function updateBatchInfo() {
currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]);
updateDispenseSourceUi();
toggleDispensePrintFields();
updateDispenseAllocationSummary();
return;
@@ -1175,32 +1305,28 @@ async function updateBatchInfo() {
currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity);
currentDispenseBatches = activeBatches;
renderExpiredDispenseBatches(expiredBatches);
if (activeBatches.length === 0) {
if (currentDispenseLegacyQuantity > 0) {
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant?.unit || 'units')} available outside the batch system. Dispense by quantity only; whole-pack allocation is unavailable.</div>`;
} else {
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>';
}
toggleDispensePrintFields();
if (!activeBatches.length && currentDispenseLegacyQuantity <= 0 && expiredBatches.length > 0) {
updateDispenseSourceUi();
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>';
toggleDispensePrintFields();
updateDispenseAllocationSummary();
return;
}
// Sort by expiry date (FEFO order)
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
currentDispenseBatches = activeBatches;
renderDispenseBatchAllocationRows(activeBatches);
updateDispenseSourceUi();
renderDispenseInventorySourceView();
toggleDispensePrintFields();
autoAllocateDispenseBatches();
} catch (error) {
console.error('Error loading batches:', error);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]);
updateDispenseSourceUi();
toggleDispensePrintFields();
updateDispenseAllocationSummary();
}
@@ -1217,6 +1343,14 @@ function autoAllocateDispenseBatches() {
return;
}
if (isLegacyDispenseSelected()) {
allocationInputs.forEach(input => {
input.value = '0';
});
updateDispenseAllocationSummary();
return;
}
let remaining = mode === 'pack'
? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0)
: requestedQuantity;
@@ -1254,7 +1388,7 @@ function updateDispenseAllocationSummary() {
const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
const mode = getSelectedDispenseMode();
const selectedPack = getSelectedDispensePack();
const legacyStockOnly = hasLegacyDispenseStock();
const legacyStockOnly = isLegacyDispenseSelected();
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
@@ -1536,6 +1670,7 @@ async function handleDispenseDrug(e) {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const dispenseMode = getSelectedDispenseMode();
const dispenseSource = getSelectedDispenseSource();
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
const animalName = document.getElementById('dispenseAnimal').value;
@@ -1547,7 +1682,7 @@ async function handleDispenseDrug(e) {
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
const variant = getVariantById(variantId);
const legacyStockOnly = hasLegacyDispenseStock();
const legacyStockOnly = isLegacyDispenseSelected();
const selectedPack = variant && selectedPackId
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
: null;
@@ -1660,6 +1795,7 @@ async function handleDispenseDrug(e) {
dispense_mode: dispenseMode,
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
dispense_source: dispenseSource,
animal_name: animalName || null,
notes: notes || null,
allocations
@@ -1726,12 +1862,12 @@ function openEditModal(drugId) {
document.getElementById('editDrugDescription').value = drug.description || '';
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
document.getElementById('editModal').classList.add('show');
openModal(document.getElementById('editModal'));
}
// Close edit modal
function closeEditModal() {
document.getElementById('editModal').classList.remove('show');
closeModal(document.getElementById('editModal'));
document.getElementById('editForm').reset();
}
@@ -1772,7 +1908,7 @@ function openAddVariantModal(drugId) {
if (form) form.reset();
document.getElementById('variantDrugId').value = drug.id;
initializeVariantPackRows();
document.getElementById('addVariantModal').classList.add('show');
openModal(document.getElementById('addVariantModal'));
}
function inferBaseUnitFromStrength(strength) {
@@ -2063,7 +2199,7 @@ function openEditVariantModal(variantId) {
setEditVariantFieldLockState(hasInventoryContext);
initializeEditVariantPackRows();
document.getElementById('editVariantModal').classList.add('show');
openModal(document.getElementById('editVariantModal'));
}
// Handle edit variant form
@@ -2987,7 +3123,9 @@ function wireDeliveryLineEvents(line) {
variantSelect.addEventListener('change', () => {
const variantId = parseInt(variantSelect.value || '', 10);
const variant = getVariantById(variantId);
packSelect.innerHTML = buildDeliveryPackOptions(variant, '');
const activePacks = getActivePacksForVariant(variant);
const nextPackId = activePacks.length === 1 ? activePacks[0].id : '';
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
if (packCountInput) packCountInput.value = '';
updateDeliveryLineQuantityDisplay(line);
});
@@ -3018,9 +3156,11 @@ function appendDeliveryLine(prefill = {}) {
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 initialVariant = prefill.variantId
? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
: drug.variants.length === 1 ? drug.variants[0] : null;
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
const initialPackId = prefill.packId || '';
const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
const initialPackCount = prefill.packCount || '';
line.innerHTML = `
@@ -3039,7 +3179,7 @@ function appendDeliveryLine(prefill = {}) {
</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>
<input type="number" class="delivery-pack-count" min="1" step="1" value="${initialPackCount}" required>
</div>
<div class="form-group">
<label>Batch Number</label>
@@ -3087,12 +3227,15 @@ function refreshDeliveryVariantSelects() {
if (!select) return;
const currentVariantId = select.value;
select.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : '');
select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId);
const variant = getVariantById(parseInt(select.value || '', 10));
if (packSelect) {
const currentPackId = packSelect.value;
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
const activePacks = getActivePacksForVariant(variant);
const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : '');
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
}
updateDeliveryLineQuantityDisplay(line);
@@ -3176,6 +3319,11 @@ async function handleReceiveDelivery(e) {
return;
}
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
showToast(`Delivery line ${i + 1} pack count must be a whole number`, 'warning');
return;
}
const variant = drug.variants.find(v => v.id === variantId);
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
if (!selectedPack) {