Batch disposal

This commit is contained in:
2026-04-06 10:41:33 -04:00
parent 5b5e17ec3e
commit b958ca493b
7 changed files with 620 additions and 65 deletions
+87 -17
View File
@@ -134,6 +134,10 @@ class BatchUpdate(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class BatchDisposeRequest(BaseModel):
notes: Optional[str] = None
class BatchResponse(BaseModel): class BatchResponse(BaseModel):
id: int id: int
drug_variant_id: int drug_variant_id: int
@@ -150,6 +154,10 @@ class BatchResponse(BaseModel):
location_name: Optional[str] = None location_name: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
received_at: datetime 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: class Config:
from_attributes = True 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, "location_name": location.name if location else None,
"notes": batch.notes, "notes": batch.notes,
"received_at": batch.received_at, "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( def resolve_requested_allocations(
db: Session, db: Session,
variant_id: int, variant_id: int,
variant_quantity: float,
requested_quantity: float, requested_quantity: float,
requested_allocations: List[DispensingAllocationCreate], requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str, dispense_mode: str,
@@ -487,6 +500,8 @@ def resolve_requested_allocations(
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Validate explicit batch allocations against in-date stock for the variant.""" """Validate explicit batch allocations against in-date stock for the variant."""
today = date.today() 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 = ( eligible_batches = (
db.query(Batch) db.query(Batch)
.filter( .filter(
@@ -499,7 +514,18 @@ def resolve_requested_allocations(
) )
if not eligible_batches: 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: if not requested_allocations:
raise HTTPException(status_code=400, detail="At least one batch allocation is required") 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( allocations = resolve_requested_allocations(
db, db,
variant_id=variant.id, variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispense_qty, requested_quantity=dispense_qty,
requested_allocations=dispensing.allocations, requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode, 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 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( db_dispensing = Dispensing(
drug_variant_id=dispensing.drug_variant_id, 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, "expiry_date": batch.expiry_date,
"location_id": batch.location_id, "location_id": batch.location_id,
"notes": batch.notes, "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: 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) 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]]) @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)): 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() 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(DrugVariant, Batch.drug_variant_id == DrugVariant.id)
.join(Drug, DrugVariant.drug_id == Drug.id) .join(Drug, DrugVariant.drug_id == Drug.id)
.join(Location, Batch.location_id == Location.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()) .order_by(Batch.expiry_date.asc(), Drug.name.asc(), DrugVariant.strength.asc(), Batch.batch_number.asc())
.all() .all()
) )
result = [] result = []
for batch, variant, drug, location in rows: 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( result.append(
{ {
"batch_id": batch.id, "batch_id": batch.id,
@@ -2014,7 +2084,7 @@ def report_batch_attention(
"unit": variant.unit, "unit": variant.unit,
"location": location.name, "location": location.name,
"expiry_date": batch.expiry_date, "expiry_date": batch.expiry_date,
"status": status, "status": "expired",
"received_pack_label": None, "received_pack_label": None,
"current_full_pack_count": batch.current_full_pack_count, "current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units, "current_loose_base_units": batch.current_loose_base_units,
+16
View File
@@ -107,6 +107,22 @@ def migrate_compliance_schema() -> None:
cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT") cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT")
print("Added batches.current_loose_base_units") 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"): 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") cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_id INTEGER")
print("Added dispensings.requested_pack_id") print("Added dispensings.requested_pack_id")
+4
View File
@@ -92,6 +92,10 @@ class Batch(Base):
location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True) 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) received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
notes = Column(String, nullable=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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
+325 -39
View File
@@ -12,6 +12,26 @@ let deliveryDrugId = null;
let deliveryLineCounter = 0; let deliveryLineCounter = 0;
let deliveryLocations = []; let deliveryLocations = [];
let currentDispenseBatches = []; 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 // Toast notification system
function showToast(message, type = 'info', duration = 3000) { function showToast(message, type = 'info', duration = 3000) {
@@ -209,6 +229,7 @@ function setupEventListeners() {
const prescribeForm = document.getElementById('prescribeForm'); const prescribeForm = document.getElementById('prescribeForm');
const editForm = document.getElementById('editForm'); const editForm = document.getElementById('editForm');
const printNotesForm = document.getElementById('printNotesForm'); const printNotesForm = document.getElementById('printNotesForm');
const disposeBatchForm = document.getElementById('disposeBatchForm');
const addModal = document.getElementById('addModal'); const addModal = document.getElementById('addModal');
const addVariantModal = document.getElementById('addVariantModal'); const addVariantModal = document.getElementById('addVariantModal');
const editVariantModal = document.getElementById('editVariantModal'); const editVariantModal = document.getElementById('editVariantModal');
@@ -216,6 +237,7 @@ function setupEventListeners() {
const prescribeModal = document.getElementById('prescribeModal'); const prescribeModal = document.getElementById('prescribeModal');
const editModal = document.getElementById('editModal'); const editModal = document.getElementById('editModal');
const printNotesModal = document.getElementById('printNotesModal'); const printNotesModal = document.getElementById('printNotesModal');
const disposeBatchModal = document.getElementById('disposeBatchModal');
const batchReceiveModal = document.getElementById('batchReceiveModal'); const batchReceiveModal = document.getElementById('batchReceiveModal');
const receiveDeliveryModal = document.getElementById('receiveDeliveryModal'); const receiveDeliveryModal = document.getElementById('receiveDeliveryModal');
const addDrugBtn = document.getElementById('addDrugBtn'); const addDrugBtn = document.getElementById('addDrugBtn');
@@ -227,6 +249,7 @@ function setupEventListeners() {
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn'); const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn'); const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn');
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn'); const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn'); const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn');
const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn'); const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn');
@@ -237,6 +260,7 @@ function setupEventListeners() {
const variantStrengthInput = document.getElementById('variantStrength'); const variantStrengthInput = document.getElementById('variantStrength');
const editVariantUnitSelect = document.getElementById('editVariantUnit'); const editVariantUnitSelect = document.getElementById('editVariantUnit');
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]'); const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
const showAllBtn = document.getElementById('showAllBtn'); const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn');
const locationFilterSelect = document.getElementById('locationFilterSelect'); const locationFilterSelect = document.getElementById('locationFilterSelect');
@@ -257,6 +281,7 @@ function setupEventListeners() {
if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug); if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug);
if (editForm) editForm.addEventListener('submit', handleEditDrug); if (editForm) editForm.addEventListener('submit', handleEditDrug);
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes); if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
const batchReceiveForm = document.getElementById('batchReceiveForm'); const batchReceiveForm = document.getElementById('batchReceiveForm');
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive); if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
@@ -286,6 +311,9 @@ function setupEventListeners() {
}); });
} }
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi)); dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
if (dispensePrintEnabled) {
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
}
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
@@ -301,6 +329,7 @@ function setupEventListeners() {
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal)); if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal)); if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal));
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal); if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn'); const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn');
if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal)); if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal));
@@ -331,6 +360,9 @@ function setupEventListeners() {
closeButtons.forEach(btn => btn.addEventListener('click', (e) => { closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
const modal = e.target.closest('.modal'); const modal = e.target.closest('.modal');
if (modal?.id === 'disposeBatchModal') {
resetDisposeBatchModal();
}
closeModal(modal); closeModal(modal);
})); }));
@@ -401,6 +433,9 @@ function setupEventListeners() {
// Close modal when clicking outside // Close modal when clicking outside
window.addEventListener('click', (e) => { window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) { if (e.target.classList.contains('modal')) {
if (e.target.id === 'disposeBatchModal') {
resetDisposeBatchModal();
}
closeModal(e.target); closeModal(e.target);
} }
}); });
@@ -481,7 +516,10 @@ function updateDispenseDrugSelect() {
packPreview.textContent = 'Select a pack and whole-number count.'; packPreview.textContent = 'Select a pack and whole-number count.';
} }
resetDispensePrintFields();
currentDispenseBatches = []; currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
updateDispenseModeUi(); updateDispenseModeUi();
} }
@@ -490,6 +528,120 @@ function getSelectedDispenseMode() {
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit'; 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) { function populateDispensePackSelect(variant) {
const packSelect = document.getElementById('dispensePackSelect'); const packSelect = document.getElementById('dispensePackSelect');
const packCount = document.getElementById('dispensePackCount'); const packCount = document.getElementById('dispensePackCount');
@@ -622,6 +774,7 @@ function isBatchExpired(batch) {
function renderVariantInventoryDetails(variant) { function renderVariantInventoryDetails(variant) {
const activePacks = getActivePacksForVariant(variant); const activePacks = getActivePacksForVariant(variant);
const isReadOnly = currentUser?.role === 'readonly';
const batches = [...(variant.batches || [])] const batches = [...(variant.batches || [])]
.filter(batch => Number(batch.quantity) > 0) .filter(batch => Number(batch.quantity) > 0)
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); .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 const batchesHtml = batches.length > 0
? batches.map(batch => { ? batches.map(batch => {
const locationLabel = getBatchLocationLabel(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 hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label;
const stocktakeLabel = hasPackState 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.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)}`; : `${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 ` 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;"> <div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
<strong>${escapeHtml(batch.batch_number)}</strong> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<span style="color: #4b5563;">Expires ${formatDisplayDate(batch.expiry_date)}</span> <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>
<div style="margin-top: 4px; color: #374151;">${escapeHtml(locationLabel)} | ${stocktakeLabel}</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> </div>
`; `;
}).join('') }).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) { function getBatchLocationLabel(batch) {
return batch.location_name || batch.location?.name || `Location #${batch.location_id}`; 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()) { function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) {
return mode === 'pack' ? 0 : currentDispenseLegacyQuantity;
}
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0); return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
} }
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) { function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
if (hasLegacyDispenseStock()) {
return 0;
}
if (!selectedPack) { if (!selectedPack) {
return 0; return 0;
} }
@@ -922,7 +1145,9 @@ async function updateBatchInfo() {
const packSelect = document.getElementById('dispensePackSelect'); const packSelect = document.getElementById('dispensePackSelect');
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>'; if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
currentDispenseBatches = []; currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]); renderExpiredDispenseBatches([]);
toggleDispensePrintFields();
updateDispenseAllocationSummary(); updateDispenseAllocationSummary();
return; return;
} }
@@ -946,13 +1171,20 @@ async function updateBatchInfo() {
const stockedBatches = batches.filter(b => b.quantity > 0); const stockedBatches = batches.filter(b => b.quantity > 0);
const expiredBatches = stockedBatches.filter(isBatchExpired); const expiredBatches = stockedBatches.filter(isBatchExpired);
const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch)); 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; currentDispenseBatches = activeBatches;
renderExpiredDispenseBatches(expiredBatches); renderExpiredDispenseBatches(expiredBatches);
if (activeBatches.length === 0) { if (activeBatches.length === 0) {
batchInfoContent.innerHTML = expiredBatches.length > 0 if (currentDispenseLegacyQuantity > 0) {
? '<p style="color: #d32f2f; margin: 0;">⚠️ No in-date batches available for this variant. Expired batches are hidden from selection.</p>' 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>`;
: '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>'; } 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(); updateDispenseAllocationSummary();
return; return;
} }
@@ -961,12 +1193,15 @@ async function updateBatchInfo() {
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
currentDispenseBatches = activeBatches; currentDispenseBatches = activeBatches;
renderDispenseBatchAllocationRows(activeBatches); renderDispenseBatchAllocationRows(activeBatches);
toggleDispensePrintFields();
autoAllocateDispenseBatches(); autoAllocateDispenseBatches();
} catch (error) { } catch (error) {
console.error('Error loading batches:', error); console.error('Error loading batches:', error);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>'; batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
currentDispenseBatches = []; currentDispenseBatches = [];
currentDispenseLegacyQuantity = 0;
renderExpiredDispenseBatches([]); renderExpiredDispenseBatches([]);
toggleDispensePrintFields();
updateDispenseAllocationSummary(); updateDispenseAllocationSummary();
} }
} }
@@ -1019,10 +1254,11 @@ function updateDispenseAllocationSummary() {
const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation')); const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
const mode = getSelectedDispenseMode(); const mode = getSelectedDispenseMode();
const selectedPack = getSelectedDispensePack(); const selectedPack = getSelectedDispensePack();
const legacyStockOnly = hasLegacyDispenseStock();
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack); const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0; 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'; if (summarySection) summarySection.style.display = 'none';
return; return;
} }
@@ -1056,7 +1292,22 @@ function updateDispenseAllocationSummary() {
summarySection.style.display = 'block'; summarySection.style.display = 'block';
if (requestedQuantity <= 0) { 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; return;
} }
@@ -1289,10 +1540,14 @@ async function handleDispenseDrug(e) {
const requestedPackCountValue = document.getElementById('dispensePackCount').value; const requestedPackCountValue = document.getElementById('dispensePackCount').value;
const animalName = document.getElementById('dispenseAnimal').value; const animalName = document.getElementById('dispenseAnimal').value;
const notes = document.getElementById('dispenseNotes').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 selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null; const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
const variant = getVariantById(variantId); const variant = getVariantById(variantId);
const legacyStockOnly = hasLegacyDispenseStock();
const selectedPack = variant && selectedPackId const selectedPack = variant && selectedPackId
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId) ? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
: null; : null;
@@ -1303,6 +1558,10 @@ async function handleDispenseDrug(e) {
} }
if (dispenseMode === 'pack') { if (dispenseMode === 'pack') {
if (legacyStockOnly) {
showToast('Whole-pack dispensing is unavailable for stock that is not attached to batches.', 'warning');
return;
}
if (!selectedPack) { if (!selectedPack) {
showToast('Please select a pack type for whole-pack dispensing.', 'warning'); showToast('Please select a pack type for whole-pack dispensing.', 'warning');
return; return;
@@ -1352,7 +1611,7 @@ async function handleDispenseDrug(e) {
return; return;
} }
if (allocations.length === 0) { if (!legacyStockOnly && allocations.length === 0) {
showToast('Allocate quantity against at least one batch.', 'warning'); showToast('Allocate quantity against at least one batch.', 'warning');
return; 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'); showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning');
return; 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 = { const dispensingData = {
drug_variant_id: variantId, drug_variant_id: variantId,
quantity: quantity, quantity: quantity,
@@ -1401,10 +1676,40 @@ async function handleDispenseDrug(e) {
throw new Error(error.detail || 'Failed to dispense drug'); 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(); document.getElementById('dispenseForm').reset();
resetDispensePrintFields();
closeModal(document.getElementById('dispenseModal')); closeModal(document.getElementById('dispenseModal'));
await loadDrugs(); await loadDrugs();
showToast('Drug dispensed successfully!', 'success'); showToast(successMessage, toastType, toastType === 'warning' ? 5000 : undefined);
} catch (error) { } catch (error) {
console.error('Error dispensing drug:', error); console.error('Error dispensing drug:', error);
showToast('Failed to dispense drug: ' + error.message, '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 // Set default expiry date to 1 month from now
const defaultExpiry = new Date(); document.getElementById('prescribeExpiry').value = getDefaultLabelExpiryDate();
defaultExpiry.setMonth(defaultExpiry.getMonth() + 1);
document.getElementById('prescribeExpiry').value = defaultExpiry.toISOString().split('T')[0];
// Open prescribe modal // Open prescribe modal
openModal(document.getElementById('prescribeModal')); openModal(document.getElementById('prescribeModal'));
@@ -1907,34 +2210,17 @@ async function handlePrescribeDrug(e) {
return; return;
} }
// Convert expiry date to DD/MM/YYYY format
const expiryParts = expiryDate.split('-');
const formattedExpiry = `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`;
try { try {
// First, print the label // First, print the label
const labelData = { const labelResult = await requestLabelPrint({
variables: { animalName,
practice_name: "Many Tears Animal Rescue", drugName,
animal_name: animalName, variantStrength,
drug_name: `${drugName} ${variantStrength}`, quantity,
dosage: dosage, unit,
quantity: `${quantity} ${unit}`, dosage,
expiry_date: formattedExpiry 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');
}
const labelResult = await labelResponse.json();
console.log('Label print result:', labelResult); console.log('Label print result:', labelResult);
if (!labelResult.success) { if (!labelResult.success) {
+48
View File
@@ -241,6 +241,24 @@
<label for="dispenseAnimal">Animal Name/ID</label> <label for="dispenseAnimal">Animal Name/ID</label>
<input type="text" id="dispenseAnimal"> <input type="text" id="dispenseAnimal">
</div> </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"> <div class="form-group">
<label for="dispenseNotes">Notes</label> <label for="dispenseNotes">Notes</label>
@@ -549,6 +567,36 @@
</div> </div>
</div> </div>
<!-- Dispose Batch Modal -->
<div id="disposeBatchModal" class="modal">
<div class="modal-content">
<span class="close">&times;</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 --> <!-- Batch Receive Modal -->
<div id="batchReceiveModal" class="modal"> <div id="batchReceiveModal" class="modal">
<div class="modal-content"> <div class="modal-content">
+30 -1
View File
@@ -31,7 +31,7 @@
<label for="reportTypeSelect">Report</label> <label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect"> <select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option> <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> <option value="audit">Audit Trail (Raw)</option>
</select> </select>
</div> </div>
@@ -88,6 +88,35 @@
</div> </div>
</div> </div>
<div id="disposeBatchModal" class="modal">
<div class="modal-content">
<span class="close">&times;</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> <script src="reports.js"></script>
</body> </body>
</html> </html>
+110 -8
View File
@@ -10,6 +10,34 @@ let activeReportType = 'dispensing';
const batchLookupById = new Map(); const batchLookupById = new Map();
const loadedBatchVariants = new Set(); 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) { function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer'); const container = document.getElementById('toastContainer');
if (!container) return; if (!container) return;
@@ -325,16 +353,15 @@ function renderBatchAttentionTable(rows) {
if (!container) return; if (!container) return;
if (!rows.length) { 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; return;
} }
const rowsHtml = rows.map(row => { const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown'; const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
const quantityText = `${row.quantity} ${row.unit || 'units'}`; const quantityText = `${row.quantity} ${row.unit || 'units'}`;
let statusText = 'Partial'; const statusText = 'Expired';
if (row.status === 'expired') statusText = 'Expired'; const isExpired = true;
if (row.status === 'expired_partial') statusText = 'Expired + Partial';
const packState = row.current_loose_base_units > 0 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'}` ? `${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(row.location || '-')}</td>
<td>${escapeHtml(expiryText)}</td> <td>${escapeHtml(expiryText)}</td>
<td>${escapeHtml(statusText)}</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> </tr>
`; `;
}).join(''); }).join('');
@@ -366,6 +394,7 @@ function renderBatchAttentionTable(rows) {
<th>Location</th> <th>Location</th>
<th>Expiry</th> <th>Expiry</th>
<th>Status</th> <th>Status</th>
<th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody>${rowsHtml}</tbody> <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() { function applyCurrentFilters() {
const userFilter = document.getElementById('reportUserFilter'); const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter'); const drugFilter = document.getElementById('reportDrugFilter');
@@ -436,7 +513,7 @@ function applyCurrentFilters() {
const reportName = activeReportType === 'dispensing' const reportName = activeReportType === 'dispensing'
? 'dispensing records' ? 'dispensing records'
: activeReportType === 'batch_attention' : activeReportType === 'batch_attention'
? 'expired/partial batches' ? 'expired batches'
: 'audit events'; : 'audit events';
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`; reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
} }
@@ -461,8 +538,8 @@ function updateReportHeading() {
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...'; searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = ''; if (userFilter) userFilter.style.display = '';
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired / Partial Batches'; heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location, status...'; searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none'; if (userFilter) userFilter.style.display = 'none';
} else { } else {
heading.textContent = 'Audit Trail (Raw)'; heading.textContent = 'Audit Trail (Raw)';
@@ -511,7 +588,7 @@ async function loadActiveReport() {
const loadingText = activeReportType === 'dispensing' const loadingText = activeReportType === 'dispensing'
? 'Loading dispensing history...' ? 'Loading dispensing history...'
: activeReportType === 'batch_attention' : activeReportType === 'batch_attention'
? 'Loading expired / partial batches...' ? 'Loading expired batches...'
: 'Loading audit trail...'; : 'Loading audit trail...';
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`; container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
} }
@@ -581,6 +658,9 @@ function setupEventListeners() {
const backBtn = document.getElementById('backToInventoryBtn'); const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn'); const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn'); 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 userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter'); const drugFilter = document.getElementById('reportDrugFilter');
@@ -635,6 +715,28 @@ function setupEventListeners() {
if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => { if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => {
window.location.href = 'index.html'; 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() { async function initializeReportsPage() {