Disposal
This commit is contained in:
+147
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+822
-12
@@ -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) {
|
||||
<span style="${expiryStyles}">Expires ${formatDisplayDate(batch.expiry_date)}</span>
|
||||
</div>
|
||||
<div style="margin-top: 4px; color: #374151;">${escapeHtml(locationLabel)} | ${stocktakeLabel}</div>
|
||||
${expired && !isReadOnly ? `
|
||||
${expired && isAdmin ? `
|
||||
<div style="margin-top: 8px; display: flex; justify-content: flex-end;">
|
||||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeBatch(${batch.id}, '${String(batch.batch_number).replace(/'/g, "\\'")}')">Dispose Expired Batch</button>
|
||||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeBatch(${batch.id}, '${String(batch.batch_number).replace(/'/g, "\\'")}', '${String(stocktakeLabel).replace(/'/g, "\\'")}')">Dispose Expired Batch</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -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 = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||||
}
|
||||
|
||||
function updateDisposeDrugSelect() {
|
||||
const select = document.getElementById('disposeDrugSelect');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">-- Select a drug variant --</option>';
|
||||
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 = '<option value="">-- Select pack --</option>';
|
||||
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 = '<option value="">-- Select pack --</option>';
|
||||
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 = '<p style="color: #d32f2f; margin: 0;">No active batches available for this variant</p>';
|
||||
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 `
|
||||
<div style="padding: 10px; margin: 6px 0; background: white; border: 1px solid #e0e0e0; border-radius: 4px; ${index === 0 ? 'border-left: 3px solid #2196F3; background: #f8fbff;' : ''}">
|
||||
<div style="display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr) 140px; gap: 12px; align-items: end;">
|
||||
<div>
|
||||
<div><strong>${escapeHtml(batch.batch_number)}</strong>${index === 0 ? ' <span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FEFO</span>' : ''}</div>
|
||||
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||||
Available: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||||
Location: <strong>${escapeHtml(locationLabel)}</strong> |
|
||||
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.9em; color: #374151;">
|
||||
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_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 ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="disposeBatchAllocation-${batch.id}">${allocationLabel}</label>
|
||||
<input type="number" id="disposeBatchAllocation-${batch.id}" class="dispose-batch-allocation" data-batch-id="${batch.id}" min="0" max="${allocationMax}" step="${allocationStep}" value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 `
|
||||
<div style="padding: 8px; margin: 6px 0; background: white; border: 1px solid #f0d7a1; border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap;">
|
||||
<strong>${escapeHtml(batch.batch_number)}</strong>
|
||||
<span style="color: #b45309; font-weight: 600;">Expired ${formatDisplayDate(batch.expiry_date)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.9em; color: #666; margin-top: 4px;">
|
||||
Qty: <strong>${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}</strong> |
|
||||
Location: <strong>${escapeHtml(locationLabel)}</strong>
|
||||
</div>
|
||||
<div style="font-size: 0.9em; color: #374151; margin-top: 4px;">${stocktakeLabel}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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 = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Whole-pack disposal is unavailable.</div>`;
|
||||
updateDisposeAllocationSummary();
|
||||
} else {
|
||||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">No active batches available for this variant</p>';
|
||||
updateDisposeAllocationSummary();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLegacyDisposeSelected()) {
|
||||
const extraText = hasBatchDisposeStock() ? ' Batch stock is also available; switch source to allocate from batches.' : ' Dispose by quantity only.';
|
||||
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy loose stock selected.</strong> ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system.${extraText}</div>`;
|
||||
updateDisposeAllocationSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasBatchDisposeStock()) {
|
||||
renderDisposeBatchAllocationRows(currentDisposeBatches);
|
||||
autoAllocateDisposeBatches();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLegacyDisposeQuantity()) {
|
||||
batchInfoContent.innerHTML = `<div style="padding: 10px; background: #fff8e8; border: 1px solid #f3d18d; border-radius: 4px; color: #7a4f01;"><strong>Legacy stock only.</strong> ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(variant.unit || 'units')} available outside the batch system. Dispose by quantity only.</div>`;
|
||||
updateDisposeAllocationSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">No active batches available for this variant</p>';
|
||||
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 = '<option value="">-- Select pack --</option>';
|
||||
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 = '<p class="loading">Loading batches...</p>';
|
||||
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 = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
|
||||
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
|
||||
? `<span style="color: #666;">Enter a disposal quantity. ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available outside batches.</span>`
|
||||
: '<span style="color: #666;">Enter a disposal amount to allocate batches.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyStockOnly) {
|
||||
if (requestedQuantity - currentDisposeLegacyQuantity > 1e-6) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(currentDisposeLegacyQuantity)} ${escapeHtml(unitLabel)} of legacy stock is available.</span>`;
|
||||
return;
|
||||
}
|
||||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Disposing ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} from legacy stock outside batches.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'pack' && selectedPack) {
|
||||
const requestedPackCount = parseFloat(document.getElementById('disposePackCount')?.value || '0');
|
||||
if (totalAvailablePacks <= 0) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">No full ${escapeHtml(selectedPack.pack_unit_name)} packs are available to dispose.</span>`;
|
||||
return;
|
||||
}
|
||||
if (!Number.isNaN(requestedPackCount) && requestedPackCount > totalAvailablePacks) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Only ${totalAvailablePacks} full ${escapeHtml(selectedPack.pack_unit_name)} pack${totalAvailablePacks === 1 ? '' : 's'} are available.</span>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedQuantity - totalAvailableQuantity > 1e-6) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Requested ${formatDisplayNumber(requestedQuantity)} ${escapeHtml(unitLabel)} but only ${formatDisplayNumber(totalAvailableQuantity)} ${escapeHtml(unitLabel)} available.</span>`;
|
||||
return;
|
||||
}
|
||||
if (invalidInput) {
|
||||
summaryContent.innerHTML = '<span style="color: #d32f2f; font-weight: 600;">One or more batch allocations exceed available stock or are not valid full-pack amounts.</span>';
|
||||
return;
|
||||
}
|
||||
if (Math.abs(difference) <= 1e-6) {
|
||||
if (mode === 'pack' && selectedPack) {
|
||||
const requestedPackCount = parseFloat(document.getElementById('disposePackCount')?.value || '0');
|
||||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocated)} pack${allocated === 1 ? '' : 's'} of ${formatDisplayNumber(requestedPackCount)} requested (${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)}).</span>`;
|
||||
} else {
|
||||
summaryContent.innerHTML = `<span style="color: #1565c0; font-weight: 600;">Allocated ${formatDisplayNumber(allocatedQuantity)} ${escapeHtml(unitLabel)} of ${formatDisplayNumber(requestedQuantity)} requested.</span>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (difference > 0) {
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Allocate ${formatDisplayNumber(difference)} more ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||||
return;
|
||||
}
|
||||
summaryContent.innerHTML = `<span style="color: #d32f2f; font-weight: 600;">Reduce allocations by ${formatDisplayNumber(Math.abs(difference))} ${escapeHtml(unitLabel)} to match the requested total.</span>`;
|
||||
}
|
||||
|
||||
// Render drugs list
|
||||
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() {
|
||||
<div class="variant-actions">
|
||||
${!isReadOnly ? `
|
||||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); dispenseVariant(${summaryVariant.id})">💊 Dispense</button>
|
||||
${isAdmin ? `<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); disposeVariant(${summaryVariant.id})">Dispose</button>` : ''}
|
||||
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditVariantModal(${summaryVariant.id})">Edit</button>
|
||||
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteVariant(${summaryVariant.id})" title="${summaryVariant.has_inventory_history ? 'Variant has history and cannot be deleted' : ''}">Delete</button>
|
||||
` : ''}
|
||||
@@ -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 = `
|
||||
<h3>Users</h3>
|
||||
<div class="users-table">
|
||||
${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 `
|
||||
<div class="user-item">
|
||||
<span>${user.username}</span>
|
||||
<span class="admin-badge">${roleBadge}</span>
|
||||
<button class="btn btn-secondary btn-small" onclick="openAdminChangePasswordModal(${user.id}, '${escapeHtml(user.username)}')">🔑 Password</button>
|
||||
${user.id !== currentUser.id ? `
|
||||
<button class="btn btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
|
||||
` : ''}
|
||||
<div class="user-identity">
|
||||
<strong>${escapeHtml(user.username)}</strong>
|
||||
${isCurrentUser ? '<span class="current-user-label">You</span>' : ''}
|
||||
</div>
|
||||
<span class="admin-badge role-${escapeHtml(user.role)}">${roleBadge}</span>
|
||||
<label class="role-control">
|
||||
<span>Role</span>
|
||||
<select class="user-role-select" data-user-id="${user.id}" ${isCurrentUser ? 'disabled' : ''}>
|
||||
${roleOptions.map(role => `<option value="${role.value}" ${role.value === user.role ? 'selected' : ''}>${role.label}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<div class="user-actions">
|
||||
${!isCurrentUser ? `
|
||||
<button class="btn btn-secondary btn-small admin-password-btn" data-user-id="${user.id}" data-username="${escapeHtml(user.username)}">Password</button>
|
||||
<button class="btn btn-danger btn-small delete-user-btn" data-user-id="${user.id}">Delete</button>
|
||||
` : '<span class="self-note">Use account menu for your password</span>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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 = '<h3>Users</h3><p class="empty">Error loading users</p>';
|
||||
@@ -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;
|
||||
|
||||
+96
-2
@@ -264,6 +264,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dispose Inventory Modal -->
|
||||
<div id="disposeInventoryModal" class="modal">
|
||||
<div class="modal-content modal-large dispense-modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Dispose Inventory</h2>
|
||||
<form id="disposeInventoryForm" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="disposeDrugSelect">Drug Variant *</label>
|
||||
<select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
|
||||
<option value="">-- Select a drug variant --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Disposal Mode *</label>
|
||||
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="disposeMode" id="disposeModeQuantity" value="subunit" checked>
|
||||
Quantity
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="disposeMode" id="disposeModePack" value="pack">
|
||||
Whole Pack
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="disposeQuantityGroup">
|
||||
<label for="disposeQuantity">Quantity *</label>
|
||||
<input type="number" id="disposeQuantity" step="1.0">
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="disposePackRow" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="disposePackSelect">Pack Type *</label>
|
||||
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
|
||||
<option value="">-- Select pack --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="disposePackCount">Pack Count *</label>
|
||||
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
|
||||
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="disposeSourceGroup" style="display: none;">
|
||||
<label>Stock Source *</label>
|
||||
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
|
||||
Batch stock
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
|
||||
Legacy loose stock
|
||||
</label>
|
||||
</div>
|
||||
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
|
||||
</div>
|
||||
|
||||
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
|
||||
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
|
||||
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
|
||||
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
|
||||
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
|
||||
</details>
|
||||
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
|
||||
<div id="disposeAllocationSummaryContent"></div>
|
||||
</div>
|
||||
<div id="disposeBatchInfoContent">
|
||||
<p class="loading">Loading batches...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeNotes">Disposal Note</label>
|
||||
<textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Dispose</button>
|
||||
<button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Variant Modal -->
|
||||
<div id="addVariantModal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -432,7 +521,7 @@
|
||||
<span class="close">×</span>
|
||||
<h2>User Management</h2>
|
||||
<div class="user-management-content">
|
||||
<div class="form-group">
|
||||
<section class="user-create-panel">
|
||||
<h3>Create New User</h3>
|
||||
<form id="createUserForm">
|
||||
<div class="form-row">
|
||||
@@ -447,7 +536,7 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-small">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<div id="usersList" class="users-list">
|
||||
<h3>Users</h3>
|
||||
<p class="loading">Loading users...</p>
|
||||
@@ -522,6 +611,11 @@
|
||||
<input type="text" id="disposeBatchName" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="disposeBatchStockSummary">Stock to Dispose</label>
|
||||
<input type="text" id="disposeBatchStockSummary" disabled>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||
</div>
|
||||
|
||||
+113
-21
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user