This commit is contained in:
2026-03-28 15:10:11 -04:00
parent 0521b8dfd6
commit e00669ae2c
6 changed files with 789 additions and 2 deletions
+578
View File
@@ -0,0 +1,578 @@
const API_URL = '/api';
let accessToken = null;
let currentUser = null;
let allDrugs = [];
let auditTrailRows = [];
let dispensingRows = [];
let activeReportType = 'dispensing';
const batchLookupById = new Map();
const loadedBatchVariants = new Set();
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => {
if (container.contains(toast)) container.removeChild(toast);
}, 300);
}, duration);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getVariantInfoById(variantId) {
for (const drug of allDrugs) {
const variant = drug.variants.find(v => v.id === variantId);
if (variant) {
return {
drugName: drug.name,
strength: variant.strength,
unit: variant.unit
};
}
}
return { drugName: 'Unknown Drug', strength: '', unit: 'units' };
}
function extractDrugLabelFromAuditRow(row) {
const details = row.details || {};
if (details.drug_name) return details.drug_name;
if (details.before?.name) return details.before.name;
if (details.after?.name) return details.after.name;
if (details.name && row.entity_type === 'drug') return details.name;
if (details.drug_id) {
const info = getVariantInfoById(details.drug_id);
if (info.drugName !== 'Unknown Drug') return info.drugName;
}
if (details.variant_id) {
const info = getVariantInfoById(details.variant_id);
if (info.drugName) return `${info.drugName}${info.strength ? ` ${info.strength}` : ''}`;
}
if (row.entity_type === 'variant' && row.entity_id) {
const info = getVariantInfoById(row.entity_id);
if (info.drugName) return `${info.drugName}${info.strength ? ` ${info.strength}` : ''}`;
}
return 'N/A';
}
function extractDrugLabelFromDispenseRow(row) {
const info = getVariantInfoById(row.drug_variant_id);
return `${info.drugName}${info.strength ? ` ${info.strength}` : ''}`;
}
function formatAuditSummary(row) {
const details = row.details || {};
if (row.action === 'dispense.create') {
const qty = details.quantity || details.dispensed_quantity || '';
const animal = details.animal_name ? ` for ${details.animal_name}` : '';
return `Dispensed ${qty}${animal}`.trim();
}
if (row.action === 'batch.create' || row.action === 'batch.update') {
const batch = details.batch_number || details.after?.batch_number || details.before?.batch_number || '';
const quantity = details.quantity || details.after?.quantity || '';
return `Batch ${batch}${quantity !== '' ? ` (qty ${quantity})` : ''}`.trim();
}
if (row.action === 'drug.create' || row.action === 'drug.update') {
const name = details.name || details.after?.name || details.before?.name || extractDrugLabelFromAuditRow(row);
return `Drug ${name}`;
}
if (row.action === 'variant.create' || row.action === 'variant.update') {
const variant = details.strength || details.after?.strength || details.before?.strength || '';
const drug = extractDrugLabelFromAuditRow(row);
return `Variant ${variant}${drug !== 'N/A' ? ` (${drug})` : ''}`.trim();
}
if (details.message) return String(details.message);
return row.action || 'Event';
}
function formatDispenseAllocation(row) {
if (row.allocations && row.allocations.length > 0) {
return row.allocations
.map(a => {
const batch = batchLookupById.get(a.batch_id);
if (batch) {
const expiry = batch.expiry_date ? new Date(batch.expiry_date).toLocaleDateString() : 'Unknown';
return `${batch.batch_number} (exp ${expiry}): ${a.quantity}`;
}
return `Batch ${a.batch_id}: ${a.quantity}`;
})
.join(', ');
}
if (row.batch_id) {
const batch = batchLookupById.get(row.batch_id);
if (batch) {
const expiry = batch.expiry_date ? new Date(batch.expiry_date).toLocaleDateString() : 'Unknown';
return `${batch.batch_number} (exp ${expiry})`;
}
return `Batch ${row.batch_id}`;
}
return 'N/A';
}
async function ensureBatchLookupForDispensing(rows) {
const variantIds = Array.from(new Set(rows.map(row => row.drug_variant_id).filter(Boolean)));
for (const variantId of variantIds) {
if (loadedBatchVariants.has(variantId)) {
continue;
}
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (!response.ok) {
continue;
}
const batches = await response.json();
batches.forEach(batch => {
batchLookupById.set(batch.id, {
batch_number: batch.batch_number,
expiry_date: batch.expiry_date
});
});
loadedBatchVariants.add(variantId);
} catch (error) {
console.error(`Failed to load batch lookup for variant ${variantId}:`, error);
}
}
}
function detailsContainsText(details, searchText) {
if (!details) return false;
try {
return JSON.stringify(details).toLowerCase().includes(searchText);
} catch {
return false;
}
}
function getActiveRows() {
return activeReportType === 'dispensing' ? dispensingRows : auditTrailRows;
}
function getRowUser(row) {
return activeReportType === 'dispensing' ? (row.user_name || 'unknown') : (row.actor_username || 'system');
}
function getRowDrug(row) {
return activeReportType === 'dispensing' ? extractDrugLabelFromDispenseRow(row) : extractDrugLabelFromAuditRow(row);
}
function getRowDate(row) {
return new Date(activeReportType === 'dispensing' ? row.dispensed_at : row.created_at);
}
function populateCommonFilters(rows) {
const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter');
if (!userFilter || !drugFilter) return;
const previousUser = userFilter.value;
const previousDrug = drugFilter.value;
const users = Array.from(new Set(rows.map(getRowUser))).sort((a, b) => a.localeCompare(b));
const drugs = Array.from(new Set(rows.map(getRowDrug).filter(label => label && label !== 'N/A'))).sort((a, b) => a.localeCompare(b));
userFilter.innerHTML = '<option value="">All Users</option>';
users.forEach(user => {
const option = document.createElement('option');
option.value = user;
option.textContent = user;
userFilter.appendChild(option);
});
drugFilter.innerHTML = '<option value="">All Drugs</option>';
drugs.forEach(drug => {
const option = document.createElement('option');
option.value = drug;
option.textContent = drug;
drugFilter.appendChild(option);
});
userFilter.value = users.includes(previousUser) ? previousUser : '';
drugFilter.value = drugs.includes(previousDrug) ? previousDrug : '';
}
function renderAuditTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No audit events match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const dateText = new Date(row.created_at).toLocaleString();
const userText = row.actor_username || 'system';
const detailsText = row.details ? escapeHtml(JSON.stringify(row.details, null, 2)) : '-';
return `
<tr>
<td>${escapeHtml(dateText)}</td>
<td>${escapeHtml(userText)}</td>
<td>${escapeHtml(row.action || '')}</td>
<td>${escapeHtml(row.entity_type || '')}</td>
<td>${escapeHtml(extractDrugLabelFromAuditRow(row))}</td>
<td>${escapeHtml(formatAuditSummary(row))}</td>
<td><code>${detailsText}</code></td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Drug</th>
<th>Summary</th>
<th>Details</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderDispensingTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No dispensing records match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const dateText = new Date(row.dispensed_at).toLocaleString();
const info = getVariantInfoById(row.drug_variant_id);
const quantityText = `${row.quantity} ${info.unit || 'units'}`;
const animal = row.animal_name || '-';
const notes = row.notes || '-';
const allocations = formatDispenseAllocation(row);
return `
<tr>
<td>${escapeHtml(dateText)}</td>
<td>${escapeHtml(row.user_name || 'unknown')}</td>
<td>${escapeHtml(info.drugName)}</td>
<td>${escapeHtml(info.strength || '-')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(animal)}</td>
<td>${escapeHtml(allocations)}</td>
<td>${escapeHtml(notes)}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Drug</th>
<th>Strength</th>
<th>Quantity</th>
<th>Animal</th>
<th>Batch Allocation</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function applyCurrentFilters() {
const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter');
const fromDateInput = document.getElementById('reportFromDate');
const toDateInput = document.getElementById('reportToDate');
const searchInput = document.getElementById('reportActionSearch');
const reportsSummary = document.getElementById('reportsSummary');
const selectedUser = userFilter ? userFilter.value : '';
const selectedDrug = drugFilter ? drugFilter.value : '';
const fromDate = fromDateInput && fromDateInput.value ? new Date(`${fromDateInput.value}T00:00:00`) : null;
const toDate = toDateInput && toDateInput.value ? new Date(`${toDateInput.value}T23:59:59`) : null;
const searchText = searchInput ? searchInput.value.trim().toLowerCase() : '';
const sourceRows = getActiveRows();
const filteredRows = sourceRows.filter(row => {
const userMatch = !selectedUser || getRowUser(row) === selectedUser;
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
const rowDate = getRowDate(row);
const fromMatch = !fromDate || rowDate >= fromDate;
const toMatch = !toDate || rowDate <= toDate;
let textMatch = true;
if (searchText) {
if (activeReportType === 'dispensing') {
const info = getVariantInfoById(row.drug_variant_id);
const haystack = [
row.user_name || '',
info.drugName || '',
info.strength || '',
row.animal_name || '',
row.notes || '',
formatDispenseAllocation(row)
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else {
const actionText = (row.action || '').toLowerCase();
const entityText = (row.entity_type || '').toLowerCase();
const summaryText = formatAuditSummary(row).toLowerCase();
textMatch = actionText.includes(searchText)
|| entityText.includes(searchText)
|| summaryText.includes(searchText)
|| detailsContainsText(row.details, searchText);
}
}
return userMatch && drugMatch && fromMatch && toMatch && textMatch;
});
if (reportsSummary) {
const reportName = activeReportType === 'dispensing' ? 'dispensing records' : 'audit events';
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
}
if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows);
} else {
renderAuditTable(filteredRows);
}
}
function updateReportHeading() {
const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch');
if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
} else {
heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...';
}
}
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
window.location.href = 'index.html';
throw new Error('Authentication expired');
}
return response;
}
async function loadReferenceData() {
try {
const drugResponse = await apiCall('/drugs');
if (drugResponse.ok) {
allDrugs = await drugResponse.json();
}
} catch (error) {
console.error('Failed to load drug reference data:', error);
}
}
async function loadActiveReport() {
const container = document.getElementById('reportsTableContainer');
const reportsSummary = document.getElementById('reportsSummary');
if (container) {
const loadingText = activeReportType === 'dispensing' ? 'Loading dispensing history...' : 'Loading audit trail...';
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
}
if (reportsSummary) reportsSummary.textContent = '';
try {
if (activeReportType === 'dispensing') {
const response = await apiCall('/dispense/history?limit=1000');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load dispensing history');
}
dispensingRows = await response.json();
await ensureBatchLookupForDispensing(dispensingRows);
populateCommonFilters(dispensingRows);
} else {
const response = await apiCall('/reports/audit-trail');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load audit trail report');
}
auditTrailRows = await response.json();
populateCommonFilters(auditTrailRows);
}
applyCurrentFilters();
} catch (error) {
console.error('Error loading report:', error);
if (container) {
container.innerHTML = `<p class="error" style="padding: 14px;">Failed to load report: ${escapeHtml(error.message)}</p>`;
}
showToast(`Failed to load report: ${error.message}`, 'error');
}
}
function showReportsPage() {
document.getElementById('reportsApp').style.display = 'block';
document.getElementById('reportsErrorState').style.display = 'none';
const userDisplay = document.getElementById('reportsCurrentUser');
if (userDisplay && currentUser) {
const roleLabel = currentUser.role.charAt(0).toUpperCase() + currentUser.role.slice(1);
userDisplay.textContent = `👤 ${currentUser.username} [${roleLabel}]`;
}
}
function showErrorState(message) {
const errorMessage = document.getElementById('reportsErrorMessage');
if (errorMessage) errorMessage.textContent = message;
document.getElementById('reportsApp').style.display = 'none';
document.getElementById('reportsErrorState').style.display = 'flex';
}
function setupEventListeners() {
const reportTypeSelect = document.getElementById('reportTypeSelect');
const applyBtn = document.getElementById('applyReportFiltersBtn');
const clearBtn = document.getElementById('clearReportFiltersBtn');
const refreshBtn = document.getElementById('refreshReportsBtn');
const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn');
const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter');
const fromDate = document.getElementById('reportFromDate');
const toDate = document.getElementById('reportToDate');
const searchInput = document.getElementById('reportActionSearch');
if (reportTypeSelect) {
reportTypeSelect.addEventListener('change', async (e) => {
activeReportType = e.target.value;
updateReportHeading();
await loadActiveReport();
});
}
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (userFilter) userFilter.value = '';
if (drugFilter) drugFilter.value = '';
if (fromDate) fromDate.value = '';
if (toDate) toDate.value = '';
if (searchInput) searchInput.value = '';
applyCurrentFilters();
});
}
if (userFilter) userFilter.addEventListener('change', applyCurrentFilters);
if (drugFilter) drugFilter.addEventListener('change', applyCurrentFilters);
if (fromDate) fromDate.addEventListener('change', applyCurrentFilters);
if (toDate) toDate.addEventListener('change', applyCurrentFilters);
if (searchInput) {
let timeout;
searchInput.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(applyCurrentFilters, 120);
});
}
if (backBtn) backBtn.addEventListener('click', () => {
window.location.href = 'index.html';
});
if (logoutBtn) logoutBtn.addEventListener('click', () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('currentUser');
window.location.href = 'index.html';
});
if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => {
window.location.href = 'index.html';
});
}
async function initializeReportsPage() {
const token = localStorage.getItem('accessToken');
const userData = localStorage.getItem('currentUser');
if (!token || !userData) {
showErrorState('You are not logged in. Please sign in first.');
return;
}
accessToken = token;
try {
currentUser = JSON.parse(userData);
} catch {
showErrorState('Invalid session data. Please sign in again.');
return;
}
if (!currentUser.role && currentUser.is_admin !== undefined) {
currentUser.role = currentUser.is_admin ? 'admin' : 'user';
}
if (currentUser.role !== 'admin') {
showErrorState('Only admin users can access reports.');
return;
}
setupEventListeners();
showReportsPage();
updateReportHeading();
await loadReferenceData();
await loadActiveReport();
}
document.addEventListener('DOMContentLoaded', initializeReportsPage);