diff --git a/backend/app/main.py b/backend/app/main.py
index 63aee99..b55678e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -73,6 +73,9 @@ class UserCreate(BaseModel):
password: str
role: Optional[str] = "user" # admin, user, readonly
+class UserRoleUpdate(BaseModel):
+ role: str
+
class PasswordChange(BaseModel):
current_password: str
new_password: str
@@ -297,6 +300,17 @@ class DispensingAllocationCreate(BaseModel):
quantity: float
+class InventoryDisposeRequest(BaseModel):
+ drug_variant_id: int
+ quantity: Optional[float] = None
+ dispense_mode: str = "subunit"
+ dispense_source: str = "batch"
+ requested_pack_id: Optional[int] = None
+ requested_pack_count: Optional[float] = None
+ notes: Optional[str] = None
+ allocations: List[DispensingAllocationCreate] = []
+
+
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
@@ -876,6 +890,34 @@ def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User
db.commit()
return {"message": "User deleted successfully"}
+@router.patch("/users/{user_id}/role", response_model=UserResponse)
+def update_user_role(user_id: int, role_data: UserRoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
+ """Update a user's role (admin only)"""
+ if current_user.id == user_id:
+ raise HTTPException(status_code=400, detail="Cannot change your own role")
+
+ valid_roles = ["admin", "user", "readonly"]
+ if role_data.role not in valid_roles:
+ raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}")
+
+ user = db.query(User).filter(User.id == user_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ old_role = user.role
+ user.role = role_data.role
+ write_audit_log(
+ db,
+ action="user.role.update",
+ entity_type="user",
+ entity_id=user.id,
+ actor=current_user,
+ details={"username": user.username, "old_role": old_role, "new_role": user.role},
+ )
+ db.commit()
+ db.refresh(user)
+ return user
+
@router.post("/auth/change-password")
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Change current user's password"""
@@ -1620,6 +1662,110 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"allocations": allocation_payload,
}
+@router.post("/dispose")
+def dispose_inventory(payload: InventoryDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
+ """Dispose selected inventory and reduce stock (admin only)"""
+ variant = db.query(DrugVariant).filter(DrugVariant.id == payload.drug_variant_id).first()
+ if not variant:
+ raise HTTPException(status_code=404, detail="Drug variant not found")
+
+ dispose_mode = (payload.dispense_mode or "subunit").strip().lower()
+ if dispose_mode not in {"subunit", "pack"}:
+ raise HTTPException(status_code=400, detail="dispose_mode must be either 'subunit' or 'pack'")
+
+ if dispose_mode == "pack":
+ if payload.requested_pack_id is None or payload.requested_pack_count is None:
+ raise HTTPException(status_code=400, detail="Pack disposal requires requested_pack_id and requested_pack_count")
+ if payload.requested_pack_count <= 0:
+ raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
+ if abs(payload.requested_pack_count - round(payload.requested_pack_count)) > 1e-6:
+ raise HTTPException(status_code=400, detail="Whole-pack disposal requires an integer pack count")
+
+ resolved = resolve_pack_quantity(
+ db,
+ variant_id=variant.id,
+ quantity=None,
+ pack_id=payload.requested_pack_id,
+ pack_count=payload.requested_pack_count,
+ )
+ else:
+ if payload.quantity is None or payload.quantity <= 0:
+ raise HTTPException(status_code=400, detail="Quantity disposal requires quantity > 0")
+ resolved = resolve_pack_quantity(
+ db,
+ variant_id=variant.id,
+ quantity=payload.quantity,
+ pack_id=None,
+ pack_count=None,
+ )
+
+ dispose_qty = resolved["quantity"]
+ if variant.quantity < dispose_qty:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispose_qty}",
+ )
+
+ allocations = resolve_requested_allocations(
+ db,
+ variant_id=variant.id,
+ variant_quantity=variant.quantity,
+ requested_quantity=dispose_qty,
+ requested_allocations=payload.allocations,
+ dispense_mode=dispose_mode,
+ dispense_source=payload.dispense_source,
+ requested_pack_id=resolved["pack_id"],
+ )
+
+ selected_source = (payload.dispense_source or ("legacy" if not allocations else "batch")).strip().lower()
+ allocation_payload = []
+ disposed_at = datetime.utcnow()
+
+ for allocation in allocations:
+ batch = allocation["batch"]
+ qty = allocation["quantity"]
+ remaining_before = batch.quantity
+ batch.quantity -= qty
+ if batch.quantity <= 1e-6:
+ batch.quantity = 0
+ batch.disposed_at = disposed_at
+ batch.disposed_by_user_id = current_user.id
+ batch.disposed_quantity = remaining_before
+ batch.disposal_notes = payload.notes
+ recompute_batch_pack_state(batch)
+ allocation_payload.append({"batch_id": batch.id, "quantity": qty})
+
+ variant.quantity = max(0, variant.quantity - dispose_qty)
+
+ write_audit_log(
+ db,
+ action="inventory.dispose",
+ entity_type="drug_variant",
+ entity_id=variant.id,
+ actor=current_user,
+ details={
+ "drug_variant_id": variant.id,
+ "disposed_quantity": dispose_qty,
+ "dispose_mode": dispose_mode,
+ "dispose_source": selected_source,
+ "requested_pack_id": resolved["pack_id"],
+ "requested_pack_count": resolved["pack_count"],
+ "allocations": allocation_payload,
+ "notes": payload.notes,
+ },
+ )
+
+ db.commit()
+ db.refresh(variant)
+ return {
+ "message": "Inventory disposed successfully",
+ "drug_variant_id": variant.id,
+ "disposed_quantity": dispose_qty,
+ "dispose_mode": dispose_mode,
+ "dispose_source": selected_source,
+ "allocations": allocation_payload,
+ }
+
@router.get("/dispense/history", response_model=List[DispensingResponse])
def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get dispensing records (audit log)"""
@@ -2012,7 +2158,7 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
-def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
+def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
batch = db.query(Batch).filter(Batch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="Batch not found")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index eac39a6..3bdb20c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,8 +1,8 @@
-fastapi==0.104.1
-uvicorn==0.24.0
-sqlalchemy==2.0.23
-pydantic==2.5.0
-python-multipart==0.0.6
-python-jose[cryptography]==3.3.0
+fastapi==0.137.2
+uvicorn==0.49.0
+sqlalchemy==2.0.51
+pydantic==2.13.4
+python-multipart==0.0.32
+python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4
paho-mqtt==1.6.1
diff --git a/frontend/app.js b/frontend/app.js
index 4cec52b..e9012a6 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -15,6 +15,8 @@ let deliveryLineCounter = 0;
let deliveryLocations = [];
let currentDispenseBatches = [];
let currentDispenseLegacyQuantity = 0;
+let currentDisposeBatches = [];
+let currentDisposeLegacyQuantity = 0;
let _gtinMappingPendingRefresh = false;
let _gtinMappingPendingVariantId = null;
let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload
@@ -46,8 +48,10 @@ function resetDisposeBatchModal() {
}
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
+ const stockSummaryInput = document.getElementById('disposeBatchStockSummary');
if (batchIdInput) batchIdInput.value = '';
if (batchNameInput) batchNameInput.value = '';
+ if (stockSummaryInput) stockSummaryInput.value = '';
}
function closeDisposeBatchModal() {
@@ -253,6 +257,7 @@ function setupEventListeners() {
const variantForm = document.getElementById('variantForm');
const editVariantForm = document.getElementById('editVariantForm');
const dispenseForm = document.getElementById('dispenseForm');
+ const disposeInventoryForm = document.getElementById('disposeInventoryForm');
const editForm = document.getElementById('editForm');
const printNotesForm = document.getElementById('printNotesForm');
const disposeBatchForm = document.getElementById('disposeBatchForm');
@@ -260,6 +265,7 @@ function setupEventListeners() {
const addVariantModal = document.getElementById('addVariantModal');
const editVariantModal = document.getElementById('editVariantModal');
const dispenseModal = document.getElementById('dispenseModal');
+ const disposeInventoryModal = document.getElementById('disposeInventoryModal');
const editModal = document.getElementById('editModal');
const printNotesModal = document.getElementById('printNotesModal');
const disposeBatchModal = document.getElementById('disposeBatchModal');
@@ -272,6 +278,7 @@ function setupEventListeners() {
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
+ const cancelDisposeInventoryBtn = document.getElementById('cancelDisposeInventoryBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
@@ -286,6 +293,8 @@ function setupEventListeners() {
const editVariantUnitSelect = document.getElementById('editVariantUnit');
const dispenseModeInputs = document.querySelectorAll('input[name="dispenseMode"]');
const dispenseSourceInputs = document.querySelectorAll('input[name="dispenseSource"]');
+ const disposeModeInputs = document.querySelectorAll('input[name="disposeMode"]');
+ const disposeSourceInputs = document.querySelectorAll('input[name="disposeSource"]');
const dispensePrintEnabled = document.getElementById('dispensePrintEnabled');
const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn');
@@ -330,6 +339,7 @@ function setupEventListeners() {
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
+ if (disposeInventoryForm) disposeInventoryForm.addEventListener('submit', handleDisposeInventory);
if (editForm) editForm.addEventListener('submit', handleEditDrug);
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
@@ -387,6 +397,11 @@ function setupEventListeners() {
toggleDispensePrintFields();
updateDispenseAllocationSummary();
}));
+ disposeModeInputs.forEach(input => input.addEventListener('change', updateDisposeModeUi));
+ disposeSourceInputs.forEach(input => input.addEventListener('change', () => {
+ renderDisposeInventorySourceView();
+ updateDisposeAllocationSummary();
+ }));
if (dispensePrintEnabled) {
dispensePrintEnabled.addEventListener('change', toggleDispensePrintFields);
}
@@ -404,6 +419,7 @@ function setupEventListeners() {
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
+ if (cancelDisposeInventoryBtn) cancelDisposeInventoryBtn.addEventListener('click', () => closeModal(disposeInventoryModal));
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
@@ -537,6 +553,40 @@ function setupEventListeners() {
}, { passive: false });
}
+ const disposeQuantityInput = document.getElementById('disposeQuantity');
+ if (disposeQuantityInput) {
+ disposeQuantityInput.addEventListener('wheel', (event) => {
+ event.preventDefault();
+ }, { passive: false });
+
+ disposeQuantityInput.addEventListener('input', () => {
+ if (getSelectedDisposeMode() !== 'subunit') return;
+
+ const packSelect = document.getElementById('disposePackSelect');
+ const packCount = document.getElementById('disposePackCount');
+ const packPreview = document.getElementById('disposePackPreview');
+ const variantId = parseInt(document.getElementById('disposeDrugSelect')?.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}.`;
+ }
+
+ autoAllocateDisposeBatches();
+ });
+ }
+
+ const disposePackCountInput = document.getElementById('disposePackCount');
+ if (disposePackCountInput) {
+ disposePackCountInput.addEventListener('wheel', (event) => {
+ event.preventDefault();
+ }, { passive: false });
+
+ disposePackCountInput.addEventListener('input', updateDisposeQuantityFromPack);
+ }
+
// Close modal when clicking outside
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
@@ -1016,7 +1066,7 @@ function isBatchExpired(batch) {
function renderVariantInventoryDetails(variant, batches) {
const activePacks = getActivePacksForVariant(variant);
- const isReadOnly = currentUser?.role === 'readonly';
+ const isAdmin = currentUser?.role === 'admin';
const sortedBatches = [...(batches || [])]
.filter(batch => Number(batch.quantity) > 0)
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
@@ -1056,9 +1106,9 @@ function renderVariantInventoryDetails(variant, batches) {
Expires ${formatDisplayDate(batch.expiry_date)}
${escapeHtml(locationLabel)} | ${stocktakeLabel}
- ${expired && !isReadOnly ? `
+ ${expired && isAdmin ? `
-
+
` : ''}
@@ -1082,19 +1132,21 @@ function renderVariantInventoryDetails(variant, batches) {
`;
}
-function disposeBatch(batchId, batchNumber) {
+function disposeBatch(batchId, batchNumber, stockSummary = '') {
const modal = document.getElementById('disposeBatchModal');
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
+ const stockSummaryInput = document.getElementById('disposeBatchStockSummary');
const notesInput = document.getElementById('disposeBatchNotes');
- if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
+ if (!modal || !batchIdInput || !batchNameInput || !stockSummaryInput || !notesInput) {
showToast('Dispose batch modal is unavailable.', 'error');
return;
}
batchIdInput.value = String(batchId);
batchNameInput.value = batchNumber;
+ stockSummaryInput.value = stockSummary;
notesInput.value = '';
openModal(modal);
}
@@ -1684,6 +1736,560 @@ function updateDispenseAllocationSummary() {
summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.`;
}
+function updateDisposeDrugSelect() {
+ const select = document.getElementById('disposeDrugSelect');
+ if (!select) return;
+
+ select.innerHTML = '';
+ allDrugs.forEach(drug => {
+ drug.variants.forEach(variant => {
+ const option = document.createElement('option');
+ option.value = variant.id;
+ option.textContent = `${drug.name} ${variant.strength} (${variant.quantity} ${variant.unit})`;
+ select.appendChild(option);
+ });
+ });
+
+ const packSelect = document.getElementById('disposePackSelect');
+ const packCount = document.getElementById('disposePackCount');
+ const packPreview = document.getElementById('disposePackPreview');
+ const quantityModeRadio = document.getElementById('disposeModeQuantity');
+ const batchSourceRadio = document.getElementById('disposeSourceBatch');
+ const legacySourceRadio = document.getElementById('disposeSourceLegacy');
+
+ if (packSelect) packSelect.innerHTML = '';
+ if (packCount) packCount.value = '';
+ if (quantityModeRadio) quantityModeRadio.checked = true;
+ if (packPreview) packPreview.textContent = 'Select a pack and whole-number count.';
+ if (batchSourceRadio) batchSourceRadio.checked = true;
+ if (legacySourceRadio) legacySourceRadio.checked = false;
+
+ currentDisposeBatches = [];
+ currentDisposeLegacyQuantity = 0;
+ updateDisposeModeUi();
+}
+
+function getSelectedDisposeMode() {
+ return document.querySelector('input[name="disposeMode"]:checked')?.value || 'subunit';
+}
+
+function hasLegacyDisposeQuantity() {
+ return currentDisposeLegacyQuantity > 0;
+}
+
+function hasBatchDisposeStock() {
+ return currentDisposeBatches.length > 0;
+}
+
+function getSelectedDisposeSource() {
+ if (getSelectedDisposeMode() === 'pack') return 'batch';
+
+ const selected = document.querySelector('input[name="disposeSource"]:checked')?.value;
+ if (selected) return selected;
+ if (hasLegacyDisposeQuantity() && !hasBatchDisposeStock()) return 'legacy';
+ return 'batch';
+}
+
+function isLegacyDisposeSelected() {
+ return getSelectedDisposeMode() === 'subunit' && getSelectedDisposeSource() === 'legacy' && hasLegacyDisposeQuantity();
+}
+
+function updateDisposeSourceUi() {
+ const sourceGroup = document.getElementById('disposeSourceGroup');
+ const sourceHelp = document.getElementById('disposeSourceHelp');
+ const batchRadio = document.getElementById('disposeSourceBatch');
+ const legacyRadio = document.getElementById('disposeSourceLegacy');
+ const hasBatches = hasBatchDisposeStock();
+ const hasLegacy = hasLegacyDisposeQuantity();
+
+ if (!sourceGroup || !batchRadio || !legacyRadio) return;
+
+ if (getSelectedDisposeMode() === 'pack' || (!hasBatches && !hasLegacy)) {
+ sourceGroup.style.display = 'none';
+ batchRadio.checked = true;
+ batchRadio.disabled = !hasBatches;
+ legacyRadio.checked = false;
+ legacyRadio.disabled = true;
+ if (sourceHelp) sourceHelp.textContent = '';
+ return;
+ }
+
+ batchRadio.disabled = !hasBatches;
+ legacyRadio.disabled = !hasLegacy;
+
+ if (hasLegacy && !hasBatches) {
+ legacyRadio.checked = true;
+ } else if (!hasLegacy && hasBatches) {
+ batchRadio.checked = true;
+ } else if (!batchRadio.checked && !legacyRadio.checked) {
+ batchRadio.checked = true;
+ }
+
+ sourceGroup.style.display = hasLegacy ? '' : 'none';
+ if (sourceHelp) {
+ if (hasLegacy && hasBatches) {
+ sourceHelp.textContent = `Batch stock available alongside ${formatDisplayNumber(currentDisposeLegacyQuantity)} loose legacy units.`;
+ } else if (hasLegacy) {
+ sourceHelp.textContent = `Legacy loose stock available: ${formatDisplayNumber(currentDisposeLegacyQuantity)}.`;
+ } else {
+ sourceHelp.textContent = '';
+ }
+ }
+}
+
+function getDisposeRequestedQuantity() {
+ const quantity = parseFloat(document.getElementById('disposeQuantity')?.value || '');
+ return Number.isNaN(quantity) || quantity <= 0 ? 0 : quantity;
+}
+
+function getSelectedDisposePack() {
+ const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
+ const packId = parseInt(document.getElementById('disposePackSelect')?.value || '', 10);
+ const variant = getVariantById(variantId);
+ if (!variant || Number.isNaN(packId)) return null;
+ return getActivePacksForVariant(variant).find(pack => pack.id === packId) || null;
+}
+
+function getBatchAvailableDisposeQuantity(batch, mode = getSelectedDisposeMode(), selectedPack = getSelectedDisposePack()) {
+ if (mode !== 'pack') return Number(batch.quantity || 0);
+ if (!batchMatchesSelectedPack(batch, selectedPack)) return 0;
+ return Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0))) * Number(selectedPack.pack_size_in_base_units || 0);
+}
+
+function getTotalAvailableDisposeQuantity(mode = getSelectedDisposeMode(), selectedPack = getSelectedDisposePack()) {
+ if (getSelectedDisposeSource() === 'legacy') {
+ return mode === 'pack' ? 0 : currentDisposeLegacyQuantity;
+ }
+ return currentDisposeBatches.reduce((sum, batch) => sum + getBatchAvailableDisposeQuantity(batch, mode, selectedPack), 0);
+}
+
+function getTotalAvailableDisposePackCount(selectedPack = getSelectedDisposePack()) {
+ if (getSelectedDisposeSource() === 'legacy' || !selectedPack) return 0;
+ return currentDisposeBatches.reduce((sum, batch) => {
+ if (!batchMatchesSelectedPack(batch, selectedPack)) return sum;
+ return sum + Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)));
+ }, 0);
+}
+
+function populateDisposePackSelect(variant) {
+ const packSelect = document.getElementById('disposePackSelect');
+ const packCount = document.getElementById('disposePackCount');
+ const packPreview = document.getElementById('disposePackPreview');
+ if (!packSelect) return;
+
+ const activePacks = getActivePacksForVariant(variant);
+ packSelect.innerHTML = '';
+ activePacks.forEach(pack => {
+ const option = document.createElement('option');
+ option.value = String(pack.id);
+ option.textContent = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
+ packSelect.appendChild(option);
+ });
+
+ if (packCount) packCount.value = '';
+ if (activePacks.length > 0) packSelect.value = String(activePacks[0].id);
+ if (packPreview) {
+ packPreview.textContent = activePacks.length > 0
+ ? `Select a pack and whole-number count (${variant.unit} base unit).`
+ : 'No active packs for this variant.';
+ }
+ if (activePacks.length > 0) updateDisposeQuantityFromPack();
+}
+
+function updateDisposeModeUi() {
+ const mode = getSelectedDisposeMode();
+ const quantityGroup = document.getElementById('disposeQuantityGroup');
+ const packRow = document.getElementById('disposePackRow');
+ const quantityInput = document.getElementById('disposeQuantity');
+ const packSelect = document.getElementById('disposePackSelect');
+ const packCount = document.getElementById('disposePackCount');
+
+ 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';
+
+ updateDisposeSourceUi();
+ renderDisposeInventorySourceView();
+}
+
+function updateDisposeQuantityFromPack() {
+ if (getSelectedDisposeMode() !== 'pack') return;
+
+ const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
+ const packId = parseInt(document.getElementById('disposePackSelect')?.value || '', 10);
+ const packCount = parseFloat(document.getElementById('disposePackCount')?.value || '');
+ const quantityInput = document.getElementById('disposeQuantity');
+ const preview = document.getElementById('disposePackPreview');
+ const variant = getVariantById(variantId);
+ if (!quantityInput || !preview || !variant) return;
+
+ const selectedPack = getActivePacksForVariant(variant).find(pack => pack.id === packId);
+ const totalAvailablePacks = selectedPack ? getTotalAvailableDisposePackCount(selectedPack) : 0;
+ 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);
+ if (totalAvailablePacks <= 0) {
+ preview.textContent = `No full ${selectedPack.pack_unit_name} packs are currently available.`;
+ } else if (packCount > totalAvailablePacks) {
+ preview.textContent = `Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`;
+ } else {
+ preview.textContent = `${packCount} x ${selectedPack.pack_size_in_base_units} = ${quantity} ${variant.unit} | ${totalAvailablePacks} full pack${totalAvailablePacks === 1 ? '' : 's'} available`;
+ }
+ autoAllocateDisposeBatches();
+ return;
+ }
+
+ quantityInput.value = '';
+ preview.textContent = selectedPack
+ ? `${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`
+ : 'Select a pack and whole-number count.';
+ autoAllocateDisposeBatches();
+}
+
+function renderDisposeBatchAllocationRows(activeBatches) {
+ const batchInfoContent = document.getElementById('disposeBatchInfoContent');
+ const variantId = parseInt(document.getElementById('disposeDrugSelect').value || '', 10);
+ const variant = getVariantById(variantId);
+ const unitLabel = variant?.unit || 'units';
+ if (!batchInfoContent) return;
+
+ if (!activeBatches.length) {
+ batchInfoContent.innerHTML = 'No active batches available for this variant
';
+ return;
+ }
+
+ const mode = getSelectedDisposeMode();
+ const selectedPack = getSelectedDisposePack();
+
+ batchInfoContent.innerHTML = activeBatches.map((batch, index) => {
+ const expiryDate = new Date(batch.expiry_date);
+ const locationLabel = getBatchLocationLabel(batch);
+ const expiryLabel = formatDisplayDate(batch.expiry_date);
+ const today = new Date();
+ const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
+ const statusColor = daysToExpiry <= 7 ? '#ff9800' : '#4caf50';
+ const expiryStatus = daysToExpiry <= 7 ? `${daysToExpiry}d left` : 'OK';
+ const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
+ ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
+ : 0;
+ const allocationLabel = mode === 'pack' ? 'Dispose Packs' : 'Dispose';
+ const allocationMax = mode === 'pack' ? availableFullPacks : getBatchAvailableDisposeQuantity(batch, mode, selectedPack);
+ const allocationStep = mode === 'pack' ? '1' : '1.0';
+ const batchAvailabilityNote = mode === 'pack'
+ ? (selectedPack && batchMatchesSelectedPack(batch, selectedPack) && availableFullPacks <= 0 ? 'No full packs available in this batch' : '')
+ : `Available to dispose: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
+
+ return `
+
+
+
+
${escapeHtml(batch.batch_number)}${index === 0 ? ' FEFO' : ''}
+
+ Available: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} |
+ Location: ${escapeHtml(locationLabel)} |
+ Expiry: ${expiryLabel} (${expiryStatus})
+
+
+
+ ${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
+ ? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
+ : ''}
+ ${batchAvailabilityNote ? `
${batchAvailabilityNote}
` : ''}
+
+
+
+
+
+
+
+ `;
+ }).join('');
+
+ batchInfoContent.querySelectorAll('.dispose-batch-allocation').forEach(input => {
+ input.addEventListener('wheel', (event) => {
+ event.preventDefault();
+ }, { passive: false });
+ input.addEventListener('input', updateDisposeAllocationSummary);
+ });
+}
+
+function renderExpiredDisposeBatches(expiredBatches) {
+ const expiredDetails = document.getElementById('disposeExpiredBatchDetails');
+ const expiredContent = document.getElementById('disposeExpiredBatchContent');
+ const variantId = parseInt(document.getElementById('disposeDrugSelect').value || '', 10);
+ const variant = getVariantById(variantId);
+ const unitLabel = variant?.unit || 'units';
+ if (!expiredDetails || !expiredContent) return;
+
+ if (!expiredBatches.length) {
+ expiredDetails.style.display = 'none';
+ expiredDetails.open = false;
+ expiredContent.innerHTML = '';
+ return;
+ }
+
+ expiredDetails.style.display = 'block';
+ expiredContent.innerHTML = expiredBatches.map(batch => {
+ const locationLabel = getBatchLocationLabel(batch);
+ const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
+ ? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
+ : `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
+
+ return `
+
+
+ ${escapeHtml(batch.batch_number)}
+ Expired ${formatDisplayDate(batch.expiry_date)}
+
+
+ Qty: ${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)} |
+ Location: ${escapeHtml(locationLabel)}
+
+
${stocktakeLabel}
+
+ `;
+ }).join('');
+}
+
+function renderDisposeInventorySourceView() {
+ const batchInfoContent = document.getElementById('disposeBatchInfoContent');
+ const variantId = parseInt(document.getElementById('disposeDrugSelect')?.value || '', 10);
+ const variant = getVariantById(variantId);
+ if (!batchInfoContent || !variant) return;
+
+ if (getSelectedDisposeMode() === 'pack') {
+ if (hasBatchDisposeStock()) {
+ renderDisposeBatchAllocationRows(currentDisposeBatches);
+ autoAllocateDisposeBatches();
+ } else if (hasLegacyDisposeQuantity()) {
+ batchInfoContent.innerHTML = `Legacy stock only. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack disposal is unavailable.
`;
+ updateDisposeAllocationSummary();
+ } else {
+ batchInfoContent.innerHTML = 'No active batches available for this variant
';
+ updateDisposeAllocationSummary();
+ }
+ return;
+ }
+
+ if (isLegacyDisposeSelected()) {
+ const extraText = hasBatchDisposeStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispose by quantity only.';
+ batchInfoContent.innerHTML = `Legacy loose stock selected. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}
`;
+ updateDisposeAllocationSummary();
+ return;
+ }
+
+ if (hasBatchDisposeStock()) {
+ renderDisposeBatchAllocationRows(currentDisposeBatches);
+ autoAllocateDisposeBatches();
+ return;
+ }
+
+ if (hasLegacyDisposeQuantity()) {
+ batchInfoContent.innerHTML = `Legacy stock only. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispose by quantity only.
`;
+ updateDisposeAllocationSummary();
+ return;
+ }
+
+ batchInfoContent.innerHTML = 'No active batches available for this variant
';
+ updateDisposeAllocationSummary();
+}
+
+async function updateDisposeBatchInfo() {
+ const variantId = parseInt(document.getElementById('disposeDrugSelect').value);
+ const batchInfoSection = document.getElementById('disposeBatchInfoSection');
+ const batchInfoContent = document.getElementById('disposeBatchInfoContent');
+
+ if (!variantId) {
+ batchInfoSection.style.display = 'none';
+ const packSelect = document.getElementById('disposePackSelect');
+ if (packSelect) packSelect.innerHTML = '';
+ currentDisposeBatches = [];
+ currentDisposeLegacyQuantity = 0;
+ renderExpiredDisposeBatches([]);
+ updateDisposeSourceUi();
+ updateDisposeAllocationSummary();
+ return;
+ }
+
+ const variant = getVariantById(variantId);
+ if (variant) {
+ const drugOfVariant = allDrugs.find(d => (d.variants || []).some(v => v.id === variantId));
+ if (drugOfVariant) await ensureDrugDetailLoaded(drugOfVariant.id);
+ populateDisposePackSelect(getVariantById(variantId));
+ }
+ updateDisposeModeUi();
+
+ batchInfoSection.style.display = 'block';
+ batchInfoContent.innerHTML = 'Loading batches...
';
+ renderExpiredDisposeBatches([]);
+
+ try {
+ const response = await apiCall(`/variants/${variantId}/batches`);
+ if (!response.ok) throw new Error('Failed to load batches');
+ const batches = await response.json();
+ const stockedBatches = batches.filter(b => b.quantity > 0);
+ const expiredBatches = stockedBatches.filter(isBatchExpired);
+ const activeBatches = stockedBatches.filter(batch => !isBatchExpired(batch));
+ const totalBatchQuantity = stockedBatches.reduce((sum, batch) => sum + Number(batch.quantity || 0), 0);
+ currentDisposeLegacyQuantity = Math.max(0, Number(variant?.quantity || 0) - totalBatchQuantity);
+ activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
+ currentDisposeBatches = activeBatches;
+ renderExpiredDisposeBatches(expiredBatches);
+ updateDisposeSourceUi();
+ renderDisposeInventorySourceView();
+ } catch (error) {
+ console.error('Error loading disposal batches:', error);
+ batchInfoContent.innerHTML = 'Error loading batches
';
+ currentDisposeBatches = [];
+ currentDisposeLegacyQuantity = 0;
+ renderExpiredDisposeBatches([]);
+ updateDisposeSourceUi();
+ updateDisposeAllocationSummary();
+ }
+}
+
+function autoAllocateDisposeBatches() {
+ const requestedQuantity = getDisposeRequestedQuantity();
+ const allocationInputs = Array.from(document.querySelectorAll('.dispose-batch-allocation'));
+ const mode = getSelectedDisposeMode();
+ const selectedPack = getSelectedDisposePack();
+ if (!allocationInputs.length) {
+ updateDisposeAllocationSummary();
+ return;
+ }
+
+ if (isLegacyDisposeSelected()) {
+ allocationInputs.forEach(input => { input.value = '0'; });
+ updateDisposeAllocationSummary();
+ return;
+ }
+
+ let remaining = mode === 'pack'
+ ? Math.max(0, Math.round(parseFloat(document.getElementById('disposePackCount')?.value || '0')) || 0)
+ : requestedQuantity;
+ allocationInputs.forEach(input => {
+ const batchId = parseInt(input.dataset.batchId || '', 10);
+ const batch = currentDisposeBatches.find(row => row.id === batchId);
+ if (!batch || requestedQuantity <= 0) {
+ input.value = '0';
+ return;
+ }
+
+ let allocation = 0;
+ if (mode === 'pack' && selectedPack) {
+ const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
+ ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
+ : 0;
+ allocation = Math.min(availableFullPacks, Math.max(remaining, 0));
+ input.value = allocation > 0 ? String(allocation) : '0';
+ } else {
+ allocation = Math.min(getBatchAvailableDisposeQuantity(batch, mode, selectedPack), Math.max(remaining, 0));
+ input.value = allocation > 0 ? String(Number(allocation.toFixed(3))) : '0';
+ }
+ remaining -= allocation;
+ });
+
+ updateDisposeAllocationSummary();
+}
+
+function updateDisposeAllocationSummary() {
+ const summarySection = document.getElementById('disposeAllocationSummary');
+ const summaryContent = document.getElementById('disposeAllocationSummaryContent');
+ const requestedQuantity = getDisposeRequestedQuantity();
+ const variantId = parseInt(document.getElementById('disposeDrugSelect').value || '', 10);
+ const unitLabel = getVariantById(variantId)?.unit || 'units';
+ const inputs = Array.from(document.querySelectorAll('.dispose-batch-allocation'));
+ const mode = getSelectedDisposeMode();
+ const selectedPack = getSelectedDisposePack();
+ const legacyStockOnly = isLegacyDisposeSelected();
+ const totalAvailableQuantity = getTotalAvailableDisposeQuantity(mode, selectedPack);
+ const totalAvailablePacks = mode === 'pack' ? getTotalAvailableDisposePackCount(selectedPack) : 0;
+
+ if (!summarySection || !summaryContent || !variantId || (!inputs.length && !legacyStockOnly)) {
+ if (summarySection) summarySection.style.display = 'none';
+ return;
+ }
+
+ const allocated = inputs.reduce((sum, input) => {
+ const value = parseFloat(input.value || '0');
+ return sum + (Number.isNaN(value) ? 0 : value);
+ }, 0);
+ const allocatedQuantity = mode === 'pack' && selectedPack ? allocated * selectedPack.pack_size_in_base_units : allocated;
+ const invalidInput = inputs.find(input => {
+ const batchId = parseInt(input.dataset.batchId || '', 10);
+ const batch = currentDisposeBatches.find(row => row.id === batchId);
+ const value = parseFloat(input.value || '0');
+ if (!batch || Number.isNaN(value)) return false;
+ if (mode === 'pack' && selectedPack) {
+ const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
+ ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
+ : 0;
+ return value - availableFullPacks > 1e-6 || Math.abs(value - Math.round(value)) > 1e-6;
+ }
+ return value - getBatchAvailableDisposeQuantity(batch, mode, selectedPack) > 1e-6;
+ });
+
+ const difference = requestedQuantity - allocatedQuantity;
+ summarySection.style.display = 'block';
+
+ if (requestedQuantity <= 0) {
+ summaryContent.innerHTML = legacyStockOnly
+ ? `Enter a disposal quantity. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.`
+ : 'Enter a disposal amount to allocate batches.';
+ return;
+ }
+
+ if (legacyStockOnly) {
+ if (requestedQuantity - currentDisposeLegacyQuantity > 1e-6) {
+ summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.`;
+ return;
+ }
+ summaryContent.innerHTML = `Disposing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.`;
+ return;
+ }
+
+ if (mode === 'pack' && selectedPack) {
+ const requestedPackCount = parseFloat(document.getElementById('disposePackCount')?.value || '0');
+ if (totalAvailablePacks <= 0) {
+ summaryContent.innerHTML = `No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispose.`;
+ return;
+ }
+ if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) {
+ summaryContent.innerHTML = `Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.`;
+ return;
+ }
+ }
+
+ if (requestedQuantity - totalAvailableQuantity > 1e-6) {
+ summaryContent.innerHTML = `Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.`;
+ return;
+ }
+ if (invalidInput) {
+ summaryContent.innerHTML = 'One or more batch allocations exceed available stock or are not valid full-pack amounts.';
+ return;
+ }
+ if (Math.abs(difference) <= 1e-6) {
+ if (mode === 'pack' && selectedPack) {
+ const requestedPackCount = parseFloat(document.getElementById('disposePackCount')?.value || '0');
+ summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).`;
+ } else {
+ summaryContent.innerHTML = `Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.`;
+ }
+ return;
+ }
+ if (difference > 0) {
+ summaryContent.innerHTML = `Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.`;
+ return;
+ }
+ summaryContent.innerHTML = `Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.`;
+}
+
// Render drugs list
function renderDrugs() {
const drugsList = document.getElementById('drugsList');
@@ -1729,6 +2335,7 @@ function renderDrugs() {
const isLowStock = lowStockVariants > 0;
const isExpanded = expandedDrugs.has(drug.id);
const isReadOnly = currentUser.role === 'readonly';
+ const isAdmin = currentUser.role === 'admin';
const isControlled = drug.is_controlled;
const drugDetail = loadedDrugDetails.get(drug.id);
@@ -1771,6 +2378,7 @@ function renderDrugs() {
${!isReadOnly ? `
+ ${isAdmin ? `
` : ''}
` : ''}
@@ -2046,6 +2654,144 @@ async function handleDispenseDrug(e) {
}
}
+async function handleDisposeInventory(e) {
+ e.preventDefault();
+
+ const variantId = parseInt(document.getElementById('disposeDrugSelect').value);
+ let quantity = parseFloat(document.getElementById('disposeQuantity').value);
+ const disposeMode = getSelectedDisposeMode();
+ const disposeSource = getSelectedDisposeSource();
+ const requestedPackIdValue = document.getElementById('disposePackSelect').value;
+ const requestedPackCountValue = document.getElementById('disposePackCount').value;
+ const notes = document.getElementById('disposeNotes')?.value.trim() || '';
+
+ const selectedPackId = requestedPackIdValue ? parseInt(requestedPackIdValue, 10) : null;
+ const selectedPackCount = requestedPackCountValue ? parseFloat(requestedPackCountValue) : null;
+ const variant = getVariantById(variantId);
+ const legacyStockOnly = isLegacyDisposeSelected();
+ const selectedPack = variant && selectedPackId
+ ? getActivePacksForVariant(variant).find(pack => pack.id === selectedPackId)
+ : null;
+
+ if (!['subunit', 'pack'].includes(disposeMode)) {
+ showToast('Please select a valid disposal mode.', 'warning');
+ return;
+ }
+
+ if (disposeMode === 'pack') {
+ if (legacyStockOnly) {
+ showToast('Whole-pack disposal is unavailable for stock that is not attached to batches.', 'warning');
+ return;
+ }
+ if (!selectedPack) {
+ showToast('Please select a pack type for whole-pack disposal.', '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 disposal requires a whole-number pack count.', 'warning');
+ return;
+ }
+ quantity = selectedPackCount * selectedPack.pack_size_in_base_units;
+ }
+
+ const allocationEntries = Array.from(document.querySelectorAll('.dispose-batch-allocation'))
+ .map(input => ({
+ batch_id: parseInt(input.dataset.batchId || '', 10),
+ entered_value: parseFloat(input.value || '0')
+ }))
+ .filter(entry => !Number.isNaN(entry.batch_id) && !Number.isNaN(entry.entered_value) && entry.entered_value > 0);
+
+ const allocations = allocationEntries.map(entry => ({
+ batch_id: entry.batch_id,
+ quantity: disposeMode === 'pack' && selectedPack
+ ? entry.entered_value * selectedPack.pack_size_in_base_units
+ : entry.entered_value
+ }));
+
+ const allocatedTotal = allocations.reduce((sum, entry) => sum + entry.quantity, 0);
+ const totalAvailableQuantity = getTotalAvailableDisposeQuantity(disposeMode, selectedPack);
+ const totalAvailablePacks = disposeMode === 'pack' ? getTotalAvailableDisposePackCount(selectedPack) : 0;
+
+ if (!variantId || Number.isNaN(quantity) || quantity <= 0) {
+ showToast('Please fill in all required fields (Drug Variant and Quantity > 0)', 'warning');
+ return;
+ }
+
+ if (quantity - totalAvailableQuantity > 1e-6) {
+ if (disposeMode === 'pack' && selectedPack) {
+ showToast(`Only ${totalAvailablePacks} full ${selectedPack.pack_unit_name} pack${totalAvailablePacks === 1 ? '' : 's'} available.`, 'warning');
+ } else {
+ showToast(`Requested quantity exceeds available stock (${formatDisplayNumber(totalAvailableQuantity)} available).`, 'warning');
+ }
+ return;
+ }
+
+ if (!legacyStockOnly && allocations.length === 0) {
+ showToast('Allocate quantity against at least one batch.', 'warning');
+ return;
+ }
+
+ if (disposeMode === 'pack' && selectedPack) {
+ const invalidPackAllocation = allocationEntries.find(entry => {
+ const batch = currentDisposeBatches.find(row => row.id === entry.batch_id);
+ const availableFullPacks = batchMatchesSelectedPack(batch, selectedPack)
+ ? Math.max(0, Math.floor(Number(batch.current_full_pack_count || 0)))
+ : 0;
+ return !batch
+ || entry.entered_value - availableFullPacks > 1e-6
+ || Math.abs(entry.entered_value - Math.round(entry.entered_value)) > 1e-6;
+ });
+
+ if (invalidPackAllocation) {
+ showToast('Whole-pack allocations must use batches with available full packs and whole-pack multiples only.', 'warning');
+ return;
+ }
+ }
+
+ if (!legacyStockOnly && Math.abs(allocatedTotal - quantity) > 1e-6) {
+ showToast('Batch allocations must exactly match the requested disposal quantity.', 'warning');
+ return;
+ }
+
+ if (!confirm(`Dispose ${formatDisplayNumber(quantity)} ${variant?.unit || 'units'} from inventory?`)) {
+ return;
+ }
+
+ try {
+ const response = await apiCall('/dispose', {
+ method: 'POST',
+ body: JSON.stringify({
+ drug_variant_id: variantId,
+ quantity,
+ dispense_mode: disposeMode,
+ requested_pack_id: disposeMode === 'pack' ? selectedPackId : null,
+ requested_pack_count: disposeMode === 'pack' ? selectedPackCount : null,
+ dispense_source: disposeSource,
+ notes: notes || null,
+ allocations
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Failed to dispose inventory');
+ }
+
+ document.getElementById('disposeInventoryForm').reset();
+ closeModal(document.getElementById('disposeInventoryModal'));
+ loadedVariantBatches.delete(variantId);
+ await loadDrugs();
+ showToast('Inventory disposed successfully.', 'success');
+ } catch (error) {
+ console.error('Error disposing inventory:', error);
+ showToast('Failed to dispose inventory: ' + error.message, 'error');
+ }
+}
+
// Open edit modal
function openEditModal(drugId) {
const drug = allDrugs.find(d => d.id === drugId);
@@ -2518,6 +3264,22 @@ function dispenseVariant(variantId) {
openModal(document.getElementById('dispenseModal'));
}
+function disposeVariant(variantId) {
+ if (currentUser?.role !== 'admin') {
+ showToast('Only admin users can dispose inventory.', 'warning');
+ return;
+ }
+
+ document.getElementById('disposeInventoryForm')?.reset();
+ updateDisposeDrugSelect();
+
+ const drugSelect = document.getElementById('disposeDrugSelect');
+ drugSelect.value = variantId;
+
+ updateDisposeBatchInfo();
+ openModal(document.getElementById('disposeInventoryModal'));
+}
+
// Handle print notes form submission
async function handlePrintNotes(e) {
e.preventDefault();
@@ -2879,21 +3641,38 @@ async function openUserManagement() {
const users = await response.json();
+ const roleOptions = [
+ { value: 'admin', label: 'Admin' },
+ { value: 'user', label: 'Regular User' },
+ { value: 'readonly', label: 'Read-Only' }
+ ];
+
const usersHtml = `
Users
${users.map(user => {
- const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
const roleBadge = user.role === 'admin' ? '👑 Admin' :
user.role === 'readonly' ? '👁️ Read-Only' : '👤 Regular';
+ const isCurrentUser = user.id === currentUser.id;
return `
-
${user.username}
-
${roleBadge}
-
- ${user.id !== currentUser.id ? `
-
- ` : ''}
+
+ ${escapeHtml(user.username)}
+ ${isCurrentUser ? 'You' : ''}
+
+
${roleBadge}
+
+
+ ${!isCurrentUser ? `
+
+
+ ` : 'Use account menu for your password'}
+
`;
}).join('')}
@@ -2901,6 +3680,15 @@ async function openUserManagement() {
`;
usersList.innerHTML = usersHtml;
+ usersList.querySelectorAll('.user-role-select').forEach(select => {
+ select.addEventListener('change', (e) => updateUserRole(e.target.dataset.userId, e.target.value));
+ });
+ usersList.querySelectorAll('.admin-password-btn').forEach(button => {
+ button.addEventListener('click', () => openAdminChangePasswordModal(button.dataset.userId, button.dataset.username));
+ });
+ usersList.querySelectorAll('.delete-user-btn').forEach(button => {
+ button.addEventListener('click', () => deleteUser(button.dataset.userId));
+ });
} catch (error) {
console.error('Error loading users:', error);
usersList.innerHTML = '
Users
Error loading users
';
@@ -2949,6 +3737,28 @@ async function createUser(e) {
}
}
+// Update user role
+async function updateUserRole(userId, role) {
+ try {
+ const response = await apiCall(`/users/${userId}/role`, {
+ method: 'PATCH',
+ body: JSON.stringify({ role })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Failed to update user role');
+ }
+
+ showToast('User role updated successfully!', 'success');
+ openUserManagement();
+ } catch (error) {
+ console.error('Error updating user role:', error);
+ showToast('Failed to update user role: ' + error.message, 'error');
+ openUserManagement();
+ }
+}
+
// Delete user
async function deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user?')) return;
diff --git a/frontend/index.html b/frontend/index.html
index 10b8707..ab7047a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -264,6 +264,95 @@
+
+
+
+
×
+
Dispose Inventory
+
+
+
+
@@ -432,7 +521,7 @@
×
User Management
-
+
+
+
+
+
+
diff --git a/frontend/styles.css b/frontend/styles.css
index 35679fe..1e8876d 100644
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -685,7 +685,8 @@ footer {
max-height: calc(100vh - 48px) !important;
}
-#dispenseModal.show {
+#dispenseModal.show,
+#disposeInventoryModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
@@ -697,19 +698,22 @@ footer {
overflow-y: auto;
}
-#dispenseForm {
+#dispenseForm,
+#disposeInventoryForm {
display: block;
padding-right: 6px;
}
#batchInfoSection,
+#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 220px;
overflow-y: auto;
min-height: fit-content;
}
-#dispenseModal .form-actions {
+#dispenseModal .form-actions,
+#disposeInventoryModal .form-actions {
margin-top: 16px;
padding-top: 14px;
background: var(--white);
@@ -1069,11 +1073,13 @@ footer {
max-height: calc(100vh - 24px);
}
- #dispenseModal.show {
+ #dispenseModal.show,
+ #disposeInventoryModal.show {
padding: 12px 0;
}
#batchInfoSection,
+ #disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 160px;
}
@@ -1195,22 +1201,29 @@ footer {
}
.user-management-content h3 {
- margin-top: 20px;
+ margin-top: 0;
margin-bottom: 15px;
color: var(--primary-color);
}
+.user-create-panel {
+ padding: 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: #f8fafc;
+}
+
.user-management-content .form-row {
- display: flex;
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
gap: 10px;
margin-bottom: 15px;
- flex-wrap: wrap;
}
.user-management-content input,
.user-management-content select {
- flex: 1;
- min-width: 150px;
+ width: 100%;
+ min-width: 0;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -1222,33 +1235,103 @@ footer {
}
.users-list {
- margin-top: 20px;
+ margin-top: 24px;
}
.users-table {
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: 8px;
}
.user-item {
- display: flex;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
align-items: center;
- padding: 12px;
- background: #f8f9fa;
+ padding: 12px 14px;
+ background: var(--white);
border: 1px solid var(--border-color);
- border-radius: 6px;
- gap: 15px;
+ border-radius: 8px;
+ gap: 12px;
+}
+
+.user-identity {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.user-identity strong {
+ overflow-wrap: anywhere;
+}
+
+.current-user-label,
+.self-note {
+ color: #64748b;
+ font-size: 0.85em;
+}
+
+.current-user-label {
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ color: #334155;
}
.admin-badge {
- padding: 4px 12px;
- background: var(--warning-color);
+ justify-self: start;
+ padding: 5px 10px;
color: var(--white);
- border-radius: 20px;
- font-size: 0.9em;
+ border-radius: 999px;
+ font-size: 0.85em;
font-weight: 500;
+ white-space: nowrap;
+}
+
+.admin-badge.role-admin {
+ background: var(--warning-color);
+}
+
+.admin-badge.role-user {
+ background: var(--secondary-color);
+}
+
+.admin-badge.role-readonly {
+ background: #64748b;
+}
+
+.role-control {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.role-control span {
+ color: #64748b;
+ font-size: 0.85em;
+}
+
+.role-control select {
+ margin: 0;
+}
+
+.role-control select:disabled {
+ color: #64748b;
+ background: #f1f5f9;
+ cursor: not-allowed;
+}
+
+.user-actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.user-actions .btn {
+ margin-bottom: 0;
}
/* Responsive Styles */
@@ -1278,6 +1361,15 @@ footer {
max-width: none;
}
+ .user-management-content .form-row,
+ .user-item {
+ grid-template-columns: 1fr;
+ }
+
+ .user-actions {
+ justify-content: flex-start;
+ }
+
.drug-item {
flex-direction: column;
}