Better dispensing
This commit is contained in:
+187
-41
@@ -228,17 +228,21 @@ class DrugWithVariantsResponse(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
class DispensingAllocationCreate(BaseModel):
|
||||||
|
batch_id: int
|
||||||
|
quantity: float
|
||||||
|
|
||||||
|
|
||||||
class DispensingCreate(BaseModel):
|
class DispensingCreate(BaseModel):
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
quantity: Optional[float] = None
|
quantity: Optional[float] = None
|
||||||
dispense_mode: str = "subunit"
|
dispense_mode: str = "subunit"
|
||||||
batch_id: Optional[int] = None
|
|
||||||
requested_pack_id: Optional[int] = None
|
requested_pack_id: Optional[int] = None
|
||||||
requested_pack_count: Optional[float] = None
|
requested_pack_count: Optional[float] = None
|
||||||
allow_split: bool = True
|
|
||||||
animal_name: Optional[str] = None
|
animal_name: Optional[str] = None
|
||||||
user_name: Optional[str] = None
|
user_name: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
allocations: List[DispensingAllocationCreate] = []
|
||||||
|
|
||||||
|
|
||||||
class DispensingAllocationResponse(BaseModel):
|
class DispensingAllocationResponse(BaseModel):
|
||||||
@@ -473,14 +477,15 @@ def resolve_pack_quantity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def select_batches_for_dispense(
|
def resolve_requested_allocations(
|
||||||
db: Session,
|
db: Session,
|
||||||
variant_id: int,
|
variant_id: int,
|
||||||
requested_quantity: float,
|
requested_quantity: float,
|
||||||
preferred_batch_id: Optional[int],
|
requested_allocations: List[DispensingAllocationCreate],
|
||||||
allow_split: bool,
|
dispense_mode: str,
|
||||||
|
requested_pack_id: Optional[int],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> 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()
|
today = date.today()
|
||||||
eligible_batches = (
|
eligible_batches = (
|
||||||
db.query(Batch)
|
db.query(Batch)
|
||||||
@@ -496,47 +501,96 @@ def select_batches_for_dispense(
|
|||||||
if not eligible_batches:
|
if not eligible_batches:
|
||||||
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")
|
||||||
|
|
||||||
remaining = requested_quantity
|
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]] = []
|
allocations: List[Dict[str, Any]] = []
|
||||||
|
total_allocated = 0.0
|
||||||
|
selected_pack = None
|
||||||
|
selected_pack_size = None
|
||||||
|
|
||||||
# If a preferred batch is supplied, consume from that batch first.
|
if dispense_mode == "pack":
|
||||||
if preferred_batch_id is not None:
|
if requested_pack_id is None:
|
||||||
preferred = next((b for b in eligible_batches if b.id == preferred_batch_id), None)
|
raise HTTPException(status_code=400, detail="Pack dispense requires a requested pack")
|
||||||
if preferred is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Preferred batch is unavailable or expired")
|
|
||||||
|
|
||||||
used = min(preferred.quantity, remaining)
|
selected_pack = (
|
||||||
if used > 0:
|
db.query(VariantPack)
|
||||||
allocations.append({"batch": preferred, "quantity": used})
|
.filter(
|
||||||
remaining -= used
|
VariantPack.id == requested_pack_id,
|
||||||
eligible_batches = [b for b in eligible_batches if b.id != preferred_batch_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")
|
||||||
|
|
||||||
if remaining > 0 and not allow_split:
|
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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Preferred batch cannot fully satisfy request. Remaining required: {remaining}",
|
detail=f"Only {total_full_packs_available} full packs are available for the selected pack",
|
||||||
)
|
)
|
||||||
|
|
||||||
if remaining > 0 and not allow_split:
|
for entry in requested_allocations:
|
||||||
# In non-split mode, only a single batch may satisfy the request.
|
batch = eligible_by_id.get(entry.batch_id)
|
||||||
first = eligible_batches[0]
|
if batch is None:
|
||||||
if first.quantity >= remaining:
|
raise HTTPException(status_code=400, detail=f"Batch {entry.batch_id} is unavailable, expired, or not valid for this variant")
|
||||||
allocations.append({"batch": first, "quantity": remaining})
|
if entry.batch_id in seen_batch_ids:
|
||||||
remaining = 0
|
raise HTTPException(status_code=400, detail="Each batch may only be allocated once")
|
||||||
else:
|
if entry.quantity < 0:
|
||||||
raise HTTPException(status_code=400, detail="Single-batch fulfillment is not possible for requested quantity")
|
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:
|
if dispense_mode == "pack":
|
||||||
for batch in eligible_batches:
|
if batch.received_pack_id != requested_pack_id:
|
||||||
if remaining <= 0:
|
raise HTTPException(
|
||||||
break
|
status_code=400,
|
||||||
used = min(batch.quantity, remaining)
|
detail=f"Batch {batch.batch_number} does not contain full packs of the selected pack type",
|
||||||
if used > 0:
|
)
|
||||||
allocations.append({"batch": batch, "quantity": used})
|
|
||||||
remaining -= used
|
|
||||||
|
|
||||||
if remaining > 0:
|
available_full_packs = int(batch.current_full_pack_count or 0)
|
||||||
raise HTTPException(status_code=400, detail="Insufficient in-date stock across available batches")
|
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
|
return allocations
|
||||||
|
|
||||||
@@ -1272,16 +1326,17 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
|
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
|
||||||
)
|
)
|
||||||
|
|
||||||
allocations = select_batches_for_dispense(
|
allocations = resolve_requested_allocations(
|
||||||
db,
|
db,
|
||||||
variant_id=variant.id,
|
variant_id=variant.id,
|
||||||
requested_quantity=dispense_qty,
|
requested_quantity=dispense_qty,
|
||||||
preferred_batch_id=dispensing.batch_id,
|
requested_allocations=dispensing.allocations,
|
||||||
allow_split=dispensing.allow_split,
|
dispense_mode=dispense_mode,
|
||||||
|
requested_pack_id=resolved["pack_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
user_name = dispensing.user_name or current_user.username
|
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
|
||||||
|
|
||||||
db_dispensing = Dispensing(
|
db_dispensing = Dispensing(
|
||||||
drug_variant_id=dispensing.drug_variant_id,
|
drug_variant_id=dispensing.drug_variant_id,
|
||||||
@@ -1916,6 +1971,97 @@ def report_stock_by_location(
|
|||||||
return result
|
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)
|
||||||
|
.order_by(Batch.expiry_date.asc(), Drug.name.asc(), DrugVariant.strength.asc(), Batch.batch_number.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for batch, variant, drug, location in rows:
|
||||||
|
is_expired = batch.expiry_date < today
|
||||||
|
is_partial = bool((batch.current_loose_base_units or 0) > 1e-6)
|
||||||
|
|
||||||
|
if not is_expired and not is_partial:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_expired and is_partial:
|
||||||
|
status = "expired_partial"
|
||||||
|
elif is_expired:
|
||||||
|
status = "expired"
|
||||||
|
else:
|
||||||
|
status = "partial"
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"batch_id": batch.id,
|
||||||
|
"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": status,
|
||||||
|
"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")
|
@router.get("/reports/audit-trail")
|
||||||
def report_audit_trail(
|
def report_audit_trail(
|
||||||
from_date: Optional[date] = None,
|
from_date: Optional[date] = None,
|
||||||
|
|||||||
+454
-181
@@ -11,6 +11,7 @@ let accessToken = null;
|
|||||||
let deliveryDrugId = null;
|
let deliveryDrugId = null;
|
||||||
let deliveryLineCounter = 0;
|
let deliveryLineCounter = 0;
|
||||||
let deliveryLocations = [];
|
let deliveryLocations = [];
|
||||||
|
let currentDispenseBatches = [];
|
||||||
|
|
||||||
// Toast notification system
|
// Toast notification system
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
function showToast(message, type = 'info', duration = 3000) {
|
||||||
@@ -235,7 +236,7 @@ function setupEventListeners() {
|
|||||||
const variantUnitSelect = document.getElementById('variantUnit');
|
const variantUnitSelect = document.getElementById('variantUnit');
|
||||||
const variantStrengthInput = document.getElementById('variantStrength');
|
const variantStrengthInput = document.getElementById('variantStrength');
|
||||||
const editVariantUnitSelect = document.getElementById('editVariantUnit');
|
const editVariantUnitSelect = document.getElementById('editVariantUnit');
|
||||||
const dispenseModeSelect = document.getElementById('dispenseMode');
|
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
|
||||||
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');
|
||||||
@@ -284,7 +285,7 @@ function setupEventListeners() {
|
|||||||
refreshVariantPackRowLabels();
|
refreshVariantPackRowLabels();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (dispenseModeSelect) dispenseModeSelect.addEventListener('change', updateDispenseModeUi);
|
dispenseModeInputs.forEach(input => input.addEventListener('change', updateDispenseModeUi));
|
||||||
|
|
||||||
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));
|
||||||
@@ -376,7 +377,7 @@ function setupEventListeners() {
|
|||||||
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
|
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
|
||||||
if (dispenseQuantityInput) {
|
if (dispenseQuantityInput) {
|
||||||
dispenseQuantityInput.addEventListener('input', () => {
|
dispenseQuantityInput.addEventListener('input', () => {
|
||||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
const mode = getSelectedDispenseMode();
|
||||||
if (mode !== 'subunit') {
|
if (mode !== 'subunit') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -392,6 +393,8 @@ function setupEventListeners() {
|
|||||||
if (packPreview && variant) {
|
if (packPreview && variant) {
|
||||||
packPreview.textContent = `Enter direct quantity in ${variant.unit}.`;
|
packPreview.textContent = `Enter direct quantity in ${variant.unit}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoAllocateDispenseBatches();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,23 +467,29 @@ function updateDispenseDrugSelect() {
|
|||||||
const packSelect = document.getElementById('dispensePackSelect');
|
const packSelect = document.getElementById('dispensePackSelect');
|
||||||
const packCount = document.getElementById('dispensePackCount');
|
const packCount = document.getElementById('dispensePackCount');
|
||||||
const packPreview = document.getElementById('dispensePackPreview');
|
const packPreview = document.getElementById('dispensePackPreview');
|
||||||
const modeSelect = document.getElementById('dispenseMode');
|
const quantityModeRadio = document.getElementById('dispenseModeQuantity');
|
||||||
if (packSelect) {
|
if (packSelect) {
|
||||||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
}
|
}
|
||||||
if (packCount) {
|
if (packCount) {
|
||||||
packCount.value = '';
|
packCount.value = '';
|
||||||
}
|
}
|
||||||
if (modeSelect) {
|
if (quantityModeRadio) {
|
||||||
modeSelect.value = 'subunit';
|
quantityModeRadio.checked = true;
|
||||||
}
|
}
|
||||||
if (packPreview) {
|
if (packPreview) {
|
||||||
packPreview.textContent = 'Select a pack and whole-number count.';
|
packPreview.textContent = 'Select a pack and whole-number count.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentDispenseBatches = [];
|
||||||
|
|
||||||
updateDispenseModeUi();
|
updateDispenseModeUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedDispenseMode() {
|
||||||
|
return document.querySelector('input[name="dispenseMode"]:checked')?.value || 'subunit';
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -506,7 +515,7 @@ function populateDispensePackSelect(variant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateDispenseModeUi() {
|
function updateDispenseModeUi() {
|
||||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
const mode = getSelectedDispenseMode();
|
||||||
const quantityGroup = document.getElementById('dispenseQuantityGroup');
|
const quantityGroup = document.getElementById('dispenseQuantityGroup');
|
||||||
const packRow = document.getElementById('dispensePackRow');
|
const packRow = document.getElementById('dispensePackRow');
|
||||||
const quantityInput = document.getElementById('dispenseQuantity');
|
const quantityInput = document.getElementById('dispenseQuantity');
|
||||||
@@ -530,11 +539,14 @@ function updateDispenseModeUi() {
|
|||||||
packCount.required = mode === 'pack';
|
packCount.required = mode === 'pack';
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAllocationPreview();
|
if (currentDispenseBatches.length > 0) {
|
||||||
|
renderDispenseBatchAllocationRows(currentDispenseBatches);
|
||||||
|
}
|
||||||
|
autoAllocateDispenseBatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDispenseQuantityFromPack() {
|
function updateDispenseQuantityFromPack() {
|
||||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
const mode = getSelectedDispenseMode();
|
||||||
if (mode !== 'pack') return;
|
if (mode !== 'pack') return;
|
||||||
|
|
||||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||||||
@@ -547,21 +559,35 @@ function updateDispenseQuantityFromPack() {
|
|||||||
if (!quantityInput || !preview || !variant) return;
|
if (!quantityInput || !preview || !variant) return;
|
||||||
|
|
||||||
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
||||||
|
const totalAvailablePacks = selectedPack ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||||||
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
|
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
|
||||||
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
||||||
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
|
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (totalAvailablePacks <= 0) {
|
||||||
|
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||||||
|
preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`;
|
||||||
|
autoAllocateDispenseBatches();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (packCount > totalAvailablePacks) {
|
||||||
|
quantityInput.value = String(packCount * selectedPack.pack_size_in_base_units);
|
||||||
|
preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`;
|
||||||
|
autoAllocateDispenseBatches();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
||||||
quantityInput.value = String(quantity);
|
quantityInput.value = String(quantity);
|
||||||
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit}`;
|
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
|
||||||
updateAllocationPreview();
|
autoAllocateDispenseBatches();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
preview.textContent = selectedPack
|
preview.textContent = selectedPack
|
||||||
? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
|
? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available | 1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
|
||||||
: `Select a pack to calculate quantity.`;
|
: `Select a pack to calculate quantity.`;
|
||||||
|
autoAllocateDispenseBatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDisplayDate(value) {
|
function formatDisplayDate(value) {
|
||||||
@@ -583,6 +609,17 @@ function formatDisplayNumber(value) {
|
|||||||
return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3)));
|
return Number.isInteger(numeric) ? String(numeric) : String(Number(numeric.toFixed(3)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBatchExpired(batch) {
|
||||||
|
if (!batch?.expiry_date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const expiryDate = new Date(`${batch.expiry_date}T00:00:00`);
|
||||||
|
return expiryDate < today;
|
||||||
|
}
|
||||||
|
|
||||||
function renderVariantInventoryDetails(variant) {
|
function renderVariantInventoryDetails(variant) {
|
||||||
const activePacks = getActivePacksForVariant(variant);
|
const activePacks = getActivePacksForVariant(variant);
|
||||||
const batches = [...(variant.batches || [])]
|
const batches = [...(variant.batches || [])]
|
||||||
@@ -674,27 +711,204 @@ function updateLocationFilterOptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateDispenseBatchSelect(activeBatches) {
|
function getDispenseRequestedQuantity() {
|
||||||
const batchSelect = document.getElementById('dispenseBatchSelect');
|
const quantity = parseFloat(document.getElementById('dispenseQuantity')?.value || '');
|
||||||
const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity;
|
||||||
const unitLabel = getVariantById(selectedVariantId)?.unit || 'units';
|
}
|
||||||
const previousValue = batchSelect.value;
|
|
||||||
|
|
||||||
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
|
function getSelectedDispensePack() {
|
||||||
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||||||
activeBatches.forEach((batch, index) => {
|
const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10);
|
||||||
const option = document.createElement('option');
|
const variant = getVariantById(variantId);
|
||||||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
if (!variant || Number.isNaN(packId)) {
|
||||||
const locationLabel = getBatchLocationLabel(batch);
|
return null;
|
||||||
const fefoLabel = index === 0 ? ' [FEFO default]' : '';
|
|
||||||
option.value = batch.id;
|
|
||||||
option.textContent = `${batch.batch_number} | ${batch.quantity} ${unitLabel} | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`;
|
|
||||||
batchSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (previousValue && activeBatches.some(batch => String(batch.id) === previousValue)) {
|
|
||||||
batchSelect.value = previousValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchMatchesSelectedPack(batch, selectedPack) {
|
||||||
|
if (!batch || !selectedPack) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(batch.received_pack_id) === Number(selectedPack.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase();
|
||||||
|
const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase();
|
||||||
|
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchPackSize = Number(batch.received_pack_size_snapshot || 0);
|
||||||
|
const selectedPackSize = Number(selectedPack.pack_size_in_base_units || 0);
|
||||||
|
if (batchPackSize > 0 && selectedPackSize > 0 && Math.abs(batchPackSize - selectedPackSize) <= 1e-6) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatchAvailableDispenseQuantity(batch, mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||||||
|
if (mode !== 'pack') {
|
||||||
|
return Number(batch.quantity || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPackCount = Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||||||
|
return fullPackCount * Number(selectedPack.pack_size_in_base_units || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalAvailableDispenseQuantity(mode = getSelectedDispenseMode(), selectedPack = getSelectedDispensePack()) {
|
||||||
|
return currentDispenseBatches.reduce((sum, batch) => sum + getBatchAvailableDispenseQuantity(batch, mode, selectedPack), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalAvailableDispensePackCount(selectedPack = getSelectedDispensePack()) {
|
||||||
|
if (!selectedPack) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDispenseBatches.reduce((sum, batch) => {
|
||||||
|
if (!batchMatchesSelectedPack(batch, selectedPack)) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDispenseBatchAllocationRows(activeBatches) {
|
||||||
|
const batchInfoContent = document.getElementById('batchInfoContent');
|
||||||
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||||||
|
const variant = getVariantById(variantId);
|
||||||
|
const unitLabel = variant?.unit || 'units';
|
||||||
|
|
||||||
|
if (!batchInfoContent) return;
|
||||||
|
|
||||||
|
if (!activeBatches.length) {
|
||||||
|
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = getSelectedDispenseMode();
|
||||||
|
const selectedPack = getSelectedDispensePack();
|
||||||
|
|
||||||
|
batchInfoContent.innerHTML = activeBatches.map((batch, index) => {
|
||||||
|
const expiryDate = new Date(batch.expiry_date);
|
||||||
|
const locationLabel = getBatchLocationLabel(batch);
|
||||||
|
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||||||
|
const today = new Date();
|
||||||
|
const isExpired = expiryDate < today;
|
||||||
|
const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
let expiryStatus = 'OK';
|
||||||
|
let statusColor = '#4caf50';
|
||||||
|
if (isExpired) {
|
||||||
|
expiryStatus = 'EXPIRED';
|
||||||
|
statusColor = '#d32f2f';
|
||||||
|
} else if (daysToExpiry <= 7) {
|
||||||
|
expiryStatus = `${daysToExpiry}d left`;
|
||||||
|
statusColor = '#ff9800';
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||||||
|
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||||||
|
: 0;
|
||||||
|
const allocationLabel = mode === 'pack' ? 'Allocate Packs' : 'Allocate';
|
||||||
|
const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDispenseQuantity(batch, mode, selectedPack);
|
||||||
|
const allocationStep = mode === 'pack' ? 1 : 0.1;
|
||||||
|
const batchAvailabilityNote = mode === 'pack'
|
||||||
|
? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0
|
||||||
|
? 'No full packs available in this batch'
|
||||||
|
: '')
|
||||||
|
: `Available to allocate: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="padding: 10px; margin: 6px 0; background: white; border: 1px solid #e0e0e0; border-radius: 4px; ${index === 0 ? 'border-left: 3px solid #2196F3; background: #f8fbff;' : ''}">
|
||||||
|
<div style="display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) 140px; gap: 12px; align-items: end;">
|
||||||
|
<div>
|
||||||
|
<div><strong>${escapeHtml(batch.batch_number)}</strong>${index === 0 ? ' <span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FEFO</span>' : ''}</div>
|
||||||
|
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||||||
|
Available: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||||||
|
Location: <strong>${escapeHtml(locationLabel)}</strong> |
|
||||||
|
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.9em; color: #374151;">
|
||||||
|
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
||||||
|
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||||||
|
: ''}
|
||||||
|
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label for="dispenseBatchAllocation-${batch.id}">${allocationLabel}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="dispenseBatchAllocation-${batch.id}"
|
||||||
|
class="dispense-batch-allocation"
|
||||||
|
data-batch-id="${batch.id}"
|
||||||
|
data-allocation-mode="${mode}"
|
||||||
|
data-pack-size="${mode === 'pack' && selectedPack ? selectedPack.pack_size_in_base_units : ''}"
|
||||||
|
min="0"
|
||||||
|
max="${allocationMax}"
|
||||||
|
step="${allocationStep}"
|
||||||
|
value="0"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
batchInfoContent.querySelectorAll('.dispense-batch-allocation').forEach(input => {
|
||||||
|
input.addEventListener('input', updateDispenseAllocationSummary);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpiredDispenseBatches(expiredBatches) {
|
||||||
|
const expiredDetails = document.getElementById('expiredBatchDetails');
|
||||||
|
const expiredContent = document.getElementById('expiredBatchContent');
|
||||||
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||||||
|
const variant = getVariantById(variantId);
|
||||||
|
const unitLabel = variant?.unit || 'units';
|
||||||
|
|
||||||
|
if (!expiredDetails || !expiredContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expiredBatches.length) {
|
||||||
|
expiredDetails.style.display = 'none';
|
||||||
|
expiredDetails.open = false;
|
||||||
|
expiredContent.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expiredDetails.style.display = 'block';
|
||||||
|
expiredContent.innerHTML = expiredBatches.map(batch => {
|
||||||
|
const locationLabel = getBatchLocationLabel(batch);
|
||||||
|
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||||||
|
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
||||||
|
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||||||
|
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="padding: 8px; margin: 6px 0; background: white; border: 1px solid #f0d7a1; border-radius: 4px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||||||
|
<span style="color: #b45309; font-weight: 600;">Expired ${expiryLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||||||
|
Qty: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||||||
|
Location: <strong>${escapeHtml(locationLabel)}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.9em; color: #374151; margin-top: 4px;">${stocktakeLabel}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update batch info display when variant is selected
|
// Update batch info display when variant is selected
|
||||||
@@ -702,13 +916,14 @@ async function updateBatchInfo() {
|
|||||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||||||
const batchInfoSection = document.getElementById('batchInfoSection');
|
const batchInfoSection = document.getElementById('batchInfoSection');
|
||||||
const batchInfoContent = document.getElementById('batchInfoContent');
|
const batchInfoContent = document.getElementById('batchInfoContent');
|
||||||
const batchSelect = document.getElementById('dispenseBatchSelect');
|
|
||||||
|
|
||||||
if (!variantId) {
|
if (!variantId) {
|
||||||
batchInfoSection.style.display = 'none';
|
batchInfoSection.style.display = 'none';
|
||||||
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
|
|
||||||
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 = [];
|
||||||
|
renderExpiredDispenseBatches([]);
|
||||||
|
updateDispenseAllocationSummary();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +935,7 @@ async function updateBatchInfo() {
|
|||||||
|
|
||||||
batchInfoSection.style.display = 'block';
|
batchInfoSection.style.display = 'block';
|
||||||
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
|
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
|
||||||
|
renderExpiredDispenseBatches([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiCall(`/variants/${variantId}/batches`);
|
const response = await apiCall(`/variants/${variantId}/batches`);
|
||||||
@@ -727,165 +943,172 @@ async function updateBatchInfo() {
|
|||||||
|
|
||||||
const batches = await response.json();
|
const batches = await response.json();
|
||||||
|
|
||||||
// Filter out empty batches
|
const stockedBatches = batches.filter(b => b.quantity > 0);
|
||||||
const activeBatches = batches.filter(b => b.quantity > 0);
|
const expiredBatches = stockedBatches.filter(isBatchExpired);
|
||||||
|
const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch));
|
||||||
|
currentDispenseBatches = activeBatches;
|
||||||
|
renderExpiredDispenseBatches(expiredBatches);
|
||||||
|
|
||||||
if (activeBatches.length === 0) {
|
if (activeBatches.length === 0) {
|
||||||
populateDispenseBatchSelect([]);
|
batchInfoContent.innerHTML = expiredBatches.length > 0
|
||||||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</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>';
|
||||||
|
updateDispenseAllocationSummary();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by expiry date (FEFO order)
|
// Sort by expiry date (FEFO order)
|
||||||
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));
|
||||||
populateDispenseBatchSelect(activeBatches);
|
currentDispenseBatches = activeBatches;
|
||||||
|
renderDispenseBatchAllocationRows(activeBatches);
|
||||||
const batchHtml = activeBatches.map((batch, index) => {
|
autoAllocateDispenseBatches();
|
||||||
const expiryDate = new Date(batch.expiry_date);
|
|
||||||
const locationLabel = getBatchLocationLabel(batch);
|
|
||||||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
|
||||||
const today = new Date();
|
|
||||||
const isExpired = expiryDate < today;
|
|
||||||
const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
let expiryStatus = '✓ OK';
|
|
||||||
let statusColor = '#4caf50';
|
|
||||||
if (isExpired) {
|
|
||||||
expiryStatus = '✕ EXPIRED';
|
|
||||||
statusColor = '#d32f2f';
|
|
||||||
} else if (daysToExpiry <= 7) {
|
|
||||||
expiryStatus = `⚠️ ${daysToExpiry}d left`;
|
|
||||||
statusColor = '#ff9800';
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFEFO = index === 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="padding: 8px; margin: 5px 0; background: white; border: 1px solid #e0e0e0; border-radius: 3px; ${isFEFO ? 'border-left: 3px solid #2196F3; background: #f0f8ff;' : ''}">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<div>
|
|
||||||
<strong>${batch.batch_number}</strong> ${isFEFO ? '<span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FIRST</span>' : ''}
|
|
||||||
<div style="font-size: 0.9em; color: #666; margin-top: 3px;">
|
|
||||||
Qty: <strong>${batch.quantity}</strong> |
|
|
||||||
Location: <strong>${escapeHtml(locationLabel)}</strong> |
|
|
||||||
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
batchInfoContent.innerHTML = batchHtml;
|
|
||||||
} 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 = [];
|
||||||
|
renderExpiredDispenseBatches([]);
|
||||||
|
updateDispenseAllocationSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update allocation preview when batches load
|
|
||||||
updateAllocationPreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update allocation preview based on quantity and allow_split flag
|
function autoAllocateDispenseBatches() {
|
||||||
async function updateAllocationPreview() {
|
const requestedQuantity = getDispenseRequestedQuantity();
|
||||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
const allocationInputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
|
||||||
const unitLabel = getVariantById(variantId)?.unit || 'units';
|
const mode = getSelectedDispenseMode();
|
||||||
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
const selectedPack = getSelectedDispensePack();
|
||||||
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
|
|
||||||
const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value);
|
if (!allocationInputs.length) {
|
||||||
const allocationPreviewSection = document.getElementById('allocationPreviewSection');
|
updateDispenseAllocationSummary();
|
||||||
const allocationPreviewContent = document.getElementById('allocationPreviewContent');
|
|
||||||
|
|
||||||
if (!variantId || isNaN(quantity) || quantity <= 0) {
|
|
||||||
allocationPreviewSection.style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
allocationPreviewSection.style.display = 'block';
|
let remaining = mode === 'pack'
|
||||||
allocationPreviewContent.innerHTML = '<p class="loading">Calculating allocation...</p>';
|
? Math.max(0, Math.round(parseFloat(document.getElementById('dispensePackCount')?.value || '0')) || 0)
|
||||||
|
: requestedQuantity;
|
||||||
try {
|
allocationInputs.forEach(input => {
|
||||||
const response = await apiCall(`/variants/${variantId}/batches`);
|
const batchId = parseInt(input.dataset.batchId || '', 10);
|
||||||
if (!response.ok) throw new Error('Failed to load batches');
|
const batch = currentDispenseBatches.find(row => row.id === batchId);
|
||||||
|
if (!batch || requestedQuantity <= 0) {
|
||||||
const batches = await response.json();
|
input.value = '0';
|
||||||
let activeBatches = batches.filter(b => b.quantity > 0)
|
|
||||||
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
|
|
||||||
|
|
||||||
if (activeBatches.length === 0) {
|
|
||||||
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available</p>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isNaN(preferredBatchId)) {
|
let allocation = 0;
|
||||||
const preferredBatch = activeBatches.find(batch => batch.id === preferredBatchId);
|
if (mode === 'pack' && selectedPack) {
|
||||||
if (!preferredBatch) {
|
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||||||
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">✕ Selected preferred batch is no longer available.</p>';
|
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||||||
return;
|
: 0;
|
||||||
}
|
allocation = Math.min(availableFullPacks, Math.max(remaining, 0));
|
||||||
|
input.value = allocation > 0 ? String(allocation) : '0';
|
||||||
|
} else {
|
||||||
|
allocation = Math.min(getBatchAvailableDispenseQuantity(batch, mode, selectedPack), Math.max(remaining, 0));
|
||||||
|
input.value = allocation > 0 ? String(Number(allocation.toFixed(3))) : '0';
|
||||||
|
}
|
||||||
|
remaining -= allocation;
|
||||||
|
});
|
||||||
|
|
||||||
activeBatches = [preferredBatch, ...activeBatches.filter(batch => batch.id !== preferredBatchId)];
|
updateDispenseAllocationSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate FEFO allocation
|
function updateDispenseAllocationSummary() {
|
||||||
const allocations = [];
|
const summarySection = document.getElementById('batchAllocationSummary');
|
||||||
let remainingQty = quantity;
|
const summaryContent = document.getElementById('batchAllocationSummaryContent');
|
||||||
|
const requestedQuantity = getDispenseRequestedQuantity();
|
||||||
for (const batch of activeBatches) {
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||||||
if (remainingQty <= 0) break;
|
const unitLabel = getVariantById(variantId)?.unit || 'units';
|
||||||
|
const inputs = Array.from(document.querySelectorAll('.dispense-batch-allocation'));
|
||||||
const allocQty = Math.min(remainingQty, batch.quantity);
|
const mode = getSelectedDispenseMode();
|
||||||
allocations.push({
|
const selectedPack = getSelectedDispensePack();
|
||||||
batchNumber: batch.batch_number,
|
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(mode, selectedPack);
|
||||||
batchId: batch.id,
|
const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||||||
quantity: allocQty,
|
|
||||||
location: getBatchLocationLabel(batch),
|
if (!summarySection || !summaryContent || !variantId || !inputs.length) {
|
||||||
expiryDate: batch.expiry_date,
|
if (summarySection) summarySection.style.display = 'none';
|
||||||
preferred: !Number.isNaN(preferredBatchId) && batch.id === preferredBatchId
|
return;
|
||||||
});
|
|
||||||
remainingQty -= allocQty;
|
|
||||||
|
|
||||||
if (!allowSplit) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remainingQty > 0 && !allowSplit) {
|
|
||||||
const failureContext = !Number.isNaN(preferredBatchId)
|
|
||||||
? 'Preferred batch cannot fully satisfy this request. Enable split to fall through to FEFO batches.'
|
|
||||||
: 'Insufficient stock in first batch. Check "Allow Split" to use multiple batches.';
|
|
||||||
allocationPreviewContent.innerHTML = `<p style="color: #d32f2f; margin: 0;">✕ ${failureContext}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remainingQty > 0 && allowSplit) {
|
|
||||||
allocationPreviewContent.innerHTML = `
|
|
||||||
<p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} ${escapeHtml(unitLabel)} available across all batches (${remainingQty} short)</p>
|
|
||||||
<div>${allocations.map(a => `
|
|
||||||
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
|
|
||||||
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
|
|
||||||
</div>
|
|
||||||
`).join('')}</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allocationHtml = allocations.map(a => `
|
|
||||||
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
|
|
||||||
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} ${escapeHtml(unitLabel)} (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
const pluralText = allocations.length === 1 ? 'batch' : 'batches';
|
|
||||||
const introText = !Number.isNaN(preferredBatchId)
|
|
||||||
? `✓ Will start from your preferred batch, then use FEFO for any remainder across <strong>${allocations.length} ${pluralText}</strong>:`
|
|
||||||
: `✓ Will dispense from <strong>${allocations.length} ${pluralText}</strong>:`;
|
|
||||||
allocationPreviewContent.innerHTML = `
|
|
||||||
<p style="margin: 0 0 8px 0; color: #333;">${introText}</p>
|
|
||||||
<div>${allocationHtml}</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating allocation:', error);
|
|
||||||
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error calculating allocation</p>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allocated = inputs.reduce((sum, input) => {
|
||||||
|
const value = parseFloat(input.value || '0');
|
||||||
|
return sum + (Number.isNaN(value) ? 0 : value);
|
||||||
|
}, 0);
|
||||||
|
const allocatedQuantity = mode === 'pack' && selectedPack
|
||||||
|
? allocated * selectedPack.pack_size_in_base_units
|
||||||
|
: allocated;
|
||||||
|
const invalidInput = inputs.find(input => {
|
||||||
|
const batchId = parseInt(input.dataset.batchId || '', 10);
|
||||||
|
const batch = currentDispenseBatches.find(row => row.id === batchId);
|
||||||
|
const value = parseFloat(input.value || '0');
|
||||||
|
if (!batch || Number.isNaN(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mode === 'pack' && selectedPack) {
|
||||||
|
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||||||
|
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||||||
|
: 0;
|
||||||
|
return value - availableFullPacks > 1e-6 || Math.abs(value - Math.round(value)) > 1e-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAllocation = getBatchAvailableDispenseQuantity(batch, mode, selectedPack);
|
||||||
|
return value - maxAllocation > 1e-6;
|
||||||
|
});
|
||||||
|
|
||||||
|
const difference = requestedQuantity - allocatedQuantity;
|
||||||
|
summarySection.style.display = 'block';
|
||||||
|
|
||||||
|
if (requestedQuantity <= 0) {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #666;">Enter a dispense amount to allocate batches.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'pack' && selectedPack) {
|
||||||
|
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
|
||||||
|
if (totalAvailablePacks <= 0) {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispense.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedQuantity - totalAvailableQuantity > 1e-6) {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidInput) {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">One or more batch allocations exceed available stock or are not valid full-pack amounts.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(difference) <= 1e-6) {
|
||||||
|
if (mode === 'pack' && selectedPack) {
|
||||||
|
const requestedPackCount = parseFloat(document.getElementById('dispensePackCount')?.value || '0');
|
||||||
|
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).</span>`;
|
||||||
|
} else {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.</span>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (difference > 0) {
|
||||||
|
if (mode === 'pack' && selectedPack) {
|
||||||
|
const differencePacks = difference / selectedPack.pack_size_in_base_units;
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(differencePacks)} more pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
|
||||||
|
} else {
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'pack' && selectedPack) {
|
||||||
|
const differencePacks = Math.abs(difference) / selectedPack.pack_size_in_base_units;
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(differencePacks)} pack${Math.abs(differencePacks - 1) <= 1e-6 ? '' : 's'} to match the requested total.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render drugs list
|
// Render drugs list
|
||||||
@@ -1061,14 +1284,11 @@ async function handleDispenseDrug(e) {
|
|||||||
|
|
||||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||||||
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||||||
const dispenseMode = (document.getElementById('dispenseMode').value || 'subunit').toLowerCase();
|
const dispenseMode = getSelectedDispenseMode();
|
||||||
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
|
|
||||||
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
|
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
|
||||||
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 userName = document.getElementById('dispenseUser').value;
|
|
||||||
const notes = document.getElementById('dispenseNotes').value;
|
const notes = document.getElementById('dispenseNotes').value;
|
||||||
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -1099,8 +1319,63 @@ async function handleDispenseDrug(e) {
|
|||||||
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
|
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
|
const allocationEntries = Array.from(document.querySelectorAll('.dispense-batch-allocation'))
|
||||||
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
|
.map(input => ({
|
||||||
|
batch_id: parseInt(input.dataset.batchId || '', 10),
|
||||||
|
entered_value: parseFloat(input.value || '0')
|
||||||
|
}))
|
||||||
|
.filter(entry => !Number.isNaN(entry.batch_id) && !Number.isNaN(entry.entered_value) && entry.entered_value > 0);
|
||||||
|
|
||||||
|
const allocations = allocationEntries
|
||||||
|
.map(entry => ({
|
||||||
|
batch_id: entry.batch_id,
|
||||||
|
quantity: dispenseMode === 'pack' && selectedPack
|
||||||
|
? entry.entered_value * selectedPack.pack_size_in_base_units
|
||||||
|
: entry.entered_value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allocatedTotal = allocations.reduce((sum, entry) => sum + entry.quantity, 0);
|
||||||
|
const totalAvailableQuantity = getTotalAvailableDispenseQuantity(dispenseMode, selectedPack);
|
||||||
|
const totalAvailablePacks = dispenseMode === 'pack' ? getTotalAvailableDispensePackCount(selectedPack) : 0;
|
||||||
|
|
||||||
|
if (!variantId || isNaN(quantity) || quantity <= 0) {
|
||||||
|
showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity - totalAvailableQuantity > 1e-6) {
|
||||||
|
if (dispenseMode === 'pack' && selectedPack) {
|
||||||
|
showToast(`Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast(`Requested quantity exceeds available stock (${formatDisplayNumber(totalAvailableQuantity)} available).`, 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allocations.length === 0) {
|
||||||
|
showToast('Allocate quantity against at least one batch.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispenseMode === 'pack' && selectedPack) {
|
||||||
|
const invalidPackAllocation = allocationEntries.find(entry => {
|
||||||
|
const batch = currentDispenseBatches.find(row => row.id === entry.batch_id);
|
||||||
|
const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
|
||||||
|
? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
|
||||||
|
: 0;
|
||||||
|
return !batch
|
||||||
|
|| entry.entered_value - availableFullPacks > 1e-6
|
||||||
|
|| Math.abs(entry.entered_value - Math.round(entry.entered_value)) > 1e-6;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidPackAllocation) {
|
||||||
|
showToast('Whole-pack allocations must use batches with available full packs and whole-pack multiples only.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(allocatedTotal - quantity) > 1e-6) {
|
||||||
|
showToast('Batch allocations must exactly match the requested dispense quantity.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,13 +1383,11 @@ async function handleDispenseDrug(e) {
|
|||||||
drug_variant_id: variantId,
|
drug_variant_id: variantId,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
dispense_mode: dispenseMode,
|
dispense_mode: dispenseMode,
|
||||||
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
|
|
||||||
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
|
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
|
||||||
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
|
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
|
||||||
animal_name: animalName || null,
|
animal_name: animalName || null,
|
||||||
user_name: userName,
|
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
allow_split: allowSplit
|
allocations
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+27
-40
@@ -188,33 +188,24 @@
|
|||||||
<option value="">-- Select a drug variant --</option>
|
<option value="">-- Select a drug variant --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
<div class="form-group">
|
||||||
<h4 style="margin-top: 0;">Available Batches (FEFO Order)</h4>
|
<label>Dispense Mode *</label>
|
||||||
<div id="batchInfoContent">
|
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
|
||||||
<p class="loading">Loading batches...</p>
|
<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>
|
</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">
|
<div class="form-group" id="dispenseQuantityGroup">
|
||||||
<label for="dispenseQuantity">Quantity *</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-row" id="dispensePackRow" style="display: none;">
|
<div class="form-row" id="dispensePackRow" style="display: none;">
|
||||||
@@ -226,22 +217,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="dispensePackCount">Pack Count *</label>
|
<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>
|
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
|
||||||
<label>
|
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
|
||||||
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
|
<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>
|
||||||
Allow Split Across Multiple Batches
|
<details id="expiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
|
||||||
</label>
|
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
|
||||||
</div>
|
<div id="expiredBatchContent" style="margin-top: 10px;"></div>
|
||||||
|
</details>
|
||||||
<div id="allocationPreviewSection" style="display: none; margin: 15px 0; padding: 10px; background: #f0f8ff; border-radius: 4px; border-left: 3px solid #2196F3;">
|
<div id="batchAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
|
||||||
<h4 style="margin-top: 0;">Allocation Preview</h4>
|
<div id="batchAllocationSummaryContent"></div>
|
||||||
<div id="allocationPreviewContent">
|
</div>
|
||||||
<p class="loading">Loading allocation...</p>
|
<div id="batchInfoContent">
|
||||||
|
<p class="loading">Loading batches...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,11 +242,6 @@
|
|||||||
<input type="text" id="dispenseAnimal">
|
<input type="text" id="dispenseAnimal">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dispenseUser">Dispensed by *</label>
|
|
||||||
<input type="text" id="dispenseUser">
|
|
||||||
</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">
|
||||||
|
|||||||
@@ -31,6 +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="audit">Audit Trail (Raw)</option>
|
<option value="audit">Audit Trail (Raw)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+104
-7
@@ -5,6 +5,7 @@ let currentUser = null;
|
|||||||
let allDrugs = [];
|
let allDrugs = [];
|
||||||
let auditTrailRows = [];
|
let auditTrailRows = [];
|
||||||
let dispensingRows = [];
|
let dispensingRows = [];
|
||||||
|
let batchAttentionRows = [];
|
||||||
let activeReportType = 'dispensing';
|
let activeReportType = 'dispensing';
|
||||||
const batchLookupById = new Map();
|
const batchLookupById = new Map();
|
||||||
const loadedBatchVariants = new Set();
|
const loadedBatchVariants = new Set();
|
||||||
@@ -170,19 +171,27 @@ function detailsContainsText(details, searchText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveRows() {
|
function getActiveRows() {
|
||||||
return activeReportType === 'dispensing' ? dispensingRows : auditTrailRows;
|
if (activeReportType === 'dispensing') return dispensingRows;
|
||||||
|
if (activeReportType === 'batch_attention') return batchAttentionRows;
|
||||||
|
return auditTrailRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRowUser(row) {
|
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) {
|
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) {
|
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) {
|
function populateCommonFilters(rows) {
|
||||||
@@ -193,7 +202,7 @@ function populateCommonFilters(rows) {
|
|||||||
const previousUser = userFilter.value;
|
const previousUser = userFilter.value;
|
||||||
const previousDrug = drugFilter.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));
|
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>';
|
userFilter.innerHTML = '<option value="">All Users</option>';
|
||||||
@@ -311,6 +320,59 @@ 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 or partial batches match the selected filters.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsHtml = rows.map(row => {
|
||||||
|
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
|
||||||
|
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
|
||||||
|
let statusText = 'Partial';
|
||||||
|
if (row.status === 'expired') statusText = 'Expired';
|
||||||
|
if (row.status === 'expired_partial') statusText = 'Expired + Partial';
|
||||||
|
|
||||||
|
const 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>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rowsHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function applyCurrentFilters() {
|
function applyCurrentFilters() {
|
||||||
const userFilter = document.getElementById('reportUserFilter');
|
const userFilter = document.getElementById('reportUserFilter');
|
||||||
const drugFilter = document.getElementById('reportDrugFilter');
|
const drugFilter = document.getElementById('reportDrugFilter');
|
||||||
@@ -346,6 +408,16 @@ function applyCurrentFilters() {
|
|||||||
formatDispenseAllocation(row)
|
formatDispenseAllocation(row)
|
||||||
].join(' ').toLowerCase();
|
].join(' ').toLowerCase();
|
||||||
textMatch = haystack.includes(searchText);
|
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 {
|
} else {
|
||||||
const actionText = (row.action || '').toLowerCase();
|
const actionText = (row.action || '').toLowerCase();
|
||||||
const entityText = (row.entity_type || '').toLowerCase();
|
const entityText = (row.entity_type || '').toLowerCase();
|
||||||
@@ -361,12 +433,18 @@ function applyCurrentFilters() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (reportsSummary) {
|
if (reportsSummary) {
|
||||||
const reportName = activeReportType === 'dispensing' ? 'dispensing records' : 'audit events';
|
const reportName = activeReportType === 'dispensing'
|
||||||
|
? 'dispensing records'
|
||||||
|
: activeReportType === 'batch_attention'
|
||||||
|
? 'expired/partial batches'
|
||||||
|
: 'audit events';
|
||||||
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
|
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeReportType === 'dispensing') {
|
if (activeReportType === 'dispensing') {
|
||||||
renderDispensingTable(filteredRows);
|
renderDispensingTable(filteredRows);
|
||||||
|
} else if (activeReportType === 'batch_attention') {
|
||||||
|
renderBatchAttentionTable(filteredRows);
|
||||||
} else {
|
} else {
|
||||||
renderAuditTable(filteredRows);
|
renderAuditTable(filteredRows);
|
||||||
}
|
}
|
||||||
@@ -375,14 +453,21 @@ function applyCurrentFilters() {
|
|||||||
function updateReportHeading() {
|
function updateReportHeading() {
|
||||||
const heading = document.getElementById('reportsHeading');
|
const heading = document.getElementById('reportsHeading');
|
||||||
const searchInput = document.getElementById('reportActionSearch');
|
const searchInput = document.getElementById('reportActionSearch');
|
||||||
|
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
|
||||||
if (!heading || !searchInput) return;
|
if (!heading || !searchInput) return;
|
||||||
|
|
||||||
if (activeReportType === 'dispensing') {
|
if (activeReportType === 'dispensing') {
|
||||||
heading.textContent = 'Dispensing History';
|
heading.textContent = 'Dispensing History';
|
||||||
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
||||||
|
if (userFilter) userFilter.style.display = '';
|
||||||
|
} else if (activeReportType === 'batch_attention') {
|
||||||
|
heading.textContent = 'Expired / Partial Batches';
|
||||||
|
searchInput.placeholder = 'Search drug, batch, location, status...';
|
||||||
|
if (userFilter) userFilter.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
heading.textContent = 'Audit Trail (Raw)';
|
heading.textContent = 'Audit Trail (Raw)';
|
||||||
searchInput.placeholder = 'Search action, entity, details...';
|
searchInput.placeholder = 'Search action, entity, details...';
|
||||||
|
if (userFilter) userFilter.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +508,11 @@ async function loadActiveReport() {
|
|||||||
const container = document.getElementById('reportsTableContainer');
|
const container = document.getElementById('reportsTableContainer');
|
||||||
const reportsSummary = document.getElementById('reportsSummary');
|
const reportsSummary = document.getElementById('reportsSummary');
|
||||||
if (container) {
|
if (container) {
|
||||||
const loadingText = activeReportType === 'dispensing' ? 'Loading dispensing history...' : 'Loading audit trail...';
|
const loadingText = activeReportType === 'dispensing'
|
||||||
|
? 'Loading dispensing history...'
|
||||||
|
: activeReportType === 'batch_attention'
|
||||||
|
? 'Loading expired / partial batches...'
|
||||||
|
: 'Loading audit trail...';
|
||||||
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
|
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
|
||||||
}
|
}
|
||||||
if (reportsSummary) reportsSummary.textContent = '';
|
if (reportsSummary) reportsSummary.textContent = '';
|
||||||
@@ -438,6 +527,14 @@ async function loadActiveReport() {
|
|||||||
dispensingRows = await response.json();
|
dispensingRows = await response.json();
|
||||||
await ensureBatchLookupForDispensing(dispensingRows);
|
await ensureBatchLookupForDispensing(dispensingRows);
|
||||||
populateCommonFilters(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 {
|
} else {
|
||||||
const response = await apiCall('/reports/audit-trail');
|
const response = await apiCall('/reports/audit-trail');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user