Batch disposal
This commit is contained in:
+86
-16
@@ -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:
|
||||||
|
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")
|
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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
+321
-35
@@ -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;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||||||
<span style="color: #4b5563;">Expires ${formatDisplayDate(batch.expiry_date)}</span>
|
${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) {
|
||||||
|
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
|
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 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>';
|
: '<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) {
|
||||||
|
|||||||
@@ -242,6 +242,24 @@
|
|||||||
<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>
|
||||||
<input type="text" id="dispenseNotes" placeholder="Optional">
|
<input type="text" id="dispenseNotes" placeholder="Optional">
|
||||||
@@ -549,6 +567,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Batch Receive Modal -->
|
||||||
<div id="batchReceiveModal" class="modal">
|
<div id="batchReceiveModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
+30
-1
@@ -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">×</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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user