WIP
This commit is contained in:
+426
-24
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta, date
|
||||
import math
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
@@ -10,6 +11,7 @@ from .database import engine, get_db, Base
|
||||
from .models import (
|
||||
Drug,
|
||||
DrugVariant,
|
||||
VariantPack,
|
||||
Dispensing,
|
||||
DispensingAllocation,
|
||||
Location,
|
||||
@@ -114,7 +116,9 @@ class LocationResponse(BaseModel):
|
||||
|
||||
class BatchCreate(BaseModel):
|
||||
batch_number: str
|
||||
quantity: float
|
||||
quantity: Optional[float] = None
|
||||
received_pack_id: Optional[int] = None
|
||||
received_pack_count: Optional[float] = None
|
||||
expiry_date: date
|
||||
location_id: int
|
||||
notes: Optional[str] = None
|
||||
@@ -123,6 +127,8 @@ class BatchCreate(BaseModel):
|
||||
class BatchUpdate(BaseModel):
|
||||
batch_number: Optional[str] = None
|
||||
quantity: Optional[float] = None
|
||||
received_pack_id: Optional[int] = None
|
||||
received_pack_count: Optional[float] = None
|
||||
expiry_date: Optional[date] = None
|
||||
location_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
@@ -133,6 +139,12 @@ class BatchResponse(BaseModel):
|
||||
drug_variant_id: int
|
||||
batch_number: str
|
||||
quantity: float
|
||||
received_pack_id: Optional[int] = None
|
||||
received_pack_label: Optional[str] = None
|
||||
received_pack_count: Optional[float] = None
|
||||
received_pack_size_snapshot: Optional[float] = None
|
||||
current_full_pack_count: Optional[float] = None
|
||||
current_loose_base_units: Optional[float] = None
|
||||
expiry_date: date
|
||||
location_id: int
|
||||
location_name: Optional[str] = None
|
||||
@@ -155,21 +167,51 @@ class DrugVariantCreate(BaseModel):
|
||||
strength: str
|
||||
quantity: float
|
||||
unit: str = "units"
|
||||
base_unit: Optional[str] = None
|
||||
low_stock_threshold: float = 10
|
||||
|
||||
class DrugVariantUpdate(BaseModel):
|
||||
strength: str = None
|
||||
quantity: float = None
|
||||
unit: str = None
|
||||
base_unit: str = None
|
||||
low_stock_threshold: float = None
|
||||
|
||||
|
||||
class VariantPackCreate(BaseModel):
|
||||
label: str
|
||||
pack_unit_name: str
|
||||
pack_size_in_base_units: float
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class VariantPackUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
pack_unit_name: Optional[str] = None
|
||||
pack_size_in_base_units: Optional[float] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class VariantPackResponse(BaseModel):
|
||||
id: int
|
||||
drug_variant_id: int
|
||||
label: str
|
||||
pack_unit_name: str
|
||||
pack_size_in_base_units: float
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DrugVariantResponse(BaseModel):
|
||||
id: int
|
||||
drug_id: int
|
||||
strength: str
|
||||
quantity: float
|
||||
unit: str
|
||||
base_unit: str
|
||||
low_stock_threshold: float
|
||||
packs: List[VariantPackResponse] = []
|
||||
batches: List[BatchResponse] = []
|
||||
|
||||
class Config:
|
||||
@@ -187,8 +229,11 @@ class DrugWithVariantsResponse(BaseModel):
|
||||
|
||||
class DispensingCreate(BaseModel):
|
||||
drug_variant_id: int
|
||||
quantity: float
|
||||
quantity: Optional[float] = None
|
||||
dispense_mode: str = "subunit"
|
||||
batch_id: Optional[int] = None
|
||||
requested_pack_id: Optional[int] = None
|
||||
requested_pack_count: Optional[float] = None
|
||||
allow_split: bool = True
|
||||
animal_name: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
@@ -208,6 +253,9 @@ class DispensingResponse(BaseModel):
|
||||
batch_id: Optional[int] = None
|
||||
actor_user_id: Optional[int] = None
|
||||
quantity: float
|
||||
dispense_mode: str = "subunit"
|
||||
requested_pack_id: Optional[int] = None
|
||||
requested_pack_count: Optional[float] = None
|
||||
animal_name: Optional[str] = None
|
||||
user_name: str
|
||||
notes: Optional[str] = None
|
||||
@@ -276,8 +324,16 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
|
||||
"strength": variant.strength,
|
||||
"quantity": variant.quantity,
|
||||
"unit": variant.unit,
|
||||
"base_unit": variant.unit,
|
||||
"low_stock_threshold": variant.low_stock_threshold,
|
||||
}
|
||||
packs = (
|
||||
db.query(VariantPack)
|
||||
.filter(VariantPack.drug_variant_id == variant.id)
|
||||
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
|
||||
.all()
|
||||
)
|
||||
variant_dict["packs"] = [serialize_variant_pack(pack) for pack in packs]
|
||||
batches = (
|
||||
db.query(Batch)
|
||||
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0)
|
||||
@@ -290,11 +346,20 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
|
||||
|
||||
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
||||
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
||||
pack = None
|
||||
if batch.received_pack_id is not None:
|
||||
pack = db.query(VariantPack).filter(VariantPack.id == batch.received_pack_id).first()
|
||||
return {
|
||||
"id": batch.id,
|
||||
"drug_variant_id": batch.drug_variant_id,
|
||||
"batch_number": batch.batch_number,
|
||||
"quantity": batch.quantity,
|
||||
"received_pack_id": batch.received_pack_id,
|
||||
"received_pack_label": pack.label if pack else None,
|
||||
"received_pack_count": batch.received_pack_count,
|
||||
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||
"current_full_pack_count": batch.current_full_pack_count,
|
||||
"current_loose_base_units": batch.current_loose_base_units,
|
||||
"expiry_date": batch.expiry_date,
|
||||
"location_id": batch.location_id,
|
||||
"location_name": location.name if location else None,
|
||||
@@ -303,6 +368,96 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def resolve_pack_size_snapshot(db: Session, pack_id: Optional[int]) -> Optional[float]:
|
||||
if pack_id is None:
|
||||
return None
|
||||
pack = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
|
||||
if not pack:
|
||||
return None
|
||||
return pack.pack_size_in_base_units
|
||||
|
||||
|
||||
def recompute_batch_pack_state(batch: Batch) -> None:
|
||||
pack_size = batch.received_pack_size_snapshot
|
||||
if pack_size is None or pack_size <= 0 or batch.quantity < 0:
|
||||
batch.current_full_pack_count = None
|
||||
batch.current_loose_base_units = None
|
||||
return
|
||||
|
||||
full_packs = math.floor((batch.quantity + 1e-9) / pack_size)
|
||||
loose_units = batch.quantity - (full_packs * pack_size)
|
||||
if loose_units < 1e-9:
|
||||
loose_units = 0.0
|
||||
|
||||
batch.current_full_pack_count = float(full_packs)
|
||||
batch.current_loose_base_units = loose_units
|
||||
|
||||
|
||||
def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": pack.id,
|
||||
"drug_variant_id": pack.drug_variant_id,
|
||||
"label": pack.label,
|
||||
"pack_unit_name": pack.pack_unit_name,
|
||||
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
||||
"is_active": pack.is_active,
|
||||
}
|
||||
|
||||
|
||||
def resolve_pack_quantity(
|
||||
db: Session,
|
||||
variant_id: int,
|
||||
quantity: Optional[float],
|
||||
pack_id: Optional[int],
|
||||
pack_count: Optional[float],
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve canonical base-unit quantity from either direct quantity or pack input."""
|
||||
if quantity is None and pack_id is None and pack_count is None:
|
||||
raise HTTPException(status_code=400, detail="Either quantity or pack fields must be provided")
|
||||
|
||||
resolved_quantity = quantity
|
||||
resolved_pack: Optional[VariantPack] = None
|
||||
|
||||
if pack_id is not None or pack_count is not None:
|
||||
if pack_id is None or pack_count is None:
|
||||
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required when using pack input")
|
||||
if pack_count <= 0:
|
||||
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
|
||||
|
||||
resolved_pack = (
|
||||
db.query(VariantPack)
|
||||
.filter(
|
||||
VariantPack.id == pack_id,
|
||||
VariantPack.drug_variant_id == variant_id,
|
||||
VariantPack.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if resolved_pack is None:
|
||||
raise HTTPException(status_code=400, detail="Pack not found for variant or is inactive")
|
||||
|
||||
derived_quantity = pack_count * resolved_pack.pack_size_in_base_units
|
||||
if derived_quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Derived quantity from pack must be greater than zero")
|
||||
|
||||
if resolved_quantity is None:
|
||||
resolved_quantity = derived_quantity
|
||||
elif abs(resolved_quantity - derived_quantity) > 1e-6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Quantity does not match pack conversion for selected pack",
|
||||
)
|
||||
|
||||
if resolved_quantity is None or resolved_quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
|
||||
|
||||
return {
|
||||
"quantity": resolved_quantity,
|
||||
"pack_id": resolved_pack.id if resolved_pack else None,
|
||||
"pack_count": pack_count,
|
||||
}
|
||||
|
||||
|
||||
def select_batches_for_dispense(
|
||||
db: Session,
|
||||
variant_id: int,
|
||||
@@ -712,6 +867,7 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User
|
||||
db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False)
|
||||
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
||||
db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||
db.query(VariantPack).filter(VariantPack.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||
db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False)
|
||||
|
||||
write_audit_log(
|
||||
@@ -745,14 +901,31 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug")
|
||||
|
||||
base_unit = (variant.base_unit or variant.unit).strip()
|
||||
if not base_unit:
|
||||
raise HTTPException(status_code=400, detail="Variant unit/base_unit cannot be empty")
|
||||
|
||||
db_variant = DrugVariant(
|
||||
drug_id=drug_id,
|
||||
strength=variant.strength,
|
||||
quantity=variant.quantity,
|
||||
unit=variant.unit,
|
||||
unit=base_unit,
|
||||
low_stock_threshold=variant.low_stock_threshold
|
||||
)
|
||||
db.add(db_variant)
|
||||
db.flush()
|
||||
|
||||
# Ensure each variant has at least one active default 1:1 pack representation.
|
||||
db.add(
|
||||
VariantPack(
|
||||
drug_variant_id=db_variant.id,
|
||||
label=f"1 {base_unit}",
|
||||
pack_unit_name=base_unit,
|
||||
pack_size_in_base_units=1,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="variant.create",
|
||||
@@ -763,7 +936,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
||||
"drug_id": drug_id,
|
||||
"strength": variant.strength,
|
||||
"quantity": variant.quantity,
|
||||
"unit": variant.unit,
|
||||
"unit": base_unit,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
@@ -791,7 +964,15 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
|
||||
"unit": variant.unit,
|
||||
"low_stock_threshold": variant.low_stock_threshold,
|
||||
}
|
||||
for field, value in variant_update.dict(exclude_unset=True).items():
|
||||
payload = variant_update.dict(exclude_unset=True)
|
||||
if "base_unit" in payload and payload["base_unit"] is not None:
|
||||
cleaned_base_unit = payload["base_unit"].strip()
|
||||
if not cleaned_base_unit:
|
||||
raise HTTPException(status_code=400, detail="base_unit cannot be empty")
|
||||
payload["unit"] = cleaned_base_unit
|
||||
payload.pop("base_unit", None)
|
||||
|
||||
for field, value in payload.items():
|
||||
setattr(variant, field, value)
|
||||
|
||||
write_audit_log(
|
||||
@@ -800,7 +981,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
|
||||
entity_type="drug_variant",
|
||||
entity_id=variant.id,
|
||||
actor=current_user,
|
||||
details={"before": before, "after": variant_update.dict(exclude_unset=True)},
|
||||
details={"before": before, "after": payload},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
@@ -820,6 +1001,7 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
|
||||
db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False)
|
||||
|
||||
db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).delete(synchronize_session=False)
|
||||
db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id).delete(synchronize_session=False)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="variant.delete",
|
||||
@@ -833,29 +1015,180 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_
|
||||
|
||||
return {"message": "Drug variant deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/variants/{variant_id}/packs", response_model=List[VariantPackResponse])
|
||||
def list_variant_packs(
|
||||
variant_id: int,
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||
|
||||
query = db.query(VariantPack).filter(VariantPack.drug_variant_id == variant_id)
|
||||
if active_only:
|
||||
query = query.filter(VariantPack.is_active.is_(True))
|
||||
|
||||
packs = query.order_by(VariantPack.is_active.desc(), VariantPack.id.asc()).all()
|
||||
return [serialize_variant_pack(pack) for pack in packs]
|
||||
|
||||
|
||||
@router.post("/variants/{variant_id}/packs", response_model=VariantPackResponse)
|
||||
def create_variant_pack(
|
||||
variant_id: int,
|
||||
payload: VariantPackCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_non_readonly_user),
|
||||
):
|
||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||
|
||||
label = payload.label.strip()
|
||||
pack_unit_name = payload.pack_unit_name.strip()
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
||||
if not pack_unit_name:
|
||||
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||
if payload.pack_size_in_base_units <= 0:
|
||||
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
|
||||
|
||||
row = VariantPack(
|
||||
drug_variant_id=variant_id,
|
||||
label=label,
|
||||
pack_unit_name=pack_unit_name,
|
||||
pack_size_in_base_units=payload.pack_size_in_base_units,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
db.add(row)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="variant_pack.create",
|
||||
entity_type="variant_pack",
|
||||
entity_id=None,
|
||||
actor=current_user,
|
||||
details={
|
||||
"variant_id": variant_id,
|
||||
"label": label,
|
||||
"pack_unit_name": pack_unit_name,
|
||||
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
||||
"is_active": payload.is_active,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return serialize_variant_pack(row)
|
||||
|
||||
|
||||
@router.put("/variant-packs/{pack_id}", response_model=VariantPackResponse)
|
||||
def update_variant_pack(
|
||||
pack_id: int,
|
||||
payload: VariantPackUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_non_readonly_user),
|
||||
):
|
||||
row = db.query(VariantPack).filter(VariantPack.id == pack_id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Variant pack not found")
|
||||
|
||||
before = serialize_variant_pack(row)
|
||||
|
||||
if payload.label is not None:
|
||||
cleaned = payload.label.strip()
|
||||
if not cleaned:
|
||||
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
||||
row.label = cleaned
|
||||
|
||||
if payload.pack_unit_name is not None:
|
||||
cleaned = payload.pack_unit_name.strip()
|
||||
if not cleaned:
|
||||
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||
row.pack_unit_name = cleaned
|
||||
|
||||
if payload.pack_size_in_base_units is not None:
|
||||
if payload.pack_size_in_base_units <= 0:
|
||||
raise HTTPException(status_code=400, detail="Pack size in base units must be greater than zero")
|
||||
row.pack_size_in_base_units = payload.pack_size_in_base_units
|
||||
|
||||
if payload.is_active is not None and payload.is_active is False:
|
||||
# Keep at least one active pack per variant to preserve usable receive/dispense UX.
|
||||
active_count = db.query(VariantPack).filter(
|
||||
VariantPack.drug_variant_id == row.drug_variant_id,
|
||||
VariantPack.is_active.is_(True),
|
||||
VariantPack.id != row.id,
|
||||
).count()
|
||||
if active_count == 0:
|
||||
raise HTTPException(status_code=400, detail="At least one active pack must remain for this variant")
|
||||
row.is_active = False
|
||||
elif payload.is_active is not None and payload.is_active is True:
|
||||
row.is_active = True
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="variant_pack.update",
|
||||
entity_type="variant_pack",
|
||||
entity_id=pack_id,
|
||||
actor=current_user,
|
||||
details={"before": before, "after": payload.dict(exclude_unset=True)},
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return serialize_variant_pack(row)
|
||||
|
||||
# Dispensing endpoints
|
||||
@router.post("/dispense", response_model=DispensingResponse)
|
||||
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
|
||||
"""Record a drug dispensing and reduce inventory"""
|
||||
if dispensing.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Quantity must be greater than zero")
|
||||
|
||||
# Check if drug variant exists
|
||||
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||
|
||||
dispense_mode = (dispensing.dispense_mode or "subunit").strip().lower()
|
||||
if dispense_mode not in {"subunit", "pack"}:
|
||||
raise HTTPException(status_code=400, detail="dispense_mode must be either 'subunit' or 'pack'")
|
||||
|
||||
if dispense_mode == "pack":
|
||||
if dispensing.requested_pack_id is None or dispensing.requested_pack_count is None:
|
||||
raise HTTPException(status_code=400, detail="Pack dispense requires requested_pack_id and requested_pack_count")
|
||||
if dispensing.requested_pack_count <= 0:
|
||||
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
|
||||
if abs(dispensing.requested_pack_count - round(dispensing.requested_pack_count)) > 1e-6:
|
||||
raise HTTPException(status_code=400, detail="Whole-pack dispense requires an integer pack count")
|
||||
|
||||
resolved = resolve_pack_quantity(
|
||||
db,
|
||||
variant_id=variant.id,
|
||||
quantity=None,
|
||||
pack_id=dispensing.requested_pack_id,
|
||||
pack_count=dispensing.requested_pack_count,
|
||||
)
|
||||
else:
|
||||
if dispensing.quantity is None or dispensing.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Subunit dispense requires quantity > 0")
|
||||
resolved = resolve_pack_quantity(
|
||||
db,
|
||||
variant_id=variant.id,
|
||||
quantity=dispensing.quantity,
|
||||
pack_id=None,
|
||||
pack_count=None,
|
||||
)
|
||||
|
||||
dispense_qty = resolved["quantity"]
|
||||
|
||||
# Check if enough total quantity available from active stock (legacy and batch-based remain in sync).
|
||||
if variant.quantity < dispensing.quantity:
|
||||
if variant.quantity < dispense_qty:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}",
|
||||
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispense_qty}",
|
||||
)
|
||||
|
||||
allocations = select_batches_for_dispense(
|
||||
db,
|
||||
variant_id=variant.id,
|
||||
requested_quantity=dispensing.quantity,
|
||||
requested_quantity=dispense_qty,
|
||||
preferred_batch_id=dispensing.batch_id,
|
||||
allow_split=dispensing.allow_split,
|
||||
)
|
||||
@@ -867,7 +1200,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
||||
drug_variant_id=dispensing.drug_variant_id,
|
||||
batch_id=primary_batch_id,
|
||||
actor_user_id=current_user.id,
|
||||
quantity=dispensing.quantity,
|
||||
quantity=dispense_qty,
|
||||
dispense_mode=dispense_mode,
|
||||
requested_pack_id=resolved["pack_id"],
|
||||
requested_pack_count=resolved["pack_count"],
|
||||
animal_name=dispensing.animal_name,
|
||||
user_name=user_name,
|
||||
notes=dispensing.notes,
|
||||
@@ -880,11 +1216,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
||||
batch = allocation["batch"]
|
||||
qty = allocation["quantity"]
|
||||
batch.quantity -= qty
|
||||
recompute_batch_pack_state(batch)
|
||||
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
|
||||
db.add(DispensingAllocation(dispensing_id=db_dispensing.id, batch_id=batch.id, quantity=qty))
|
||||
|
||||
# Keep legacy variant quantity field in sync for existing frontend flows.
|
||||
variant.quantity -= dispensing.quantity
|
||||
variant.quantity -= dispense_qty
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
@@ -894,7 +1231,10 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
||||
actor=current_user,
|
||||
details={
|
||||
"drug_variant_id": dispensing.drug_variant_id,
|
||||
"requested_quantity": dispensing.quantity,
|
||||
"requested_quantity": dispense_qty,
|
||||
"dispense_mode": dispense_mode,
|
||||
"requested_pack_id": resolved["pack_id"],
|
||||
"requested_pack_count": resolved["pack_count"],
|
||||
"allocations": allocation_payload,
|
||||
"animal_name": dispensing.animal_name,
|
||||
"notes": dispensing.notes,
|
||||
@@ -909,6 +1249,9 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
||||
"batch_id": db_dispensing.batch_id,
|
||||
"actor_user_id": db_dispensing.actor_user_id,
|
||||
"quantity": db_dispensing.quantity,
|
||||
"dispense_mode": db_dispensing.dispense_mode,
|
||||
"requested_pack_id": db_dispensing.requested_pack_id,
|
||||
"requested_pack_count": db_dispensing.requested_pack_count,
|
||||
"animal_name": db_dispensing.animal_name,
|
||||
"user_name": db_dispensing.user_name,
|
||||
"notes": db_dispensing.notes,
|
||||
@@ -930,6 +1273,9 @@ def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_
|
||||
"batch_id": item.batch_id,
|
||||
"actor_user_id": item.actor_user_id,
|
||||
"quantity": item.quantity,
|
||||
"dispense_mode": item.dispense_mode,
|
||||
"requested_pack_id": item.requested_pack_id,
|
||||
"requested_pack_count": item.requested_pack_count,
|
||||
"animal_name": item.animal_name,
|
||||
"user_name": item.user_name,
|
||||
"notes": item.notes,
|
||||
@@ -961,6 +1307,9 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us
|
||||
"batch_id": item.batch_id,
|
||||
"actor_user_id": item.actor_user_id,
|
||||
"quantity": item.quantity,
|
||||
"dispense_mode": item.dispense_mode,
|
||||
"requested_pack_id": item.requested_pack_id,
|
||||
"requested_pack_count": item.requested_pack_count,
|
||||
"animal_name": item.animal_name,
|
||||
"user_name": item.user_name,
|
||||
"notes": item.notes,
|
||||
@@ -989,6 +1338,9 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
|
||||
"batch_id": item.batch_id,
|
||||
"actor_user_id": item.actor_user_id,
|
||||
"quantity": item.quantity,
|
||||
"dispense_mode": item.dispense_mode,
|
||||
"requested_pack_id": item.requested_pack_id,
|
||||
"requested_pack_count": item.requested_pack_count,
|
||||
"animal_name": item.animal_name,
|
||||
"user_name": item.user_name,
|
||||
"notes": item.notes,
|
||||
@@ -1090,8 +1442,15 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||
if payload.quantity <= 0:
|
||||
raise HTTPException(status_code=400, detail="Batch quantity must be greater than zero")
|
||||
|
||||
resolved = resolve_pack_quantity(
|
||||
db,
|
||||
variant_id=variant_id,
|
||||
quantity=payload.quantity,
|
||||
pack_id=payload.received_pack_id,
|
||||
pack_count=payload.received_pack_count,
|
||||
)
|
||||
batch_quantity = resolved["quantity"]
|
||||
|
||||
location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first()
|
||||
if not location:
|
||||
@@ -1112,14 +1471,18 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
||||
row = Batch(
|
||||
drug_variant_id=variant_id,
|
||||
batch_number=batch_number,
|
||||
quantity=payload.quantity,
|
||||
quantity=batch_quantity,
|
||||
received_pack_id=resolved["pack_id"],
|
||||
received_pack_count=resolved["pack_count"],
|
||||
received_pack_size_snapshot=resolve_pack_size_snapshot(db, resolved["pack_id"]),
|
||||
expiry_date=payload.expiry_date,
|
||||
location_id=payload.location_id,
|
||||
notes=payload.notes,
|
||||
)
|
||||
recompute_batch_pack_state(row)
|
||||
db.add(row)
|
||||
|
||||
variant.quantity += payload.quantity
|
||||
variant.quantity += batch_quantity
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
@@ -1130,7 +1493,10 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
||||
details={
|
||||
"variant_id": variant_id,
|
||||
"batch_number": batch_number,
|
||||
"quantity": payload.quantity,
|
||||
"quantity": batch_quantity,
|
||||
"received_pack_id": resolved["pack_id"],
|
||||
"received_pack_count": resolved["pack_count"],
|
||||
"received_pack_size_snapshot": row.received_pack_size_snapshot,
|
||||
"expiry_date": payload.expiry_date,
|
||||
"location_id": payload.location_id,
|
||||
},
|
||||
@@ -1153,6 +1519,11 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
||||
before = {
|
||||
"batch_number": batch.batch_number,
|
||||
"quantity": batch.quantity,
|
||||
"received_pack_id": batch.received_pack_id,
|
||||
"received_pack_count": batch.received_pack_count,
|
||||
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||
"current_full_pack_count": batch.current_full_pack_count,
|
||||
"current_loose_base_units": batch.current_loose_base_units,
|
||||
"expiry_date": batch.expiry_date,
|
||||
"location_id": batch.location_id,
|
||||
"notes": batch.notes,
|
||||
@@ -1187,14 +1558,42 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
|
||||
if payload.notes is not None:
|
||||
batch.notes = payload.notes
|
||||
|
||||
if payload.quantity is not None:
|
||||
if payload.quantity < 0:
|
||||
if payload.received_pack_id is not None or payload.received_pack_count is not None or payload.quantity is not None:
|
||||
if payload.quantity is not None and payload.quantity < 0:
|
||||
raise HTTPException(status_code=400, detail="Batch quantity cannot be negative")
|
||||
delta = payload.quantity - batch.quantity
|
||||
|
||||
if payload.received_pack_id is None and payload.received_pack_count is None:
|
||||
if payload.quantity is None:
|
||||
raise HTTPException(status_code=400, detail="Batch quantity cannot be empty")
|
||||
resolved_quantity = payload.quantity
|
||||
resolved_pack_id = batch.received_pack_id
|
||||
resolved_pack_count = batch.received_pack_count
|
||||
else:
|
||||
target_pack_id = payload.received_pack_id if payload.received_pack_id is not None else batch.received_pack_id
|
||||
target_pack_count = payload.received_pack_count if payload.received_pack_count is not None else batch.received_pack_count
|
||||
if target_pack_id is None or target_pack_count is None:
|
||||
raise HTTPException(status_code=400, detail="Both pack_id and pack_count are required for pack-based updates")
|
||||
|
||||
resolved = resolve_pack_quantity(
|
||||
db,
|
||||
variant_id=batch.drug_variant_id,
|
||||
quantity=payload.quantity,
|
||||
pack_id=target_pack_id,
|
||||
pack_count=target_pack_count,
|
||||
)
|
||||
resolved_quantity = resolved["quantity"]
|
||||
resolved_pack_id = resolved["pack_id"]
|
||||
resolved_pack_count = resolved["pack_count"]
|
||||
|
||||
delta = resolved_quantity - batch.quantity
|
||||
projected_variant_qty = variant.quantity + delta
|
||||
if projected_variant_qty < 0:
|
||||
raise HTTPException(status_code=400, detail="Variant quantity cannot become negative")
|
||||
batch.quantity = payload.quantity
|
||||
batch.quantity = resolved_quantity
|
||||
batch.received_pack_id = resolved_pack_id
|
||||
batch.received_pack_count = resolved_pack_count
|
||||
batch.received_pack_size_snapshot = resolve_pack_size_snapshot(db, resolved_pack_id)
|
||||
recompute_batch_pack_state(batch)
|
||||
variant.quantity = projected_variant_qty
|
||||
|
||||
write_audit_log(
|
||||
@@ -1276,6 +1675,9 @@ def report_controlled_movement(
|
||||
"drug_name": drug.name,
|
||||
"strength": variant.strength,
|
||||
"quantity": d.quantity,
|
||||
"dispense_mode": d.dispense_mode,
|
||||
"requested_pack_id": d.requested_pack_id,
|
||||
"requested_pack_count": d.requested_pack_count,
|
||||
"user_name": d.user_name,
|
||||
"animal_name": d.animal_name,
|
||||
"batch_id": d.batch_id,
|
||||
|
||||
@@ -56,6 +56,25 @@ def migrate_compliance_schema() -> None:
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
if _table_exists(cursor, "drug_variants") and not _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE variant_packs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
drug_variant_id INTEGER NOT NULL,
|
||||
label VARCHAR NOT NULL,
|
||||
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
|
||||
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(drug_variant_id) REFERENCES drug_variants(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_variant_packs_drug_variant_id ON variant_packs(drug_variant_id)")
|
||||
print("Created variant_packs table")
|
||||
|
||||
if _table_exists(cursor, "drugs") and not _column_exists(cursor, "drugs", "is_controlled"):
|
||||
cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0")
|
||||
print("Added drugs.is_controlled")
|
||||
@@ -68,6 +87,133 @@ def migrate_compliance_schema() -> None:
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER")
|
||||
print("Added dispensings.actor_user_id")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_id"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_id INTEGER")
|
||||
print("Added batches.received_pack_id")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_count"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_count FLOAT")
|
||||
print("Added batches.received_pack_count")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_size_snapshot"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_size_snapshot FLOAT")
|
||||
print("Added batches.received_pack_size_snapshot")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_full_pack_count"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN current_full_pack_count FLOAT")
|
||||
print("Added batches.current_full_pack_count")
|
||||
|
||||
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_loose_base_units"):
|
||||
cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT")
|
||||
print("Added batches.current_loose_base_units")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_id"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_id INTEGER")
|
||||
print("Added dispensings.requested_pack_id")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_count"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_count FLOAT")
|
||||
print("Added dispensings.requested_pack_count")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "dispense_mode"):
|
||||
cursor.execute("ALTER TABLE dispensings ADD COLUMN dispense_mode VARCHAR NOT NULL DEFAULT 'subunit'")
|
||||
print("Added dispensings.dispense_mode")
|
||||
|
||||
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO variant_packs (drug_variant_id, label, pack_unit_name, pack_size_in_base_units, is_active)
|
||||
SELECT v.id,
|
||||
'1 ' || COALESCE(NULLIF(v.unit, ''), 'unit'),
|
||||
COALESCE(NULLIF(v.unit, ''), 'unit'),
|
||||
1,
|
||||
1
|
||||
FROM drug_variants v
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = v.id
|
||||
)
|
||||
"""
|
||||
)
|
||||
print("Ensured default pack rows for variants")
|
||||
|
||||
if _table_exists(cursor, "batches") and _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET received_pack_id = (
|
||||
SELECT p.id
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = batches.drug_variant_id
|
||||
ORDER BY p.id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
received_pack_count = quantity
|
||||
WHERE received_pack_id IS NULL
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches pack context where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET received_pack_size_snapshot = (
|
||||
SELECT p.pack_size_in_base_units
|
||||
FROM variant_packs p
|
||||
WHERE p.id = batches.received_pack_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE received_pack_id IS NOT NULL
|
||||
AND (received_pack_size_snapshot IS NULL OR received_pack_size_snapshot <= 0)
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches pack size snapshot where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE batches
|
||||
SET current_full_pack_count = CASE
|
||||
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN CAST(quantity / received_pack_size_snapshot AS INTEGER)
|
||||
ELSE NULL
|
||||
END,
|
||||
current_loose_base_units = CASE
|
||||
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN quantity - (CAST(quantity / received_pack_size_snapshot AS INTEGER) * received_pack_size_snapshot)
|
||||
ELSE NULL
|
||||
END
|
||||
"""
|
||||
)
|
||||
print("Backfilled batches live pack state")
|
||||
|
||||
if _table_exists(cursor, "dispensings") and _table_exists(cursor, "variant_packs"):
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE dispensings
|
||||
SET requested_pack_id = (
|
||||
SELECT p.id
|
||||
FROM variant_packs p
|
||||
WHERE p.drug_variant_id = dispensings.drug_variant_id
|
||||
ORDER BY p.id ASC
|
||||
LIMIT 1
|
||||
),
|
||||
requested_pack_count = quantity
|
||||
WHERE requested_pack_id IS NULL
|
||||
"""
|
||||
)
|
||||
print("Backfilled dispensing pack context where missing")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE dispensings
|
||||
SET dispense_mode = CASE
|
||||
WHEN requested_pack_id IS NOT NULL AND requested_pack_count IS NOT NULL THEN 'pack'
|
||||
ELSE 'subunit'
|
||||
END
|
||||
WHERE dispense_mode IS NULL OR TRIM(dispense_mode) = ''
|
||||
"""
|
||||
)
|
||||
print("Backfilled dispensing mode where missing")
|
||||
|
||||
# Seed default locations once table exists (created via SQLAlchemy create_all).
|
||||
if _table_exists(cursor, "locations"):
|
||||
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
|
||||
|
||||
@@ -36,6 +36,19 @@ class DrugVariant(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
class VariantPack(Base):
|
||||
__tablename__ = "variant_packs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||
label = Column(String, nullable=False)
|
||||
pack_unit_name = Column(String, nullable=False, default="pack")
|
||||
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
class Dispensing(Base):
|
||||
__tablename__ = "dispensings"
|
||||
|
||||
@@ -44,6 +57,9 @@ class Dispensing(Base):
|
||||
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
|
||||
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
quantity = Column(Float, nullable=False)
|
||||
dispense_mode = Column(String, nullable=False, default="subunit")
|
||||
requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
|
||||
requested_pack_count = Column(Float, nullable=True)
|
||||
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
|
||||
user_name = Column(String, nullable=False) # User who dispensed
|
||||
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
@@ -67,6 +83,11 @@ class Batch(Base):
|
||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||
batch_number = Column(String, nullable=False, index=True)
|
||||
quantity = Column(Float, nullable=False, default=0)
|
||||
received_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
|
||||
received_pack_count = Column(Float, nullable=True)
|
||||
received_pack_size_snapshot = Column(Float, nullable=True)
|
||||
current_full_pack_count = Column(Float, nullable=True)
|
||||
current_loose_base_units = Column(Float, nullable=True)
|
||||
expiry_date = Column(Date, 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)
|
||||
|
||||
+816
-8
@@ -7,6 +7,10 @@ let searchTerm = '';
|
||||
let expandedDrugs = new Set();
|
||||
let currentUser = null;
|
||||
let accessToken = null;
|
||||
let deliveryDrugId = null;
|
||||
let deliveryLineCounter = 0;
|
||||
let deliveryLocations = [];
|
||||
let activeVariantPacksVariantId = null;
|
||||
|
||||
// Toast notification system
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
@@ -212,6 +216,7 @@ function setupEventListeners() {
|
||||
const editModal = document.getElementById('editModal');
|
||||
const printNotesModal = document.getElementById('printNotesModal');
|
||||
const batchReceiveModal = document.getElementById('batchReceiveModal');
|
||||
const receiveDeliveryModal = document.getElementById('receiveDeliveryModal');
|
||||
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||
const dispenseBtn = document.getElementById('dispenseBtn');
|
||||
const printNotesBtn = document.getElementById('printNotesBtn');
|
||||
@@ -222,6 +227,15 @@ function setupEventListeners() {
|
||||
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
|
||||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||||
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
|
||||
const cancelReceiveDeliveryBtn = document.getElementById('cancelReceiveDeliveryBtn');
|
||||
const addDeliveryLineBtn = document.getElementById('addDeliveryLineBtn');
|
||||
const addVariantFromDeliveryBtn = document.getElementById('addVariantFromDeliveryBtn');
|
||||
const addVariantPackRowBtn = document.getElementById('addVariantPackRowBtn');
|
||||
const variantUnitSelect = document.getElementById('variantUnit');
|
||||
const variantStrengthInput = document.getElementById('variantStrength');
|
||||
const dispenseModeSelect = document.getElementById('dispenseMode');
|
||||
const variantPacksForm = document.getElementById('variantPacksForm');
|
||||
const closeVariantPacksBtn = document.getElementById('closeVariantPacksBtn');
|
||||
const showAllBtn = document.getElementById('showAllBtn');
|
||||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||||
const locationFilterSelect = document.getElementById('locationFilterSelect');
|
||||
@@ -247,10 +261,30 @@ function setupEventListeners() {
|
||||
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
|
||||
if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal));
|
||||
|
||||
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
|
||||
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
|
||||
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => closeModal(receiveDeliveryModal));
|
||||
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
|
||||
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
|
||||
if (addVariantPackRowBtn) addVariantPackRowBtn.addEventListener('click', () => appendVariantPackRow());
|
||||
if (variantUnitSelect) {
|
||||
variantUnitSelect.addEventListener('change', () => {
|
||||
refreshVariantPackRowLabels();
|
||||
});
|
||||
}
|
||||
if (variantStrengthInput && variantUnitSelect) {
|
||||
variantStrengthInput.addEventListener('blur', () => {
|
||||
variantUnitSelect.value = inferBaseUnitFromStrength(variantStrengthInput.value);
|
||||
refreshVariantPackRowLabels();
|
||||
});
|
||||
}
|
||||
if (dispenseModeSelect) dispenseModeSelect.addEventListener('change', updateDispenseModeUi);
|
||||
|
||||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||||
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||||
updateDispenseDrugSelect();
|
||||
updateDispenseModeUi();
|
||||
openModal(dispenseModal);
|
||||
});
|
||||
|
||||
@@ -273,6 +307,9 @@ function setupEventListeners() {
|
||||
const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn');
|
||||
if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal')));
|
||||
|
||||
if (variantPacksForm) variantPacksForm.addEventListener('submit', handleCreateVariantPack);
|
||||
if (closeVariantPacksBtn) closeVariantPacksBtn.addEventListener('click', () => closeModal(document.getElementById('variantPacksModal')));
|
||||
|
||||
const createLocationForm = document.getElementById('createLocationForm');
|
||||
if (createLocationForm) createLocationForm.addEventListener('submit', createLocation);
|
||||
|
||||
@@ -333,6 +370,28 @@ function setupEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
const dispenseQuantityInput = document.getElementById('dispenseQuantity');
|
||||
if (dispenseQuantityInput) {
|
||||
dispenseQuantityInput.addEventListener('input', () => {
|
||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
||||
if (mode !== 'subunit') {
|
||||
return;
|
||||
}
|
||||
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
const packCount = document.getElementById('dispensePackCount');
|
||||
const packPreview = document.getElementById('dispensePackPreview');
|
||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||||
const variant = getVariantById(variantId);
|
||||
|
||||
if (packSelect) packSelect.value = '';
|
||||
if (packCount) packCount.value = '';
|
||||
if (packPreview && variant) {
|
||||
packPreview.textContent = `Enter direct quantity in ${variant.unit}.`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
@@ -398,6 +457,108 @@ function updateDispenseDrugSelect() {
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
const packCount = document.getElementById('dispensePackCount');
|
||||
const packPreview = document.getElementById('dispensePackPreview');
|
||||
const modeSelect = document.getElementById('dispenseMode');
|
||||
if (packSelect) {
|
||||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
}
|
||||
if (packCount) {
|
||||
packCount.value = '';
|
||||
}
|
||||
if (modeSelect) {
|
||||
modeSelect.value = 'subunit';
|
||||
}
|
||||
if (packPreview) {
|
||||
packPreview.textContent = 'Select a pack and whole-number count.';
|
||||
}
|
||||
|
||||
updateDispenseModeUi();
|
||||
}
|
||||
|
||||
function populateDispensePackSelect(variant) {
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
const packCount = document.getElementById('dispensePackCount');
|
||||
const packPreview = document.getElementById('dispensePackPreview');
|
||||
if (!packSelect) return;
|
||||
|
||||
const activePacks = getActivePacksForVariant(variant);
|
||||
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
|
||||
activePacks.forEach(pack => {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(pack.id);
|
||||
option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||||
packSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (packCount) packCount.value = '';
|
||||
if (packPreview) {
|
||||
packPreview.textContent = activePacks.length > 0
|
||||
? `Select a pack and whole-number count (${variant.unit} base unit).`
|
||||
: `No active packs for this variant.`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDispenseModeUi() {
|
||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
||||
const quantityGroup = document.getElementById('dispenseQuantityGroup');
|
||||
const packRow = document.getElementById('dispensePackRow');
|
||||
const quantityInput = document.getElementById('dispenseQuantity');
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
const packCount = document.getElementById('dispensePackCount');
|
||||
|
||||
if (quantityGroup) {
|
||||
quantityGroup.style.display = mode === 'subunit' ? '' : 'none';
|
||||
}
|
||||
if (packRow) {
|
||||
packRow.style.display = mode === 'pack' ? '' : 'none';
|
||||
}
|
||||
|
||||
if (quantityInput) {
|
||||
quantityInput.required = mode === 'subunit';
|
||||
}
|
||||
if (packSelect) {
|
||||
packSelect.required = mode === 'pack';
|
||||
}
|
||||
if (packCount) {
|
||||
packCount.required = mode === 'pack';
|
||||
}
|
||||
|
||||
updateAllocationPreview();
|
||||
}
|
||||
|
||||
function updateDispenseQuantityFromPack() {
|
||||
const mode = document.getElementById('dispenseMode')?.value || 'subunit';
|
||||
if (mode !== 'pack') return;
|
||||
|
||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect')?.value || '', 10);
|
||||
const packId = parseInt(document.getElementById('dispensePackSelect')?.value || '', 10);
|
||||
const packCount = parseFloat(document.getElementById('dispensePackCount')?.value || '');
|
||||
const quantityInput = document.getElementById('dispenseQuantity');
|
||||
const preview = document.getElementById('dispensePackPreview');
|
||||
|
||||
const variant = getVariantById(variantId);
|
||||
if (!quantityInput || !preview || !variant) return;
|
||||
|
||||
const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
|
||||
if (selectedPack && !Number.isNaN(packCount) && packCount > 0) {
|
||||
if (Math.abs(packCount - Math.round(packCount)) > 1e-6) {
|
||||
preview.textContent = 'Whole-pack mode requires a whole-number pack count.';
|
||||
return;
|
||||
}
|
||||
const quantity = packCount * selectedPack.pack_size_in_base_units;
|
||||
quantityInput.value = String(quantity);
|
||||
preview.textContent = `${packCount} × ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit}`;
|
||||
updateAllocationPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
preview.textContent = selectedPack
|
||||
? `1 ${selectedPack.pack_unit_name} = ${selectedPack.pack_size_in_base_units} ${variant.unit}`
|
||||
: `Select a pack to calculate quantity.`;
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
@@ -455,6 +616,8 @@ function updateLocationFilterOptions() {
|
||||
|
||||
function populateDispenseBatchSelect(activeBatches) {
|
||||
const batchSelect = document.getElementById('dispenseBatchSelect');
|
||||
const selectedVariantId = parseInt(document.getElementById('dispenseDrugSelect').value || '', 10);
|
||||
const unitLabel = getVariantById(selectedVariantId)?.unit || 'units';
|
||||
const previousValue = batchSelect.value;
|
||||
|
||||
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
|
||||
@@ -465,7 +628,7 @@ function populateDispenseBatchSelect(activeBatches) {
|
||||
const locationLabel = getBatchLocationLabel(batch);
|
||||
const fefoLabel = index === 0 ? ' [FEFO default]' : '';
|
||||
option.value = batch.id;
|
||||
option.textContent = `${batch.batch_number} | ${batch.quantity} units | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`;
|
||||
option.textContent = `${batch.batch_number} | ${batch.quantity} ${unitLabel} | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`;
|
||||
batchSelect.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -484,9 +647,17 @@ async function updateBatchInfo() {
|
||||
if (!variantId) {
|
||||
batchInfoSection.style.display = 'none';
|
||||
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
|
||||
const packSelect = document.getElementById('dispensePackSelect');
|
||||
if (packSelect) packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = getVariantById(variantId);
|
||||
if (variant) {
|
||||
populateDispensePackSelect(variant);
|
||||
}
|
||||
updateDispenseModeUi();
|
||||
|
||||
batchInfoSection.style.display = 'block';
|
||||
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
|
||||
|
||||
@@ -558,6 +729,7 @@ async function updateBatchInfo() {
|
||||
// Update allocation preview based on quantity and allow_split flag
|
||||
async function updateAllocationPreview() {
|
||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||||
const unitLabel = getVariantById(variantId)?.unit || 'units';
|
||||
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||||
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
|
||||
const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value);
|
||||
@@ -626,10 +798,10 @@ async function updateAllocationPreview() {
|
||||
|
||||
if (remainingQty > 0 && allowSplit) {
|
||||
allocationPreviewContent.innerHTML = `
|
||||
<p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} units available across all batches (${remainingQty} short)</p>
|
||||
<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} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
|
||||
<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>
|
||||
`;
|
||||
@@ -638,7 +810,7 @@ async function updateAllocationPreview() {
|
||||
|
||||
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} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
|
||||
<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('');
|
||||
|
||||
@@ -725,7 +897,7 @@ function renderDrugs() {
|
||||
</div>
|
||||
<div class="variant-actions">
|
||||
${!isReadOnly ? `
|
||||
<button class="btn btn-success btn-small" onclick="openBatchReceiveModal(${variant.id})">📦 Receive Batch</button>
|
||||
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); openVariantPacksModal(${variant.id})">📦 Packs</button>
|
||||
<button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button>
|
||||
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
|
||||
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
|
||||
@@ -753,6 +925,7 @@ function renderDrugs() {
|
||||
</div>
|
||||
<div class="drug-actions">
|
||||
${!isReadOnly ? `
|
||||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
|
||||
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})">➕ Add</button>
|
||||
` : ''}
|
||||
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
|
||||
@@ -825,13 +998,45 @@ async function handleDispenseDrug(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||||
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||||
let quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||||
const dispenseMode = (document.getElementById('dispenseMode').value || 'subunit').toLowerCase();
|
||||
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
|
||||
const requestedPackIdValue = document.getElementById('dispensePackSelect').value;
|
||||
const requestedPackCountValue = document.getElementById('dispensePackCount').value;
|
||||
const animalName = document.getElementById('dispenseAnimal').value;
|
||||
const userName = document.getElementById('dispenseUser').value;
|
||||
const notes = document.getElementById('dispenseNotes').value;
|
||||
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
|
||||
|
||||
const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
|
||||
const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
|
||||
const variant = getVariantById(variantId);
|
||||
const selectedPack = variant && selectedPackId
|
||||
? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
|
||||
: null;
|
||||
|
||||
if (!['subunit', 'pack'].includes(dispenseMode)) {
|
||||
showToast('Please select a valid dispense mode.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispenseMode === 'pack') {
|
||||
if (!selectedPack) {
|
||||
showToast('Please select a pack type for whole-pack dispensing.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (selectedPackCount == null || Number.isNaN(selectedPackCount) || selectedPackCount <= 0) {
|
||||
showToast('Please enter a valid pack count greater than zero.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (Math.abs(selectedPackCount - Math.round(selectedPackCount)) > 1e-6) {
|
||||
showToast('Whole-pack dispensing requires a whole-number pack count.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
|
||||
}
|
||||
|
||||
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
|
||||
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
|
||||
return;
|
||||
@@ -840,7 +1045,10 @@ async function handleDispenseDrug(e) {
|
||||
const dispensingData = {
|
||||
drug_variant_id: variantId,
|
||||
quantity: quantity,
|
||||
dispense_mode: dispenseMode,
|
||||
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
|
||||
requested_pack_id: dispenseMode === 'pack' ? selectedPackId : null,
|
||||
requested_pack_count: dispenseMode === 'pack' ? selectedPackCount : null,
|
||||
animal_name: animalName || null,
|
||||
user_name: userName,
|
||||
notes: notes || null,
|
||||
@@ -903,19 +1111,143 @@ function openAddVariantModal(drugId) {
|
||||
if (!drug) return;
|
||||
|
||||
currentDrug = drug;
|
||||
const form = document.getElementById('variantForm');
|
||||
if (form) form.reset();
|
||||
document.getElementById('variantDrugId').value = drug.id;
|
||||
initializeVariantPackRows();
|
||||
document.getElementById('addVariantModal').classList.add('show');
|
||||
}
|
||||
|
||||
function inferBaseUnitFromStrength(strength) {
|
||||
const value = String(strength || '').toLowerCase();
|
||||
if (value.includes('/ml') || value.includes('ml')) return 'ml';
|
||||
if (value.includes('tablet')) return 'tablets';
|
||||
if (value.includes('capsule')) return 'capsules';
|
||||
return 'units';
|
||||
}
|
||||
|
||||
function getVariantPackRowsContainer() {
|
||||
return document.getElementById('variantPackRows');
|
||||
}
|
||||
|
||||
function refreshVariantPackRowLabels() {
|
||||
const container = getVariantPackRowsContainer();
|
||||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||||
if (!container) return;
|
||||
|
||||
container.querySelectorAll('.variant-pack-row').forEach(row => {
|
||||
const packUnit = row.querySelector('.variant-pack-unit')?.value || 'pack';
|
||||
const label = row.querySelector('.variant-pack-size-label');
|
||||
if (!label) return;
|
||||
const titleCasePack = packUnit.charAt(0).toUpperCase() + packUnit.slice(1);
|
||||
label.textContent = `${titleCasePack} Size (${baseUnit}) *`;
|
||||
});
|
||||
}
|
||||
|
||||
function appendVariantPackRow(prefill = {}) {
|
||||
const container = getVariantPackRowsContainer();
|
||||
if (!container) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'delivery-line variant-pack-row';
|
||||
|
||||
const selectedPackUnit = prefill.packUnit || 'bottle';
|
||||
const selectedSize = prefill.packSize || '';
|
||||
const baseUnit = document.getElementById('variantUnit')?.value || 'units';
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="delivery-line-grid" style="grid-template-columns: 1.2fr 1.2fr auto;">
|
||||
<div class="form-group">
|
||||
<label>Pack Type *</label>
|
||||
<select class="variant-pack-unit" required>
|
||||
<option value="bottle" ${selectedPackUnit === 'bottle' ? 'selected' : ''}>Bottle</option>
|
||||
<option value="box" ${selectedPackUnit === 'box' ? 'selected' : ''}>Box</option>
|
||||
<option value="vial" ${selectedPackUnit === 'vial' ? 'selected' : ''}>Vial</option>
|
||||
<option value="packet" ${selectedPackUnit === 'packet' ? 'selected' : ''}>Packet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="variant-pack-size-label">Bottle Size (${baseUnit}) *</label>
|
||||
<input type="number" class="variant-pack-size" min="0.0001" step="0.0001" value="${selectedSize}" required>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-small variant-pack-remove-btn">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const removeBtn = row.querySelector('.variant-pack-remove-btn');
|
||||
const unitSelect = row.querySelector('.variant-pack-unit');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
if (container.querySelectorAll('.variant-pack-row').length <= 1) {
|
||||
showToast('At least one pack size is required', 'warning');
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
if (unitSelect) {
|
||||
unitSelect.addEventListener('change', refreshVariantPackRowLabels);
|
||||
}
|
||||
|
||||
container.appendChild(row);
|
||||
refreshVariantPackRowLabels();
|
||||
}
|
||||
|
||||
function initializeVariantPackRows() {
|
||||
const container = getVariantPackRowsContainer();
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const strengthValue = document.getElementById('variantStrength')?.value || '';
|
||||
const inferredBaseUnit = inferBaseUnitFromStrength(strengthValue);
|
||||
const variantUnitSelect = document.getElementById('variantUnit');
|
||||
if (variantUnitSelect) {
|
||||
variantUnitSelect.value = inferredBaseUnit;
|
||||
}
|
||||
|
||||
appendVariantPackRow({ packUnit: 'bottle' });
|
||||
}
|
||||
|
||||
// Handle add variant form
|
||||
async function handleAddVariant(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const drugId = parseInt(document.getElementById('variantDrugId').value);
|
||||
const baseUnit = document.getElementById('variantUnit').value;
|
||||
const rows = Array.from(document.querySelectorAll('#variantPackRows .variant-pack-row'));
|
||||
|
||||
if (rows.length === 0) {
|
||||
showToast('Please add at least one pack size', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const packPayloads = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i];
|
||||
const packUnit = row.querySelector('.variant-pack-unit')?.value;
|
||||
const packSize = parseFloat(row.querySelector('.variant-pack-size')?.value || '');
|
||||
|
||||
if (!packUnit || Number.isNaN(packSize) || packSize <= 0) {
|
||||
showToast(`Pack row ${i + 1} is incomplete`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPackUnit = packUnit.trim().toLowerCase();
|
||||
const titleCasePack = normalizedPackUnit.charAt(0).toUpperCase() + normalizedPackUnit.slice(1);
|
||||
packPayloads.push({
|
||||
label: `${titleCasePack} ${packSize} ${baseUnit}`,
|
||||
pack_unit_name: normalizedPackUnit,
|
||||
pack_size_in_base_units: packSize,
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
|
||||
const variantData = {
|
||||
strength: document.getElementById('variantStrength').value,
|
||||
quantity: parseFloat(document.getElementById('variantQuantity').value),
|
||||
unit: document.getElementById('variantUnit').value,
|
||||
quantity: 0,
|
||||
unit: baseUnit,
|
||||
base_unit: baseUnit,
|
||||
low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value)
|
||||
};
|
||||
|
||||
@@ -927,9 +1259,41 @@ async function handleAddVariant(e) {
|
||||
|
||||
if (!response.ok) throw new Error('Failed to add variant');
|
||||
|
||||
const createdVariant = await response.json();
|
||||
|
||||
for (const packPayload of packPayloads) {
|
||||
const packResponse = await apiCall(`/variants/${createdVariant.id}/packs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(packPayload)
|
||||
});
|
||||
|
||||
if (!packResponse.ok) {
|
||||
const packError = await packResponse.json();
|
||||
throw new Error(packError.detail || 'Variant created but pack size creation failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Archive the auto-created default 1:1 pack when custom pack sizes are configured.
|
||||
const packsResponse = await apiCall(`/variants/${createdVariant.id}/packs`);
|
||||
if (packsResponse.ok) {
|
||||
const packs = await packsResponse.json();
|
||||
const defaultPack = packs.find(
|
||||
p => p.is_active && Number(p.pack_size_in_base_units) === 1 && (p.pack_unit_name || '').toLowerCase() === baseUnit.toLowerCase()
|
||||
);
|
||||
if (defaultPack && packs.filter(p => p.is_active).length > 1) {
|
||||
await apiCall(`/variant-packs/${defaultPack.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: false })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('variantForm').reset();
|
||||
closeModal(document.getElementById('addVariantModal'));
|
||||
await loadDrugs();
|
||||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||||
refreshDeliveryVariantSelects();
|
||||
}
|
||||
renderDrugs();
|
||||
showToast('Variant added successfully!', 'success');
|
||||
} catch (error) {
|
||||
@@ -958,6 +1322,13 @@ function openEditVariantModal(variantId) {
|
||||
document.getElementById('editVariantModal').classList.add('show');
|
||||
}
|
||||
|
||||
function inferPackUnitName(baseUnit) {
|
||||
const value = String(baseUnit || 'pack').trim().toLowerCase();
|
||||
if (!value) return 'pack';
|
||||
if (value.endsWith('s') && value.length > 1) return value.slice(0, -1);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Handle edit variant form
|
||||
async function handleEditVariant(e) {
|
||||
e.preventDefault();
|
||||
@@ -988,6 +1359,140 @@ async function handleEditVariant(e) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openVariantPacksModal(variantId) {
|
||||
const variant = getVariantById(variantId);
|
||||
if (!variant) {
|
||||
showToast('Variant not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDrug = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
|
||||
activeVariantPacksVariantId = variantId;
|
||||
|
||||
const label = document.getElementById('variantPacksLabel');
|
||||
if (label) {
|
||||
const drugName = parentDrug ? parentDrug.name : 'Drug';
|
||||
label.textContent = `${drugName} ${variant.strength} | Base Unit: ${variant.unit}`;
|
||||
}
|
||||
|
||||
const form = document.getElementById('variantPacksForm');
|
||||
if (form) form.reset();
|
||||
document.getElementById('variantPacksVariantId').value = String(variantId);
|
||||
document.getElementById('variantPacksNewUnit').value = inferPackUnitName(variant.unit);
|
||||
|
||||
await refreshVariantPacksList();
|
||||
openModal(document.getElementById('variantPacksModal'));
|
||||
}
|
||||
|
||||
async function refreshVariantPacksList() {
|
||||
const variantId = parseInt(document.getElementById('variantPacksVariantId')?.value || '', 10);
|
||||
const list = document.getElementById('variantPacksList');
|
||||
if (!variantId || !list) return;
|
||||
|
||||
list.innerHTML = '<p class="loading">Loading packs...</p>';
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/variants/${variantId}/packs`);
|
||||
if (!response.ok) throw new Error('Failed to load pack presentations');
|
||||
const packs = await response.json();
|
||||
|
||||
if (!Array.isArray(packs) || packs.length === 0) {
|
||||
list.innerHTML = '<p class="empty">No pack presentations defined.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = `
|
||||
<div class="locations-table">
|
||||
${packs.map(pack => `
|
||||
<div class="location-item" style="${pack.is_active ? '' : 'opacity: 0.6;'}">
|
||||
<div style="flex: 1;">
|
||||
<strong>${escapeHtml(pack.label)}</strong>
|
||||
<div style="font-size: 0.88em; color: #666;">
|
||||
${escapeHtml(pack.pack_unit_name)} | ${pack.pack_size_in_base_units} base units
|
||||
${pack.is_active ? '' : ' | archived'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn ${pack.is_active ? 'btn-danger' : 'btn-secondary'} btn-small" onclick="toggleVariantPackActive(${pack.id}, ${pack.is_active ? 'false' : 'true'})">
|
||||
${pack.is_active ? 'Archive' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error loading variant packs:', error);
|
||||
list.innerHTML = '<p class="empty">Failed to load pack presentations</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateVariantPack(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const variantId = parseInt(document.getElementById('variantPacksVariantId').value, 10);
|
||||
const label = document.getElementById('variantPacksNewLabel').value.trim();
|
||||
const packUnitName = document.getElementById('variantPacksNewUnit').value.trim();
|
||||
const size = parseFloat(document.getElementById('variantPacksNewSize').value);
|
||||
|
||||
if (!variantId || !label || !packUnitName || Number.isNaN(size) || size <= 0) {
|
||||
showToast('Please complete all pack fields', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/variants/${variantId}/packs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
label,
|
||||
pack_unit_name: packUnitName,
|
||||
pack_size_in_base_units: size,
|
||||
is_active: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to add pack presentation');
|
||||
}
|
||||
|
||||
document.getElementById('variantPacksForm').reset();
|
||||
const variant = getVariantById(variantId);
|
||||
document.getElementById('variantPacksNewUnit').value = inferPackUnitName(variant?.unit || 'pack');
|
||||
await loadDrugs();
|
||||
await refreshVariantPacksList();
|
||||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||||
refreshDeliveryVariantSelects();
|
||||
}
|
||||
showToast('Pack presentation added', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error creating pack presentation:', error);
|
||||
showToast('Failed to add pack presentation: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVariantPackActive(packId, nextActiveState) {
|
||||
try {
|
||||
const response = await apiCall(`/variant-packs/${packId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: Boolean(nextActiveState) })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to update pack state');
|
||||
}
|
||||
|
||||
await loadDrugs();
|
||||
await refreshVariantPacksList();
|
||||
if (document.getElementById('receiveDeliveryModal')?.classList.contains('show')) {
|
||||
refreshDeliveryVariantSelects();
|
||||
}
|
||||
showToast('Pack updated', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating pack state:', error);
|
||||
showToast('Failed to update pack: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Dispense from variant
|
||||
function dispenseVariant(variantId) {
|
||||
// Update the dropdown display with all variants
|
||||
@@ -1751,3 +2256,306 @@ async function handleBatchReceive(e) {
|
||||
showToast('Failed to receive batch: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveDeliveryDrug() {
|
||||
return allDrugs.find(d => d.id === deliveryDrugId);
|
||||
}
|
||||
|
||||
function getVariantById(variantId) {
|
||||
for (const drug of allDrugs) {
|
||||
const found = (drug.variants || []).find(v => v.id === variantId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDeliveryVariantOptions(drug, selectedVariantId = '') {
|
||||
if (!drug || !drug.variants || drug.variants.length === 0) {
|
||||
return '<option value="">-- No variants available --</option>';
|
||||
}
|
||||
|
||||
return [`<option value="">-- Select variant --</option>`, ...drug.variants.map(v => {
|
||||
const selected = String(v.id) === String(selectedVariantId) ? ' selected' : '';
|
||||
return `<option value="${v.id}"${selected}>${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`;
|
||||
})].join('');
|
||||
}
|
||||
|
||||
function getActivePacksForVariant(variant) {
|
||||
if (!variant || !Array.isArray(variant.packs)) return [];
|
||||
return variant.packs.filter(pack => pack.is_active);
|
||||
}
|
||||
|
||||
function buildDeliveryPackOptions(variant, selectedPackId = '') {
|
||||
const packs = getActivePacksForVariant(variant);
|
||||
if (packs.length === 0) {
|
||||
return '<option value="">-- No active packs --</option>';
|
||||
}
|
||||
|
||||
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
|
||||
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
|
||||
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||||
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
|
||||
})].join('');
|
||||
}
|
||||
|
||||
function buildDeliveryLocationOptions(selectedLocationId = '') {
|
||||
return [`<option value="">-- Select location --</option>`, ...deliveryLocations.map(location => {
|
||||
const selected = String(location.id) === String(selectedLocationId) ? ' selected' : '';
|
||||
return `<option value="${location.id}"${selected}>${escapeHtml(location.name)}</option>`;
|
||||
})].join('');
|
||||
}
|
||||
|
||||
function updateDeliveryLineQuantityDisplay(line) {
|
||||
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
|
||||
const packSelect = line.querySelector('.delivery-pack-select');
|
||||
|
||||
const variant = getVariantById(variantId);
|
||||
if (!variant || !packSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPackId = packSelect.value;
|
||||
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
|
||||
}
|
||||
|
||||
function wireDeliveryLineEvents(line) {
|
||||
const variantSelect = line.querySelector('.delivery-variant-select');
|
||||
const packSelect = line.querySelector('.delivery-pack-select');
|
||||
const packCountInput = line.querySelector('.delivery-pack-count');
|
||||
|
||||
if (variantSelect && packSelect) {
|
||||
variantSelect.addEventListener('change', () => {
|
||||
const variantId = parseInt(variantSelect.value || '', 10);
|
||||
const variant = getVariantById(variantId);
|
||||
packSelect.innerHTML = buildDeliveryPackOptions(variant, '');
|
||||
if (packCountInput) packCountInput.value = '';
|
||||
updateDeliveryLineQuantityDisplay(line);
|
||||
});
|
||||
}
|
||||
|
||||
if (packSelect) {
|
||||
packSelect.addEventListener('change', () => {
|
||||
updateDeliveryLineQuantityDisplay(line);
|
||||
});
|
||||
}
|
||||
|
||||
if (packCountInput) {
|
||||
packCountInput.addEventListener('input', () => {
|
||||
updateDeliveryLineQuantityDisplay(line);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function appendDeliveryLine(prefill = {}) {
|
||||
const container = document.getElementById('deliveryLinesContainer');
|
||||
const drug = getActiveDeliveryDrug();
|
||||
if (!container || !drug) return;
|
||||
|
||||
deliveryLineCounter += 1;
|
||||
const lineId = `delivery-line-${deliveryLineCounter}`;
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'delivery-line';
|
||||
line.dataset.lineId = lineId;
|
||||
|
||||
const initialVariant = drug.variants.find(v => String(v.id) === String(prefill.variantId)) || drug.variants[0] || null;
|
||||
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
|
||||
const initialPackId = prefill.packId || '';
|
||||
const initialPackCount = prefill.packCount || '';
|
||||
|
||||
line.innerHTML = `
|
||||
<div class="delivery-line-grid">
|
||||
<div class="form-group">
|
||||
<label>Variant</label>
|
||||
<select class="delivery-variant-select" required>
|
||||
${buildDeliveryVariantOptions(drug, initialVariantId)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pack Type</label>
|
||||
<select class="delivery-pack-select" required>
|
||||
${buildDeliveryPackOptions(initialVariant, initialPackId)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pack Count</label>
|
||||
<input type="number" class="delivery-pack-count" min="0.0001" step="0.0001" value="${initialPackCount}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Batch Number</label>
|
||||
<input type="text" class="delivery-batch-number" value="${prefill.batchNumber || ''}" placeholder="e.g. ABC123" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Expiry</label>
|
||||
<input type="date" class="delivery-expiry-date" value="${prefill.expiryDate || ''}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Location</label>
|
||||
<select class="delivery-location-select" required>
|
||||
${buildDeliveryLocationOptions(prefill.locationId || '')}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-small delivery-remove-btn">Remove</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const removeBtn = line.querySelector('.delivery-remove-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
if (container.children.length <= 1) {
|
||||
showToast('At least one delivery line is required', 'warning');
|
||||
return;
|
||||
}
|
||||
line.remove();
|
||||
});
|
||||
}
|
||||
|
||||
wireDeliveryLineEvents(line);
|
||||
updateDeliveryLineQuantityDisplay(line);
|
||||
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
function refreshDeliveryVariantSelects() {
|
||||
const drug = getActiveDeliveryDrug();
|
||||
const container = document.getElementById('deliveryLinesContainer');
|
||||
if (!drug || !container) return;
|
||||
|
||||
container.querySelectorAll('.delivery-line').forEach(line => {
|
||||
const select = line.querySelector('.delivery-variant-select');
|
||||
const packSelect = line.querySelector('.delivery-pack-select');
|
||||
if (!select) return;
|
||||
|
||||
const currentVariantId = select.value;
|
||||
select.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
|
||||
|
||||
const variant = getVariantById(parseInt(select.value || '', 10));
|
||||
if (packSelect) {
|
||||
const currentPackId = packSelect.value;
|
||||
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
|
||||
}
|
||||
|
||||
updateDeliveryLineQuantityDisplay(line);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeDeliveryLocations() {
|
||||
try {
|
||||
const response = await apiCall('/locations');
|
||||
if (!response.ok) throw new Error('Failed to load locations');
|
||||
const locations = await response.json();
|
||||
deliveryLocations = locations.filter(location => location.is_active);
|
||||
} catch (error) {
|
||||
console.error('Error loading delivery locations:', error);
|
||||
showToast('Failed to load storage locations', 'error');
|
||||
deliveryLocations = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function openReceiveDeliveryModal(drugId) {
|
||||
deliveryDrugId = drugId;
|
||||
const drug = getActiveDeliveryDrug();
|
||||
if (!drug) {
|
||||
showToast('Drug not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById('receiveDeliveryForm');
|
||||
const container = document.getElementById('deliveryLinesContainer');
|
||||
const label = document.getElementById('receiveDeliveryDrugLabel');
|
||||
|
||||
if (form) form.reset();
|
||||
if (container) container.innerHTML = '';
|
||||
if (label) label.textContent = `Drug: ${drug.name}`;
|
||||
|
||||
await initializeDeliveryLocations();
|
||||
appendDeliveryLine();
|
||||
|
||||
openModal(document.getElementById('receiveDeliveryModal'));
|
||||
}
|
||||
|
||||
function handleAddVariantFromDelivery() {
|
||||
if (!deliveryDrugId) {
|
||||
showToast('Select a drug first', 'warning');
|
||||
return;
|
||||
}
|
||||
openAddVariantModal(deliveryDrugId);
|
||||
}
|
||||
|
||||
async function handleReceiveDelivery(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const drug = getActiveDeliveryDrug();
|
||||
const container = document.getElementById('deliveryLinesContainer');
|
||||
if (!drug || !container) {
|
||||
showToast('Delivery context unavailable', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = Array.from(container.querySelectorAll('.delivery-line'));
|
||||
if (lines.length === 0) {
|
||||
showToast('Add at least one delivery line', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payloads = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const variantId = parseInt(line.querySelector('.delivery-variant-select')?.value || '', 10);
|
||||
const packIdRaw = line.querySelector('.delivery-pack-select')?.value || '';
|
||||
const packId = packIdRaw ? parseInt(packIdRaw, 10) : null;
|
||||
const packCountRaw = line.querySelector('.delivery-pack-count')?.value || '';
|
||||
const packCount = packCountRaw ? parseFloat(packCountRaw) : null;
|
||||
const batchNumber = (line.querySelector('.delivery-batch-number')?.value || '').trim();
|
||||
const expiryDate = line.querySelector('.delivery-expiry-date')?.value || '';
|
||||
const locationId = parseInt(line.querySelector('.delivery-location-select')?.value || '', 10);
|
||||
|
||||
if (!variantId || !packId || packCount === null || Number.isNaN(packCount) || packCount <= 0 || !batchNumber || !expiryDate || !locationId) {
|
||||
showToast(`Delivery line ${i + 1} is incomplete`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = drug.variants.find(v => v.id === variantId);
|
||||
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
|
||||
if (!selectedPack) {
|
||||
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
|
||||
return;
|
||||
}
|
||||
const computedQuantity = packCount * selectedPack.pack_size_in_base_units;
|
||||
|
||||
payloads.push({
|
||||
variantId,
|
||||
payload: {
|
||||
batch_number: batchNumber,
|
||||
received_pack_id: packId,
|
||||
received_pack_count: packCount,
|
||||
expiry_date: expiryDate,
|
||||
location_id: locationId,
|
||||
notes: `Received ${packCount} ${selectedPack.pack_unit_name}(s), total ${computedQuantity} ${variant ? variant.unit : 'units'}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < payloads.length; i += 1) {
|
||||
const entry = payloads[i];
|
||||
const response = await apiCall(`/variants/${entry.variantId}/batches`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(entry.payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(`Line ${i + 1}: ${error.detail || 'Failed to receive delivery line'}`);
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(document.getElementById('receiveDeliveryModal'));
|
||||
await loadDrugs();
|
||||
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error receiving delivery:', error);
|
||||
showToast('Failed to receive delivery: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
+87
-17
@@ -205,10 +205,32 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dispenseMode">Dispense Mode *</label>
|
||||
<select id="dispenseMode" onchange="updateDispenseModeUi()">
|
||||
<option value="subunit">Subunit Quantity</option>
|
||||
<option value="pack">Whole Packs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="dispenseQuantityGroup">
|
||||
<label for="dispenseQuantity">Quantity *</label>
|
||||
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="dispensePackRow" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="dispensePackSelect">Pack Type *</label>
|
||||
<select id="dispensePackSelect" onchange="updateDispenseQuantityFromPack()">
|
||||
<option value="">-- Select pack --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dispensePackCount">Pack Count *</label>
|
||||
<input type="number" id="dispensePackCount" min="0.0001" step="0.0001" onchange="updateDispenseQuantityFromPack()">
|
||||
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
|
||||
@@ -307,23 +329,21 @@
|
||||
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="variantQuantity">Quantity *</label>
|
||||
<input type="number" id="variantQuantity" step="0.1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="variantUnit">Base Unit *</label>
|
||||
<select id="variantUnit">
|
||||
<option value="ml">ml</option>
|
||||
<option value="tablets">tablets</option>
|
||||
<option value="capsules">capsules</option>
|
||||
<option value="units">units</option>
|
||||
<option value="vials">vials</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="variantUnit">Unit *</label>
|
||||
<select id="variantUnit">
|
||||
<option value="tablets">Tablets</option>
|
||||
<option value="bottles">Bottles</option>
|
||||
<option value="boxes">boxes</option>
|
||||
<option value="vials">Vials</option>
|
||||
<option value="units">Units</option>
|
||||
<option value="packets">Packets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pack Sizes *</label>
|
||||
<div id="variantPackRows" class="delivery-lines"></div>
|
||||
<button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -358,7 +378,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editVariantUnit">Unit *</label>
|
||||
<label for="editVariantUnit">Base Unit *</label>
|
||||
<select id="editVariantUnit">
|
||||
<option value="tablets">Tablets</option>
|
||||
<option value="bottles">Bottles</option>
|
||||
@@ -575,6 +595,56 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receive Delivery Modal -->
|
||||
<div id="receiveDeliveryModal" class="modal">
|
||||
<div class="modal-content modal-large receive-delivery-modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Receive Delivery</h2>
|
||||
<p id="receiveDeliveryDrugLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
|
||||
<form id="receiveDeliveryForm" novalidate>
|
||||
<div id="deliveryLinesContainer" class="delivery-lines"></div>
|
||||
<div class="delivery-toolbar">
|
||||
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
|
||||
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Receive Delivery</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelReceiveDeliveryBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variant Pack Management Modal -->
|
||||
<div id="variantPacksModal" class="modal">
|
||||
<div class="modal-content modal-large">
|
||||
<span class="close">×</span>
|
||||
<h2>Pack Presentations</h2>
|
||||
<p id="variantPacksLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
|
||||
|
||||
<div class="form-group">
|
||||
<h3 style="margin-bottom: 8px;">Add Pack Presentation</h3>
|
||||
<form id="variantPacksForm">
|
||||
<input type="hidden" id="variantPacksVariantId">
|
||||
<div class="form-row">
|
||||
<input type="text" id="variantPacksNewLabel" placeholder="Label (e.g., Bottle 300 ml)" required>
|
||||
<input type="text" id="variantPacksNewUnit" placeholder="Pack Unit Name (e.g., bottle, box)" required>
|
||||
<input type="number" id="variantPacksNewSize" min="0.0001" step="0.0001" placeholder="Size in base units" required>
|
||||
<button type="submit" class="btn btn-primary btn-small">Add Pack</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="variantPacksList" class="locations-list">
|
||||
<p class="loading">Loading packs...</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="closeVariantPacksBtn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -673,6 +673,18 @@ footer {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#receiveDeliveryModal.show {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
#receiveDeliveryModal .modal-content {
|
||||
width: min(1280px, 96vw) !important;
|
||||
max-width: min(1280px, 96vw) !important;
|
||||
max-height: calc(100vh - 48px) !important;
|
||||
}
|
||||
|
||||
#dispenseModal.show {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
@@ -877,6 +889,103 @@ footer {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.delivery-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.delivery-line {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #f9fbfd;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.receive-delivery-modal-content {
|
||||
width: min(1320px, 98vw);
|
||||
max-width: 1320px;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.delivery-line-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.delivery-line-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.delivery-line-grid .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.delivery-line-grid label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.delivery-line-grid input,
|
||||
.delivery-line-grid select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.delivery-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 6px 0 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delivery-remove-btn {
|
||||
align-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.receive-delivery-modal-content {
|
||||
width: min(1120px, 97vw);
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.delivery-line-grid {
|
||||
grid-template-columns: 1.7fr 1.4fr 0.95fr 1.2fr 1.1fr 1.15fr;
|
||||
}
|
||||
|
||||
.delivery-remove-btn {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
#receiveDeliveryModal .modal-content {
|
||||
width: 94vw !important;
|
||||
max-width: 94vw !important;
|
||||
}
|
||||
|
||||
.delivery-line-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.delivery-remove-btn {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
@@ -935,6 +1044,14 @@ footer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.delivery-line-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.delivery-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drug-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user