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} + +
`; }).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 @@
+ + +