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