WIP
This commit is contained in:
+15
-1
@@ -96,6 +96,11 @@ function showMainApp() {
|
||||
if (locationsBtn) {
|
||||
locationsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
const reportsBtn = document.getElementById('reportsBtn');
|
||||
if (reportsBtn) {
|
||||
reportsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Hide action buttons for read-only users
|
||||
const isReadOnly = currentUser.role === 'readonly';
|
||||
@@ -223,6 +228,7 @@ function setupEventListeners() {
|
||||
const userMenuBtn = document.getElementById('userMenuBtn');
|
||||
const adminBtn = document.getElementById('adminBtn');
|
||||
const locationsBtn = document.getElementById('locationsBtn');
|
||||
const reportsBtn = document.getElementById('reportsBtn');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||||
|
||||
@@ -260,7 +266,7 @@ function setupEventListeners() {
|
||||
|
||||
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||||
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||||
|
||||
|
||||
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
|
||||
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal')));
|
||||
|
||||
@@ -311,6 +317,7 @@ function setupEventListeners() {
|
||||
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
|
||||
if (adminBtn) adminBtn.addEventListener('click', openUserManagement);
|
||||
if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement);
|
||||
if (reportsBtn) reportsBtn.addEventListener('click', openReportsPage);
|
||||
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
|
||||
|
||||
// Search functionality
|
||||
@@ -1419,6 +1426,13 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function openReportsPage() {
|
||||
const dropdown = document.getElementById('userDropdown');
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
|
||||
window.location.href = 'reports.html';
|
||||
}
|
||||
|
||||
// User Management
|
||||
async function openUserManagement() {
|
||||
const modal = document.getElementById('userManagementModal');
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button>
|
||||
<button id="adminBtn" class="dropdown-item" style="display: none;">👤 Admin</button>
|
||||
<button id="locationsBtn" class="dropdown-item" style="display: none;">📍 Storage Locations</button>
|
||||
<button id="reportsBtn" class="dropdown-item" style="display: none;">📊 Reports</button>
|
||||
<button id="logoutBtn" class="dropdown-item">🚪 Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reports - Drug Inventory System</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<div id="reportsApp" class="main-app" style="display: none;">
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<h1>Audit Reports</h1>
|
||||
<div class="user-menu reports-user-menu">
|
||||
<span id="reportsCurrentUser">User</span>
|
||||
<button id="backToInventoryBtn" class="btn btn-small">Back To Inventory</button>
|
||||
<button id="reportsLogoutBtn" class="btn btn-small">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="list-section reports-page-section">
|
||||
<div class="section-header">
|
||||
<h2 id="reportsHeading">Dispensing History</h2>
|
||||
<div class="reports-controls reports-page-controls">
|
||||
<div class="form-group report-control">
|
||||
<label for="reportTypeSelect">Report</label>
|
||||
<select id="reportTypeSelect">
|
||||
<option value="dispensing" selected>Dispensing History</option>
|
||||
<option value="audit">Audit Trail (Raw)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group report-control">
|
||||
<label for="reportFromDate">From Date</label>
|
||||
<input type="date" id="reportFromDate">
|
||||
</div>
|
||||
<div class="form-group report-control">
|
||||
<label for="reportToDate">To Date</label>
|
||||
<input type="date" id="reportToDate">
|
||||
</div>
|
||||
<div class="form-group report-control">
|
||||
<label for="reportUserFilter">User</label>
|
||||
<select id="reportUserFilter">
|
||||
<option value="">All Users</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group report-control">
|
||||
<label for="reportDrugFilter">Drug</label>
|
||||
<select id="reportDrugFilter">
|
||||
<option value="">All Drugs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group report-control report-text-search">
|
||||
<label for="reportActionSearch">Search</label>
|
||||
<input type="text" id="reportActionSearch" placeholder="Search user, action, notes, details...">
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button>
|
||||
<button id="clearReportFiltersBtn" type="button" class="btn btn-secondary btn-small">Clear</button>
|
||||
<button id="refreshReportsBtn" type="button" class="btn btn-secondary btn-small">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportsSummary" class="reports-summary"></div>
|
||||
<div id="reportsTableContainer" class="reports-table-container">
|
||||
<p class="loading" style="padding: 14px;">Loading audit trail...</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Many Tears Confidential</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reportsErrorState" class="login-page" style="display: none;">
|
||||
<div class="login-container">
|
||||
<h1>Audit Reports</h1>
|
||||
<p id="reportsErrorMessage">Access denied.</p>
|
||||
<button id="goToLoginBtn" class="btn btn-primary">Go To Login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="reports.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -785,6 +785,98 @@ footer {
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.reports-modal-content {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.reports-controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin: 16px 0 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-control {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.report-control label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.report-control select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.report-control input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.report-control input:focus,
|
||||
.report-control select:focus {
|
||||
outline: none;
|
||||
border-color: var(--secondary-color);
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.12);
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reports-summary {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.reports-table-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.reports-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.reports-table th,
|
||||
.reports-table td {
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.reports-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
z-index: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reports-table code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.85em;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
@@ -834,6 +926,15 @@ footer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reports-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.report-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drug-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user