Compare commits

...

2 Commits

Author SHA1 Message Date
jamesp b958ca493b Batch disposal 2026-04-06 10:41:33 -04:00
jamesp 5b5e17ec3e Better dispensing 2026-04-06 09:15:38 -04:00
7 changed files with 1365 additions and 306 deletions
+263 -47
View File
@@ -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
@@ -228,17 +236,21 @@ class DrugWithVariantsResponse(BaseModel):
class Config:
from_attributes = True
class DispensingAllocationCreate(BaseModel):
batch_id: int
quantity: float
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
batch_id: Optional[int] = None
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
allow_split: bool = True
animal_name: Optional[str] = None
user_name: Optional[str] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
class DispensingAllocationResponse(BaseModel):
@@ -380,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,
}
@@ -473,15 +489,19 @@ def resolve_pack_quantity(
}
def select_batches_for_dispense(
def resolve_requested_allocations(
db: Session,
variant_id: int,
variant_quantity: float,
requested_quantity: float,
preferred_batch_id: Optional[int],
allow_split: bool,
requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str,
requested_pack_id: Optional[int],
) -> List[Dict[str, Any]]:
"""Select one or more batch allocations using FEFO with optional preferred batch override."""
"""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(
@@ -494,49 +514,109 @@ def select_batches_for_dispense(
)
if not eligible_batches:
raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant")
remaining = requested_quantity
allocations: List[Dict[str, Any]] = []
# If a preferred batch is supplied, consume from that batch first.
if preferred_batch_id is not None:
preferred = next((b for b in eligible_batches if b.id == preferred_batch_id), None)
if preferred is None:
raise HTTPException(status_code=400, detail="Preferred batch is unavailable or expired")
used = min(preferred.quantity, remaining)
if used > 0:
allocations.append({"batch": preferred, "quantity": used})
remaining -= used
eligible_batches = [b for b in eligible_batches if b.id != preferred_batch_id]
if remaining > 0 and not allow_split:
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"Preferred batch cannot fully satisfy request. Remaining required: {remaining}",
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")
eligible_by_id = {batch.id: batch for batch in eligible_batches}
seen_batch_ids = set()
allocations: List[Dict[str, Any]] = []
total_allocated = 0.0
selected_pack = None
selected_pack_size = None
if dispense_mode == "pack":
if requested_pack_id is None:
raise HTTPException(status_code=400, detail="Pack dispense requires a requested pack")
selected_pack = (
db.query(VariantPack)
.filter(
VariantPack.id == requested_pack_id,
VariantPack.drug_variant_id == variant_id,
VariantPack.is_active.is_(True),
)
.first()
)
if selected_pack is None:
raise HTTPException(status_code=400, detail="Selected pack is unavailable for this variant")
selected_pack_size = selected_pack.pack_size_in_base_units
total_full_packs_available = sum(
int(batch.current_full_pack_count or 0)
for batch in eligible_batches
if batch.received_pack_id == requested_pack_id
)
if total_full_packs_available <= 0:
raise HTTPException(status_code=400, detail="No full packs are available for the selected pack")
if requested_quantity - (total_full_packs_available * selected_pack_size) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Only {total_full_packs_available} full packs are available for the selected pack",
)
if remaining > 0 and not allow_split:
# In non-split mode, only a single batch may satisfy the request.
first = eligible_batches[0]
if first.quantity >= remaining:
allocations.append({"batch": first, "quantity": remaining})
remaining = 0
else:
raise HTTPException(status_code=400, detail="Single-batch fulfillment is not possible for requested quantity")
for entry in requested_allocations:
batch = eligible_by_id.get(entry.batch_id)
if batch is None:
raise HTTPException(status_code=400, detail=f"Batch {entry.batch_id} is unavailable, expired, or not valid for this variant")
if entry.batch_id in seen_batch_ids:
raise HTTPException(status_code=400, detail="Each batch may only be allocated once")
if entry.quantity < 0:
raise HTTPException(status_code=400, detail="Batch allocation quantity cannot be negative")
if entry.quantity == 0:
continue
if entry.quantity - batch.quantity > 1e-6:
raise HTTPException(status_code=400, detail=f"Batch {batch.batch_number} does not have enough stock for requested allocation")
if remaining > 0:
for batch in eligible_batches:
if remaining <= 0:
break
used = min(batch.quantity, remaining)
if used > 0:
allocations.append({"batch": batch, "quantity": used})
remaining -= used
if dispense_mode == "pack":
if batch.received_pack_id != requested_pack_id:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} does not contain full packs of the selected pack type",
)
if remaining > 0:
raise HTTPException(status_code=400, detail="Insufficient in-date stock across available batches")
available_full_packs = int(batch.current_full_pack_count or 0)
available_batch_quantity = available_full_packs * selected_pack_size
if available_full_packs <= 0 or available_batch_quantity <= 0:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} has no full packs available",
)
if abs((entry.quantity / selected_pack_size) - round(entry.quantity / selected_pack_size)) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} allocation must be a whole number of packs",
)
if entry.quantity - available_batch_quantity > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Batch {batch.batch_number} only has {available_full_packs} full packs available",
)
seen_batch_ids.add(entry.batch_id)
allocations.append({"batch": batch, "quantity": entry.quantity})
total_allocated += entry.quantity
if not allocations:
raise HTTPException(status_code=400, detail="At least one batch allocation must be greater than zero")
if abs(total_allocated - requested_quantity) > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Allocated quantity ({total_allocated}) must match requested quantity ({requested_quantity})",
)
return allocations
@@ -1272,16 +1352,18 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
)
allocations = select_batches_for_dispense(
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispense_qty,
preferred_batch_id=dispensing.batch_id,
allow_split=dispensing.allow_split,
requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode,
requested_pack_id=resolved["pack_id"],
)
user_name = dispensing.user_name or current_user.username
primary_batch_id = dispensing.batch_id if dispensing.batch_id is not None else 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,
@@ -1614,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:
@@ -1696,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()
@@ -1916,6 +2054,84 @@ def report_stock_by_location(
return result
@router.get("/reports/batch-attention")
def report_batch_attention(
format: str = "json",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
today = date.today()
rows = (
db.query(Batch, DrugVariant, Drug, Location)
.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, 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:
result.append(
{
"batch_id": batch.id,
"batch_number": batch.batch_number,
"drug_name": drug.name,
"strength": variant.strength,
"quantity": batch.quantity,
"unit": variant.unit,
"location": location.name,
"expiry_date": batch.expiry_date,
"status": "expired",
"received_pack_label": None,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,
"is_controlled": bool(drug.is_controlled),
}
)
if format.lower() == "csv":
csv_rows = [
[
item["batch_id"],
item["batch_number"],
item["drug_name"],
item["strength"],
item["quantity"],
item["unit"],
item["location"],
item["expiry_date"],
item["status"],
item["current_full_pack_count"],
item["current_loose_base_units"],
item["is_controlled"],
]
for item in result
]
return _csv_response(
"batch_attention.csv",
[
"batch_id",
"batch_number",
"drug_name",
"strength",
"quantity",
"unit",
"location",
"expiry_date",
"status",
"current_full_pack_count",
"current_loose_base_units",
"is_controlled",
],
csv_rows,
)
return result
@router.get("/reports/audit-trail")
def report_audit_trail(
from_date: Optional[date] = None,
+16
View File
@@ -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")
+4
View File
@@ -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())
+772 -213
View File
File diff suppressed because it is too large Load Diff
+74 -39
View File
@@ -188,33 +188,24 @@
<option value="">-- Select a drug variant --</option>
</select>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0;">Available Batches (FEFO Order)</h4>
<div id="batchInfoContent">
<p class="loading">Loading batches...</p>
<div class="form-group">
<label>Dispense Mode *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseMode" id="dispenseModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseMode" id="dispenseModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group">
<label for="dispenseBatchSelect">Preferred Batch Override</label>
<select id="dispenseBatchSelect" onchange="updateAllocationPreview()">
<option value="">Automatic FEFO Selection</option>
</select>
<small style="display: block; margin-top: 6px; color: #666;">Leave on automatic to use the earliest-expiry batch first. Choose a batch here to consume that batch first instead.</small>
</div>
<div class="form-group">
<label for="dispenseMode">Dispense Mode *</label>
<select id="dispenseMode" onchange="updateDispenseModeUi()">
<option value="subunit">Subunit Quantity</option>
<option value="pack">Whole Packs</option>
</select>
</div>
<div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
<input type="number" id="dispenseQuantity" step="0.1">
</div>
<div class="form-row" id="dispensePackRow" style="display: none;">
@@ -226,22 +217,23 @@
</div>
<div class="form-group">
<label for="dispensePackCount">Pack Count *</label>
<input type="number" id="dispensePackCount" min="0.0001" step="0.0001" onchange="updateDispenseQuantityFromPack()">
<input type="number" id="dispensePackCount" min="1" step="1" onchange="updateDispenseQuantityFromPack()">
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
Allow Split Across Multiple Batches
</label>
</div>
<div id="allocationPreviewSection" style="display: none; margin: 15px 0; padding: 10px; background: #f0f8ff; border-radius: 4px; border-left: 3px solid #2196F3;">
<h4 style="margin-top: 0;">Allocation Preview</h4>
<div id="allocationPreviewContent">
<p class="loading">Loading allocation...</p>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested dispense amount.</p>
<details id="expiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="expiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="batchAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="batchAllocationSummaryContent"></div>
</div>
<div id="batchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div>
@@ -249,10 +241,23 @@
<label for="dispenseAnimal">Animal Name/ID</label>
<input type="text" id="dispenseAnimal">
</div>
<div class="form-group">
<label for="dispenseUser">Dispensed by *</label>
<input type="text" id="dispenseUser">
<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">
@@ -562,6 +567,36 @@
</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 -->
<div id="batchReceiveModal" class="modal">
<div class="modal-content">
+30
View File
@@ -31,6 +31,7 @@
<label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option>
<option value="batch_attention">Expired Batches</option>
<option value="audit">Audit Trail (Raw)</option>
</select>
</div>
@@ -87,6 +88,35 @@
</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>
</body>
</html>
+206 -7
View File
@@ -5,10 +5,39 @@ let currentUser = null;
let allDrugs = [];
let auditTrailRows = [];
let dispensingRows = [];
let batchAttentionRows = [];
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;
@@ -170,19 +199,27 @@ function detailsContainsText(details, searchText) {
}
function getActiveRows() {
return activeReportType === 'dispensing' ? dispensingRows : auditTrailRows;
if (activeReportType === 'dispensing') return dispensingRows;
if (activeReportType === 'batch_attention') return batchAttentionRows;
return auditTrailRows;
}
function getRowUser(row) {
return activeReportType === 'dispensing' ? (row.user_name || 'unknown') : (row.actor_username || 'system');
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
if (activeReportType === 'batch_attention') return '';
return row.actor_username || 'system';
}
function getRowDrug(row) {
return activeReportType === 'dispensing' ? extractDrugLabelFromDispenseRow(row) : extractDrugLabelFromAuditRow(row);
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
return extractDrugLabelFromAuditRow(row);
}
function getRowDate(row) {
return new Date(activeReportType === 'dispensing' ? row.dispensed_at : row.created_at);
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
return new Date(row.created_at);
}
function populateCommonFilters(rows) {
@@ -193,7 +230,7 @@ function populateCommonFilters(rows) {
const previousUser = userFilter.value;
const previousDrug = drugFilter.value;
const users = Array.from(new Set(rows.map(getRowUser))).sort((a, b) => a.localeCompare(b));
const users = Array.from(new Set(rows.map(getRowUser).filter(Boolean))).sort((a, b) => a.localeCompare(b));
const drugs = Array.from(new Set(rows.map(getRowDrug).filter(label => label && label !== 'N/A'))).sort((a, b) => a.localeCompare(b));
userFilter.innerHTML = '<option value="">All Users</option>';
@@ -311,6 +348,108 @@ function renderDispensingTable(rows) {
`;
}
function renderBatchAttentionTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
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'}`;
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'}`
: `${row.current_full_pack_count || 0} full packs`;
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(row.batch_number || '')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(packState)}</td>
<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('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Strength</th>
<th>Batch</th>
<th>Quantity</th>
<th>Pack State</th>
<th>Location</th>
<th>Expiry</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
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');
@@ -346,6 +485,16 @@ function applyCurrentFilters() {
formatDispenseAllocation(row)
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'batch_attention') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.location || '',
row.status || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else {
const actionText = (row.action || '').toLowerCase();
const entityText = (row.entity_type || '').toLowerCase();
@@ -361,12 +510,18 @@ function applyCurrentFilters() {
});
if (reportsSummary) {
const reportName = activeReportType === 'dispensing' ? 'dispensing records' : 'audit events';
const reportName = activeReportType === 'dispensing'
? 'dispensing records'
: activeReportType === 'batch_attention'
? 'expired batches'
: 'audit events';
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
}
if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows);
} else if (activeReportType === 'batch_attention') {
renderBatchAttentionTable(filteredRows);
} else {
renderAuditTable(filteredRows);
}
@@ -375,14 +530,21 @@ function applyCurrentFilters() {
function updateReportHeading() {
const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch');
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = '';
} else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none';
} else {
heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...';
if (userFilter) userFilter.style.display = '';
}
}
@@ -423,7 +585,11 @@ async function loadActiveReport() {
const container = document.getElementById('reportsTableContainer');
const reportsSummary = document.getElementById('reportsSummary');
if (container) {
const loadingText = activeReportType === 'dispensing' ? 'Loading dispensing history...' : 'Loading audit trail...';
const loadingText = activeReportType === 'dispensing'
? 'Loading dispensing history...'
: activeReportType === 'batch_attention'
? 'Loading expired batches...'
: 'Loading audit trail...';
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
}
if (reportsSummary) reportsSummary.textContent = '';
@@ -438,6 +604,14 @@ async function loadActiveReport() {
dispensingRows = await response.json();
await ensureBatchLookupForDispensing(dispensingRows);
populateCommonFilters(dispensingRows);
} else if (activeReportType === 'batch_attention') {
const response = await apiCall('/reports/batch-attention');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load batch attention report');
}
batchAttentionRows = await response.json();
populateCommonFilters(batchAttentionRows);
} else {
const response = await apiCall('/reports/audit-trail');
if (!response.ok) {
@@ -484,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');
@@ -538,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() {