Batch disposal
This commit is contained in:
+325
-39
@@ -12,6 +12,26 @@ let deliveryDrugId = null;
|
||||
let deliveryLineCounter = 0;
|
||||
let deliveryLocations = [];
|
||||
let currentDispenseBatches = [];
|
||||
let currentDispenseLegacyQuantity = 0;
|
||||
|
||||
function resetDisposeBatchModal() {
|
||||
const form = document.getElementById('disposeBatchForm');
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
if (batchIdInput) batchIdInput.value = '';
|
||||
if (batchNameInput) batchNameInput.value = '';
|
||||
}
|
||||
|
||||
function closeDisposeBatchModal() {
|
||||
resetDisposeBatchModal();
|
||||
const modal = document.getElementById('disposeBatchModal');
|
||||
if (modal) {
|
||||
closeModal(modal);
|
||||
}
|
||||
}
|
||||
|
||||
// Toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
@@ -209,6 +229,7 @@ function setupEventListeners() {
|
||||
const prescribeForm = document.getElementById('prescribeForm');
|
||||
const editForm = document.getElementById('editForm');
|
||||
const printNotesForm = document.getElementById('printNotesForm');
|
||||
const disposeBatchForm = document.getElementById('disposeBatchForm');
|
||||
const addModal = document.getElementById('addModal');
|
||||
const addVariantModal = document.getElementById('addVariantModal');
|
||||
const editVariantModal = document.getElementById('editVariantModal');
|
||||
@@ -216,6 +237,7 @@ function setupEventListeners() {
|
||||
const prescribeModal = document.getElementById('prescribeModal');
|
||||
const editModal = document.getElementById('editModal');
|
||||
const printNotesModal = document.getElementById('printNotesModal');
|
||||
const disposeBatchModal = document.getElementById('disposeBatchModal');
|
||||
const batchReceiveModal = document.getElementById('batchReceiveModal');
|
||||
const receiveDeliveryModal = document.getElementById('receiveDeliveryModal');
|
||||
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||
@@ -227,6 +249,7 @@ function setupEventListeners() {
|
||||
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
|
||||
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
|
||||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||||
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
|
||||
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
|
||||
const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn');
|
||||
const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn');
|
||||
@@ -237,6 +260,7 @@ function setupEventListeners() {
|
||||
const variantStrengthInput = document.getElementById('variantStrength');
|
||||
const editVariantUnitSelect = document.getElementById('editVariantUnit');
|
||||
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
|
||||
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
|
||||
const showAllBtn = document.getElementById('showAllBtn');
|
||||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||||
@@ -257,6 +281,7 @@ function setupEventListeners() {
|
||||
if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug);
|
||||
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
||||
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
|
||||
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
|
||||
|
||||
const batchReceiveForm = document.getElementById('batchReceiveForm');
|
||||
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
|
||||
@@ -286,6 +311,9 @@ function setupEventListeners() {
|
||||
});
|
||||
}
|
||||
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
|
||||
if (dispensePrintEnabled) {
|
||||
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
|
||||
}
|
||||
|
||||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||||
@@ -301,6 +329,7 @@ function setupEventListeners() {
|
||||
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||||
if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal));
|
||||
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
||||
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
|
||||
|
||||
const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn');
|
||||
if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal));
|
||||
@@ -331,6 +360,9 @@ function setupEventListeners() {
|
||||
|
||||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||||
const modal = e.target.closest('.modal');
|
||||
if (modal?.id === 'disposeBatchModal') {
|
||||
resetDisposeBatchModal();
|
||||
}
|
||||
closeModal(modal);
|
||||
}));
|
||||
|
||||
@@ -401,6 +433,9 @@ function setupEventListeners() {
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
if (e.target.id === 'disposeBatchModal') {
|
||||
resetDisposeBatchModal();
|
||||
}
|
||||
closeModal(e.target);
|
||||
}
|
||||
});
|
||||
@@ -481,7 +516,10 @@ function updateDispenseDrugSelect() {
|
||||
packPreview.textContent = 'Select a pack and whole-number count.';
|
||||
}
|
||||
|
||||
resetDispensePrintFields();
|
||||
|
||||
currentDispenseBatches = [];
|
||||
currentDispenseLegacyQuantity = 0;
|
||||
|
||||
updateDispenseModeUi();
|
||||
}
|
||||
@@ -490,6 +528,120 @@ function getSelectedDispenseMode() {
|
||||
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit';
|
||||
}
|
||||
|
||||
function hasLegacyDispenseStock() {
|
||||
return currentDispenseBatches.length === 0 && currentDispenseLegacyQuantity > 0;
|
||||
}
|
||||
|
||||
function getDefaultLabelExpiryDate() {
|
||||
const defaultExpiry = new Date();
|
||||
defaultExpiry.setMonth(defaultExpiry.getMonth() + 1);
|
||||
return defaultExpiry.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function toggleDispensePrintFields() {
|
||||
const printEnabled = document.getElementById('dispensePrintEnabled');
|
||||
const printFields = document.getElementById('dispensePrintFields');
|
||||
const printHelpText = document.getElementById('dispensePrintHelpText');
|
||||
const dosageInput = document.getElementById('dispenseDosage');
|
||||
const legacyExpiryGroup = document.getElementById('dispenseLegacyExpiryGroup');
|
||||
const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry');
|
||||
const isEnabled = Boolean(printEnabled?.checked);
|
||||
const legacyStockOnly = hasLegacyDispenseStock();
|
||||
|
||||
if (printFields) {
|
||||
printFields.style.display = isEnabled ? '' : 'none';
|
||||
}
|
||||
if (printHelpText) {
|
||||
printHelpText.textContent = legacyStockOnly
|
||||
? 'Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and a manually entered expiry date for this legacy stock.'
|
||||
: 'Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and the latest expiry date from the allocated batches.';
|
||||
}
|
||||
if (dosageInput) {
|
||||
dosageInput.required = isEnabled;
|
||||
}
|
||||
if (legacyExpiryGroup) {
|
||||
legacyExpiryGroup.style.display = isEnabled && legacyStockOnly ? '' : 'none';
|
||||
}
|
||||
if (legacyExpiryInput) {
|
||||
legacyExpiryInput.required = isEnabled && legacyStockOnly;
|
||||
if (!legacyStockOnly) {
|
||||
legacyExpiryInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetDispensePrintFields() {
|
||||
const printEnabled = document.getElementById('dispensePrintEnabled');
|
||||
const dosageInput = document.getElementById('dispenseDosage');
|
||||
const legacyExpiryInput = document.getElementById('dispenseLegacyExpiry');
|
||||
|
||||
if (printEnabled) {
|
||||
printEnabled.checked = false;
|
||||
}
|
||||
if (dosageInput) {
|
||||
dosageInput.value = '';
|
||||
}
|
||||
if (legacyExpiryInput) {
|
||||
legacyExpiryInput.value = '';
|
||||
}
|
||||
|
||||
toggleDispensePrintFields();
|
||||
}
|
||||
|
||||
function formatLabelExpiryDate(expiryDate) {
|
||||
const expiryParts = expiryDate.split('-');
|
||||
return `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`;
|
||||
}
|
||||
|
||||
function getDrugContextForVariant(variantId) {
|
||||
for (const drug of allDrugs) {
|
||||
const variant = (drug.variants || []).find(item => item.id === variantId);
|
||||
if (variant) {
|
||||
return { drug, variant };
|
||||
}
|
||||
}
|
||||
return { drug: null, variant: null };
|
||||
}
|
||||
|
||||
function getLatestAllocatedBatchExpiryDate(allocationEntries) {
|
||||
const allocatedBatches = allocationEntries
|
||||
.map(entry => currentDispenseBatches.find(batch => batch.id === entry.batch_id))
|
||||
.filter(batch => batch?.expiry_date);
|
||||
|
||||
if (allocatedBatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allocatedBatches
|
||||
.map(batch => batch.expiry_date)
|
||||
.sort((left, right) => new Date(right) - new Date(left))[0];
|
||||
}
|
||||
|
||||
async function requestLabelPrint({ animalName, drugName, variantStrength, quantity, unit, dosage, expiryDate }) {
|
||||
const labelData = {
|
||||
variables: {
|
||||
practice_name: 'Many Tears Animal Rescue',
|
||||
animal_name: animalName,
|
||||
drug_name: `${drugName} ${variantStrength}`,
|
||||
dosage,
|
||||
quantity: `${quantity} ${unit}`,
|
||||
expiry_date: formatLabelExpiryDate(expiryDate)
|
||||
}
|
||||
};
|
||||
|
||||
const labelResponse = await apiCall('/labels/print', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(labelData)
|
||||
});
|
||||
|
||||
if (!labelResponse.ok) {
|
||||
const error = await labelResponse.json();
|
||||
throw new Error(error.detail || 'Label printing request failed');
|
||||
}
|
||||
|
||||
return labelResponse.json();
|
||||
}
|
||||
|
||||
function populateDispensePackSelect(variant) {
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
const packCount = document.getElementById('dispensePackCount');
|
||||
@@ -622,6 +774,7 @@ function isBatchExpired(batch) {
|
||||
|
||||
function renderVariantInventoryDetails(variant) {
|
||||
const activePacks = getActivePacksForVariant(variant);
|
||||
const isReadOnly = currentUser?.role === 'readonly';
|
||||
const batches = [...(variant.batches || [])]
|
||||
.filter(batch => Number(batch.quantity) > 0)
|
||||
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||||
@@ -638,18 +791,31 @@ function renderVariantInventoryDetails(variant) {
|
||||
const batchesHtml = batches.length > 0
|
||||
? batches.map(batch => {
|
||||
const locationLabel = getBatchLocationLabel(batch);
|
||||
const expired = isBatchExpired(batch);
|
||||
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_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)}`;
|
||||
const batchCardStyles = expired
|
||||
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
||||
: 'padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;';
|
||||
const expiryStyles = expired ? 'color: #b91c1c; font-weight: 700;' : 'color: #4b5563;';
|
||||
|
||||
return `
|
||||
<div style="padding: 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
||||
<div style="${batchCardStyles}">
|
||||
<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 style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||||
${expired ? '<span style="background: #b91c1c; color: white; padding: 2px 6px; border-radius: 999px; font-size: 0.75em; font-weight: 700;">Expired</span>' : ''}
|
||||
</div>
|
||||
<span style="${expiryStyles}">Expires ${formatDisplayDate(batch.expiry_date)}</span>
|
||||
</div>
|
||||
<div style="margin-top: 4px; color: #374151;">${escapeHtml(locationLabel)} | ${stocktakeLabel}</div>
|
||||
${expired && !isReadOnly ? `
|
||||
<div style="margin-top: 8px; display: flex; justify-content: flex-end;">
|
||||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeBatch(${batch.id}, '${String(batch.batch_number).replace(/'/g, "\\'")}')">Dispose Expired Batch</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')
|
||||
@@ -671,6 +837,57 @@ function renderVariantInventoryDetails(variant) {
|
||||
`;
|
||||
}
|
||||
|
||||
function disposeBatch(batchId, batchNumber) {
|
||||
const modal = document.getElementById('disposeBatchModal');
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
const notesInput = document.getElementById('disposeBatchNotes');
|
||||
|
||||
if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
|
||||
showToast('Dispose batch modal is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
batchIdInput.value = String(batchId);
|
||||
batchNameInput.value = batchNumber;
|
||||
notesInput.value = '';
|
||||
openModal(modal);
|
||||
}
|
||||
|
||||
async function handleDisposeBatch(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
|
||||
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
|
||||
const modal = document.getElementById('disposeBatchModal');
|
||||
|
||||
if (!batchId) {
|
||||
showToast('Batch disposal context is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/batches/${batchId}/dispose`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes: notes || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to dispose batch');
|
||||
}
|
||||
|
||||
if (modal) {
|
||||
closeDisposeBatchModal();
|
||||
}
|
||||
await loadDrugs();
|
||||
showToast('Expired batch marked as disposed.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error disposing batch:', error);
|
||||
showToast('Failed to dispose batch: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function getBatchLocationLabel(batch) {
|
||||
return batch.location_name || batch.location?.name || `Location #${batch.location_id}`;
|
||||
}
|
||||
@@ -765,10 +982,16 @@ function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode
|
||||
}
|
||||
|
||||
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||||
if (hasLegacyDispenseStock()) {
|
||||
return mode === 'pack' ? 0 : currentDispenseLegacyQuantity;
|
||||
}
|
||||
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
|
||||
}
|
||||
|
||||
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
|
||||
if (hasLegacyDispenseStock()) {
|
||||
return 0;
|
||||
}
|
||||
if (!selectedPack) {
|
||||
return 0;
|
||||
}
|
||||
@@ -922,7 +1145,9 @@ async function updateBatchInfo() {
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
currentDispenseBatches = [];
|
||||
currentDispenseLegacyQuantity = 0;
|
||||
renderExpiredDispenseBatches([]);
|
||||
toggleDispensePrintFields();
|
||||
updateDispenseAllocationSummary();
|
||||
return;
|
||||
}
|
||||
@@ -946,13 +1171,20 @@ async function updateBatchInfo() {
|
||||
const stockedBatches = batches.filter(b => b.quantity > 0);
|
||||
const expiredBatches = stockedBatches.filter(isBatchExpired);
|
||||
const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch));
|
||||
const totalBatchQuantity = stockedBatches.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0);
|
||||
currentDispenseLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity);
|
||||
currentDispenseBatches = activeBatches;
|
||||
renderExpiredDispenseBatches(expiredBatches);
|
||||
|
||||
if (activeBatches.length === 0) {
|
||||
batchInfoContent.innerHTML = expiredBatches.length > 0
|
||||
? '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>'
|
||||
: '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
|
||||
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();
|
||||
updateDispenseAllocationSummary();
|
||||
return;
|
||||
}
|
||||
@@ -961,12 +1193,15 @@ async function updateBatchInfo() {
|
||||
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
||||
currentDispenseBatches = activeBatches;
|
||||
renderDispenseBatchAllocationRows(activeBatches);
|
||||
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([]);
|
||||
toggleDispensePrintFields();
|
||||
updateDispenseAllocationSummary();
|
||||
}
|
||||
}
|
||||
@@ -1019,10 +1254,11 @@ function updateDispenseAllocationSummary() {
|
||||
const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
|
||||
const mode = getSelectedDispenseMode();
|
||||
const selectedPack = getSelectedDispensePack();
|
||||
const legacyStockOnly = hasLegacyDispenseStock();
|
||||
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
|
||||
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||||
|
||||
if (!summarySection || !summaryContent || !variantId || !inputs.length) {
|
||||
if (!summarySection || !summaryContent || !variantId || (!inputs.length && !legacyStockOnly)) {
|
||||
if (summarySection) summarySection.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -1056,7 +1292,22 @@ function updateDispenseAllocationSummary() {
|
||||
summarySection.style.display = 'block';
|
||||
|
||||
if (requestedQuantity <= 0) {
|
||||
summaryContent.innerHTML = `<span style="color: #666;">Enter a dispense amount to allocate batches.</span>`;
|
||||
summaryContent.innerHTML = legacyStockOnly
|
||||
? `<span style="color: #666;">Enter a dispense quantity. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.</span>`
|
||||
: `<span style="color: #666;">Enter a dispense amount to allocate batches.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyStockOnly) {
|
||||
if (mode === 'pack') {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Whole-pack dispensing is unavailable for stock that is not attached to batches.</span>`;
|
||||
return;
|
||||
}
|
||||
if (requestedQuantity - currentDispenseLegacyQuantity > 1e-6) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.</span>`;
|
||||
return;
|
||||
}
|
||||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Dispensing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1289,10 +1540,14 @@ async function handleDispenseDrug(e) {
|
||||
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
|
||||
const animalName = document.getElementById('dispenseAnimal').value;
|
||||
const notes = document.getElementById('dispenseNotes').value;
|
||||
const printEnabled = document.getElementById('dispensePrintEnabled')?.checked;
|
||||
const dosage = document.getElementById('dispenseDosage')?.value.trim() || '';
|
||||
const legacyExpiryDate = document.getElementById('dispenseLegacyExpiry')?.value || '';
|
||||
|
||||
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
|
||||
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
|
||||
const variant = getVariantById(variantId);
|
||||
const legacyStockOnly = hasLegacyDispenseStock();
|
||||
const selectedPack = variant && selectedPackId
|
||||
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
|
||||
: null;
|
||||
@@ -1303,6 +1558,10 @@ async function handleDispenseDrug(e) {
|
||||
}
|
||||
|
||||
if (dispenseMode === 'pack') {
|
||||
if (legacyStockOnly) {
|
||||
showToast('Whole-pack dispensing is unavailable for stock that is not attached to batches.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!selectedPack) {
|
||||
showToast('Please select a pack type for whole-pack dispensing.', 'warning');
|
||||
return;
|
||||
@@ -1352,7 +1611,7 @@ async function handleDispenseDrug(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allocations.length === 0) {
|
||||
if (!legacyStockOnly && allocations.length === 0) {
|
||||
showToast('Allocate quantity against at least one batch.', 'warning');
|
||||
return;
|
||||
}
|
||||
@@ -1374,11 +1633,27 @@ async function handleDispenseDrug(e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(allocatedTotal - quantity) > 1e-6) {
|
||||
if (!legacyStockOnly && Math.abs(allocatedTotal - quantity) > 1e-6) {
|
||||
showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const printExpiryDate = printEnabled
|
||||
? (legacyStockOnly ? legacyExpiryDate : getLatestAllocatedBatchExpiryDate(allocationEntries))
|
||||
: null;
|
||||
|
||||
if (printEnabled && (!animalName.trim() || !dosage)) {
|
||||
showToast('Animal name/ID and dosage instructions are required to print a label.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (printEnabled && !printExpiryDate) {
|
||||
showToast(legacyStockOnly
|
||||
? 'Enter an expiry date to print a label for legacy stock.'
|
||||
: 'Unable to determine a batch expiry date for the selected allocation.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const dispensingData = {
|
||||
drug_variant_id: variantId,
|
||||
quantity: quantity,
|
||||
@@ -1401,10 +1676,40 @@ async function handleDispenseDrug(e) {
|
||||
throw new Error(error.detail || 'Failed to dispense drug');
|
||||
}
|
||||
|
||||
let successMessage = 'Drug dispensed successfully!';
|
||||
let toastType = 'success';
|
||||
|
||||
if (printEnabled) {
|
||||
try {
|
||||
const { drug } = getDrugContextForVariant(variantId);
|
||||
const labelResult = await requestLabelPrint({
|
||||
animalName: animalName.trim(),
|
||||
drugName: drug?.name || 'Unknown drug',
|
||||
variantStrength: variant?.strength || '',
|
||||
quantity,
|
||||
unit: variant?.unit || 'units',
|
||||
dosage,
|
||||
expiryDate: printExpiryDate
|
||||
});
|
||||
|
||||
if (!labelResult.success) {
|
||||
successMessage = `Drug dispensed, but label printing failed: ${labelResult.message}`;
|
||||
toastType = 'warning';
|
||||
} else {
|
||||
successMessage = 'Drug dispensed and label printed successfully!';
|
||||
}
|
||||
} catch (printError) {
|
||||
console.error('Error printing label after dispensing:', printError);
|
||||
successMessage = 'Drug dispensed, but label printing failed: ' + printError.message;
|
||||
toastType = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('dispenseForm').reset();
|
||||
resetDispensePrintFields();
|
||||
closeModal(document.getElementById('dispenseModal'));
|
||||
await loadDrugs();
|
||||
showToast('Drug dispensed successfully!', 'success');
|
||||
showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined);
|
||||
} catch (error) {
|
||||
console.error('Error dispensing drug:', error);
|
||||
showToast('Failed to dispense drug: ' + error.message, 'error');
|
||||
@@ -1879,9 +2184,7 @@ function prescribeVariant(variantId, drugName, variantStrength, unit) {
|
||||
}
|
||||
|
||||
// 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];
|
||||
document.getElementById('prescribeExpiry').value = getDefaultLabelExpiryDate();
|
||||
|
||||
// Open prescribe modal
|
||||
openModal(document.getElementById('prescribeModal'));
|
||||
@@ -1907,34 +2210,17 @@ async function handlePrescribeDrug(e) {
|
||||
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)
|
||||
const labelResult = await requestLabelPrint({
|
||||
animalName,
|
||||
drugName,
|
||||
variantStrength,
|
||||
quantity,
|
||||
unit,
|
||||
dosage,
|
||||
expiryDate
|
||||
});
|
||||
|
||||
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) {
|
||||
|
||||
@@ -241,6 +241,24 @@
|
||||
<label for="dispenseAnimal">Animal Name/ID</label>
|
||||
<input type="text" id="dispenseAnimal">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
|
||||
<input type="checkbox" id="dispensePrintEnabled">
|
||||
Print label after dispensing
|
||||
</label>
|
||||
<div id="dispensePrintFields" style="display: none; margin-top: 12px;">
|
||||
<p id="dispensePrintHelpText" style="margin: 0 0 12px; color: #666;">Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and the latest expiry date from the allocated batches.</p>
|
||||
<div class="form-group">
|
||||
<label for="dispenseDosage">Dosage Instructions *</label>
|
||||
<input type="text" id="dispenseDosage" placeholder="e.g., 1 tablet twice daily with food">
|
||||
</div>
|
||||
<div class="form-group" id="dispenseLegacyExpiryGroup" style="display: none;">
|
||||
<label for="dispenseLegacyExpiry">Expiry Date *</label>
|
||||
<input type="date" id="dispenseLegacyExpiry">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dispenseNotes">Notes</label>
|
||||
@@ -549,6 +567,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispose Batch Modal -->
|
||||
<div id="disposeBatchModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Dispose Expired Batch</h2>
|
||||
<form id="disposeBatchForm" novalidate>
|
||||
<input type="hidden" id="disposeBatchId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchName">Batch</label>
|
||||
<input type="text" id="disposeBatchName" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchNotes">Disposal Note</label>
|
||||
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Receive Modal -->
|
||||
<div id="batchReceiveModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
+30
-1
@@ -31,7 +31,7 @@
|
||||
<label for="reportTypeSelect">Report</label>
|
||||
<select id="reportTypeSelect">
|
||||
<option value="dispensing" selected>Dispensing History</option>
|
||||
<option value="batch_attention">Expired / Partial Batches</option>
|
||||
<option value="batch_attention">Expired Batches</option>
|
||||
<option value="audit">Audit Trail (Raw)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -88,6 +88,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="disposeBatchModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Dispose Expired Batch</h2>
|
||||
<form id="disposeBatchForm" novalidate>
|
||||
<input type="hidden" id="disposeBatchId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchName">Batch</label>
|
||||
<input type="text" id="disposeBatchName" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchNotes">Disposal Note</label>
|
||||
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="reports.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+110
-8
@@ -10,6 +10,34 @@ let activeReportType = 'dispensing';
|
||||
const batchLookupById = new Map();
|
||||
const loadedBatchVariants = new Set();
|
||||
|
||||
function openModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
function resetDisposeBatchModal() {
|
||||
const form = document.getElementById('disposeBatchForm');
|
||||
if (form) {
|
||||
form.reset();
|
||||
}
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
if (batchIdInput) batchIdInput.value = '';
|
||||
if (batchNameInput) batchNameInput.value = '';
|
||||
}
|
||||
|
||||
function closeDisposeBatchModal() {
|
||||
resetDisposeBatchModal();
|
||||
closeModal(document.getElementById('disposeBatchModal'));
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) return;
|
||||
@@ -325,16 +353,15 @@ function renderBatchAttentionTable(rows) {
|
||||
if (!container) return;
|
||||
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<p class="empty" style="padding: 14px;">No expired or partial batches match the selected filters.</p>';
|
||||
container.innerHTML = '<p class="empty" style="padding: 14px;">No expired batches match the selected filters.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsHtml = rows.map(row => {
|
||||
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
|
||||
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
|
||||
let statusText = 'Partial';
|
||||
if (row.status === 'expired') statusText = 'Expired';
|
||||
if (row.status === 'expired_partial') statusText = 'Expired + Partial';
|
||||
const statusText = 'Expired';
|
||||
const isExpired = true;
|
||||
|
||||
const packState = row.current_loose_base_units > 0
|
||||
? `${row.current_full_pack_count || 0} full packs + ${row.current_loose_base_units} loose ${row.unit || 'units'}`
|
||||
@@ -350,6 +377,7 @@ function renderBatchAttentionTable(rows) {
|
||||
<td>${escapeHtml(row.location || '-')}</td>
|
||||
<td>${escapeHtml(expiryText)}</td>
|
||||
<td>${escapeHtml(statusText)}</td>
|
||||
<td>${isExpired ? `<button type="button" class="btn btn-danger btn-small" onclick="disposeBatchFromReport(${row.batch_id}, '${String(row.batch_number || '').replace(/'/g, "\\'")}')">Dispose Expired Batch</button>` : '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -366,6 +394,7 @@ function renderBatchAttentionTable(rows) {
|
||||
<th>Location</th>
|
||||
<th>Expiry</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
@@ -373,6 +402,54 @@ function renderBatchAttentionTable(rows) {
|
||||
`;
|
||||
}
|
||||
|
||||
function disposeBatchFromReport(batchId, batchNumber) {
|
||||
const modal = document.getElementById('disposeBatchModal');
|
||||
const batchIdInput = document.getElementById('disposeBatchId');
|
||||
const batchNameInput = document.getElementById('disposeBatchName');
|
||||
const notesInput = document.getElementById('disposeBatchNotes');
|
||||
|
||||
if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
|
||||
showToast('Dispose batch modal is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
batchIdInput.value = String(batchId);
|
||||
batchNameInput.value = batchNumber;
|
||||
notesInput.value = '';
|
||||
openModal(modal);
|
||||
}
|
||||
|
||||
async function handleDisposeBatch(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
|
||||
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
|
||||
|
||||
if (!batchId) {
|
||||
showToast('Batch disposal context is unavailable.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/batches/${batchId}/dispose`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes: notes || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to dispose batch');
|
||||
}
|
||||
|
||||
closeDisposeBatchModal();
|
||||
await loadActiveReport();
|
||||
showToast('Expired batch marked as disposed.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error disposing batch from report:', error);
|
||||
showToast('Failed to dispose batch: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function applyCurrentFilters() {
|
||||
const userFilter = document.getElementById('reportUserFilter');
|
||||
const drugFilter = document.getElementById('reportDrugFilter');
|
||||
@@ -436,7 +513,7 @@ function applyCurrentFilters() {
|
||||
const reportName = activeReportType === 'dispensing'
|
||||
? 'dispensing records'
|
||||
: activeReportType === 'batch_attention'
|
||||
? 'expired/partial batches'
|
||||
? 'expired batches'
|
||||
: 'audit events';
|
||||
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
|
||||
}
|
||||
@@ -461,8 +538,8 @@ function updateReportHeading() {
|
||||
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
||||
if (userFilter) userFilter.style.display = '';
|
||||
} else if (activeReportType === 'batch_attention') {
|
||||
heading.textContent = 'Expired / Partial Batches';
|
||||
searchInput.placeholder = 'Search drug, batch, location, status...';
|
||||
heading.textContent = 'Expired Batches';
|
||||
searchInput.placeholder = 'Search drug, batch, location...';
|
||||
if (userFilter) userFilter.style.display = 'none';
|
||||
} else {
|
||||
heading.textContent = 'Audit Trail (Raw)';
|
||||
@@ -511,7 +588,7 @@ async function loadActiveReport() {
|
||||
const loadingText = activeReportType === 'dispensing'
|
||||
? 'Loading dispensing history...'
|
||||
: activeReportType === 'batch_attention'
|
||||
? 'Loading expired / partial batches...'
|
||||
? 'Loading expired batches...'
|
||||
: 'Loading audit trail...';
|
||||
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
|
||||
}
|
||||
@@ -581,6 +658,9 @@ function setupEventListeners() {
|
||||
const backBtn = document.getElementById('backToInventoryBtn');
|
||||
const logoutBtn = document.getElementById('reportsLogoutBtn');
|
||||
const goToLoginBtn = document.getElementById('goToLoginBtn');
|
||||
const disposeBatchForm = document.getElementById('disposeBatchForm');
|
||||
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
|
||||
const closeButtons = document.querySelectorAll('.close');
|
||||
|
||||
const userFilter = document.getElementById('reportUserFilter');
|
||||
const drugFilter = document.getElementById('reportDrugFilter');
|
||||
@@ -635,6 +715,28 @@ function setupEventListeners() {
|
||||
if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => {
|
||||
window.location.href = 'index.html';
|
||||
});
|
||||
|
||||
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
|
||||
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
|
||||
|
||||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||||
const modal = e.target.closest('.modal');
|
||||
if (modal?.id === 'disposeBatchModal') {
|
||||
closeDisposeBatchModal();
|
||||
return;
|
||||
}
|
||||
closeModal(modal);
|
||||
}));
|
||||
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
if (e.target.id === 'disposeBatchModal') {
|
||||
closeDisposeBatchModal();
|
||||
return;
|
||||
}
|
||||
closeModal(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeReportsPage() {
|
||||
|
||||
Reference in New Issue
Block a user