This commit is contained in:
2026-06-19 17:21:07 +01:00
parent 25c3f1fa64
commit b093e1a90c
5 changed files with 1184 additions and 42 deletions
+822 -12
View File
@@ -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
View File
@@ -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">&times;</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">&times;</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
View File
@@ -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;
}