From b958ca493b33a381c15080a93e969e4db3a13832 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 6 Apr 2026 10:41:33 -0400 Subject: [PATCH] Batch disposal --- backend/app/main.py | 104 +++++++-- backend/app/migrate_compliance.py | 16 ++ backend/app/models.py | 4 + frontend/app.js | 364 ++++++++++++++++++++++++++---- frontend/index.html | 48 ++++ frontend/reports.html | 31 ++- frontend/reports.js | 118 +++++++++- 7 files changed, 620 insertions(+), 65 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index d3da4d0..f5a0a3b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -134,6 +134,10 @@ class BatchUpdate(BaseModel): notes: Optional[str] = None +class BatchDisposeRequest(BaseModel): + notes: Optional[str] = None + + class BatchResponse(BaseModel): id: int drug_variant_id: int @@ -150,6 +154,10 @@ class BatchResponse(BaseModel): location_name: Optional[str] = None notes: Optional[str] = None received_at: datetime + disposed_at: Optional[datetime] = None + disposed_by_user_id: Optional[int] = None + disposed_quantity: Optional[float] = None + disposal_notes: Optional[str] = None class Config: from_attributes = True @@ -384,6 +392,10 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]: "location_name": location.name if location else None, "notes": batch.notes, "received_at": batch.received_at, + "disposed_at": batch.disposed_at, + "disposed_by_user_id": batch.disposed_by_user_id, + "disposed_quantity": batch.disposed_quantity, + "disposal_notes": batch.disposal_notes, } @@ -480,6 +492,7 @@ def resolve_pack_quantity( def resolve_requested_allocations( db: Session, variant_id: int, + variant_quantity: float, requested_quantity: float, requested_allocations: List[DispensingAllocationCreate], dispense_mode: str, @@ -487,6 +500,8 @@ def resolve_requested_allocations( ) -> List[Dict[str, Any]]: """Validate explicit batch allocations against in-date stock for the variant.""" today = date.today() + total_batched_quantity = sum(float(batch.quantity or 0) for batch in db.query(Batch).filter(Batch.drug_variant_id == variant_id).all()) + legacy_unbatched_quantity = max(0.0, float(variant_quantity or 0) - total_batched_quantity) eligible_batches = ( db.query(Batch) .filter( @@ -499,7 +514,18 @@ def resolve_requested_allocations( ) if not eligible_batches: - raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant") + if dispense_mode == "pack": + raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information") + if requested_allocations: + raise HTTPException(status_code=400, detail="Batch allocations cannot be supplied when dispensing legacy stock") + if legacy_unbatched_quantity <= 0: + raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant") + if requested_quantity - legacy_unbatched_quantity > 1e-6: + raise HTTPException( + status_code=400, + detail=f"Insufficient unbatched stock. Available: {legacy_unbatched_quantity}, Requested: {requested_quantity}", + ) + return [] if not requested_allocations: raise HTTPException(status_code=400, detail="At least one batch allocation is required") @@ -1329,6 +1355,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c allocations = resolve_requested_allocations( db, variant_id=variant.id, + variant_quantity=variant.quantity, requested_quantity=dispense_qty, requested_allocations=dispensing.allocations, dispense_mode=dispense_mode, @@ -1336,7 +1363,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c ) user_name = dispensing.user_name or current_user.username - primary_batch_id = allocations[0]["batch"].id + primary_batch_id = allocations[0]["batch"].id if allocations else None db_dispensing = Dispensing( drug_variant_id=dispensing.drug_variant_id, @@ -1669,6 +1696,10 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_ "expiry_date": batch.expiry_date, "location_id": batch.location_id, "notes": batch.notes, + "disposed_at": batch.disposed_at, + "disposed_by_user_id": batch.disposed_by_user_id, + "disposed_quantity": batch.disposed_quantity, + "disposal_notes": batch.disposal_notes, } if payload.batch_number is not None: @@ -1751,6 +1782,58 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_ return serialize_batch_response(db, batch) +@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse) +def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): + batch = db.query(Batch).filter(Batch.id == batch_id).first() + if not batch: + raise HTTPException(status_code=404, detail="Batch not found") + + if batch.disposed_at is not None: + raise HTTPException(status_code=400, detail="Batch has already been disposed") + + if batch.quantity <= 0: + raise HTTPException(status_code=400, detail="Batch has no remaining stock to dispose") + + if batch.expiry_date >= date.today(): + raise HTTPException(status_code=400, detail="Only expired batches can be marked as disposed") + + variant = db.query(DrugVariant).filter(DrugVariant.id == batch.drug_variant_id).first() + if not variant: + raise HTTPException(status_code=404, detail="Parent variant not found") + + disposed_quantity = batch.quantity + if variant.quantity - disposed_quantity < -1e-6: + raise HTTPException(status_code=400, detail="Variant quantity cannot become negative during disposal") + + batch.quantity = 0 + batch.disposed_at = datetime.utcnow() + batch.disposed_by_user_id = current_user.id + batch.disposed_quantity = disposed_quantity + batch.disposal_notes = (payload.notes or '').strip() or None + recompute_batch_pack_state(batch) + variant.quantity = max(0, variant.quantity - disposed_quantity) + + write_audit_log( + db, + action="batch.dispose", + entity_type="batch", + entity_id=batch.id, + actor=current_user, + details={ + "batch_number": batch.batch_number, + "variant_id": batch.drug_variant_id, + "disposed_quantity": disposed_quantity, + "expiry_date": batch.expiry_date.isoformat() if batch.expiry_date else None, + "location_id": batch.location_id, + "disposal_notes": batch.disposal_notes, + }, + ) + + db.commit() + db.refresh(batch) + return serialize_batch_response(db, batch) + + @router.get("/audit", response_model=List[Dict[str, Any]]) def list_audit_events(skip: int = 0, limit: int = 200, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): events = db.query(AuditLog).order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all() @@ -1984,26 +2067,13 @@ def report_batch_attention( .join(DrugVariant, Batch.drug_variant_id == DrugVariant.id) .join(Drug, DrugVariant.drug_id == Drug.id) .join(Location, Batch.location_id == Location.id) - .filter(Batch.quantity > 0) + .filter(Batch.quantity > 0, Batch.expiry_date < today) .order_by(Batch.expiry_date.asc(), Drug.name.asc(), DrugVariant.strength.asc(), Batch.batch_number.asc()) .all() ) result = [] for batch, variant, drug, location in rows: - is_expired = batch.expiry_date < today - is_partial = bool((batch.current_loose_base_units or 0) > 1e-6) - - if not is_expired and not is_partial: - continue - - if is_expired and is_partial: - status = "expired_partial" - elif is_expired: - status = "expired" - else: - status = "partial" - result.append( { "batch_id": batch.id, @@ -2014,7 +2084,7 @@ def report_batch_attention( "unit": variant.unit, "location": location.name, "expiry_date": batch.expiry_date, - "status": status, + "status": "expired", "received_pack_label": None, "current_full_pack_count": batch.current_full_pack_count, "current_loose_base_units": batch.current_loose_base_units, diff --git a/backend/app/migrate_compliance.py b/backend/app/migrate_compliance.py index 4eae2b3..2643519 100644 --- a/backend/app/migrate_compliance.py +++ b/backend/app/migrate_compliance.py @@ -107,6 +107,22 @@ def migrate_compliance_schema() -> None: cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT") print("Added batches.current_loose_base_units") + if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_at"): + cursor.execute("ALTER TABLE batches ADD COLUMN disposed_at DATETIME") + print("Added batches.disposed_at") + + if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_by_user_id"): + cursor.execute("ALTER TABLE batches ADD COLUMN disposed_by_user_id INTEGER") + print("Added batches.disposed_by_user_id") + + if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_quantity"): + cursor.execute("ALTER TABLE batches ADD COLUMN disposed_quantity FLOAT") + print("Added batches.disposed_quantity") + + if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposal_notes"): + cursor.execute("ALTER TABLE batches ADD COLUMN disposal_notes VARCHAR") + print("Added batches.disposal_notes") + if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_id"): cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_id INTEGER") print("Added dispensings.requested_pack_id") diff --git a/backend/app/models.py b/backend/app/models.py index 441495f..c777f51 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,10 @@ class Batch(Base): location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True) received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True) notes = Column(String, nullable=True) + disposed_at = Column(DateTime(timezone=True), nullable=True, index=True) + disposed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + disposed_quantity = Column(Float, nullable=True) + disposal_notes = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) diff --git a/frontend/app.js b/frontend/app.js index 955a11f..d5f733c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 ` -
+
- ${escapeHtml(batch.batch_number)} - Expires ${formatDisplayDate(batch.expiry_date)} +
+ ${escapeHtml(batch.batch_number)} + ${expired ? 'Expired' : ''} +
+ Expires ${formatDisplayDate(batch.expiry_date)}
${escapeHtml(locationLabel)} | ${stocktakeLabel}
+ ${expired && !isReadOnly ? ` +
+ +
+ ` : ''}
`; }).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 = ''; 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 - ? '

⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.

' - : '

⚠️ No active batches available for this variant

'; + if (currentDispenseLegacyQuantity > 0) { + batchInfoContent.innerHTML = `
Legacy stock only. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(variant?.unit || 'units')} available outside the batch system. Dispense by quantity only; whole-pack allocation is unavailable.
`; + } else { + batchInfoContent.innerHTML = expiredBatches.length > 0 + ? '

⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.

' + : '

⚠️ No active batches available for this variant

'; + } + 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 = '

Error loading batches

'; 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 = `Enter a dispense amount to allocate batches.`; + summaryContent.innerHTML = legacyStockOnly + ? `Enter a dispense quantity. ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.` + : `Enter a dispense amount to allocate batches.`; + return; + } + + if (legacyStockOnly) { + if (mode === 'pack') { + summaryContent.innerHTML = `Whole-pack dispensing is unavailable for stock that is not attached to batches.`; + return; + } + if (requestedQuantity - currentDispenseLegacyQuantity > 1e-6) { + summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDispenseLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.`; + return; + } + summaryContent.innerHTML = `Dispensing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.`; 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) { diff --git a/frontend/index.html b/frontend/index.html index fb6e4cc..3fc1db2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -241,6 +241,24 @@
+ +
+ + +
@@ -549,6 +567,36 @@
+ + + + + \ No newline at end of file diff --git a/frontend/reports.js b/frontend/reports.js index ba3050d..7fecf5f 100644 --- a/frontend/reports.js +++ b/frontend/reports.js @@ -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 = '

No expired or partial batches match the selected filters.

'; + container.innerHTML = '

No expired batches match the selected filters.

'; 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) { ${escapeHtml(row.location || '-')} ${escapeHtml(expiryText)} ${escapeHtml(statusText)} + ${isExpired ? `` : '-'} `; }).join(''); @@ -366,6 +394,7 @@ function renderBatchAttentionTable(rows) { Location Expiry Status + Action ${rowsHtml} @@ -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 = `

${loadingText}

`; } @@ -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() {