From e00669ae2c43e7df9f6804b7273d3723ea3114ef Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sat, 28 Mar 2026 15:10:11 -0400 Subject: [PATCH] WIP --- docker-compose.yml | 3 +- frontend/app.js | 16 +- frontend/index.html | 1 + frontend/reports.html | 92 +++++++ frontend/reports.js | 578 ++++++++++++++++++++++++++++++++++++++++++ frontend/styles.css | 101 ++++++++ 6 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 frontend/reports.html create mode 100644 frontend/reports.js diff --git a/docker-compose.yml b/docker-compose.yml index 2512013..9294fce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - MQTT_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print} - MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status} - LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label} + - NOTES_TEMPLATE_ID=${NOTES_TEMPLATE_ID:-notes_1} - LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_TEST=${LABEL_TEST:-false} @@ -35,7 +36,7 @@ services: frontend: image: nginx:alpine - container_name: drugsprod + container_name: drugsdev volumes: - ./frontend:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/frontend/app.js b/frontend/app.js index 3d6d977..9c86a0c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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'); diff --git a/frontend/index.html b/frontend/index.html index 888ac3a..ef9dd18 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -44,6 +44,7 @@ + diff --git a/frontend/reports.html b/frontend/reports.html new file mode 100644 index 0000000..56d704c --- /dev/null +++ b/frontend/reports.html @@ -0,0 +1,92 @@ + + + + + + Reports - Drug Inventory System + + + +
+ + + + + + + + \ No newline at end of file diff --git a/frontend/reports.js b/frontend/reports.js new file mode 100644 index 0000000..4c22b5a --- /dev/null +++ b/frontend/reports.js @@ -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 = ` + ${icons[type] || icons.info} + ${message} + `; + + 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 = ''; + users.forEach(user => { + const option = document.createElement('option'); + option.value = user; + option.textContent = user; + userFilter.appendChild(option); + }); + + drugFilter.innerHTML = ''; + 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 = '

No audit events match the selected filters.

'; + 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 ` + + ${escapeHtml(dateText)} + ${escapeHtml(userText)} + ${escapeHtml(row.action || '')} + ${escapeHtml(row.entity_type || '')} + ${escapeHtml(extractDrugLabelFromAuditRow(row))} + ${escapeHtml(formatAuditSummary(row))} + ${detailsText} + + `; + }).join(''); + + container.innerHTML = ` + + + + + + + + + + + + + ${rowsHtml} +
DateUserActionEntityDrugSummaryDetails
+ `; +} + +function renderDispensingTable(rows) { + const container = document.getElementById('reportsTableContainer'); + if (!container) return; + + if (!rows.length) { + container.innerHTML = '

No dispensing records match the selected filters.

'; + 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 ` + + ${escapeHtml(dateText)} + ${escapeHtml(row.user_name || 'unknown')} + ${escapeHtml(info.drugName)} + ${escapeHtml(info.strength || '-')} + ${escapeHtml(quantityText)} + ${escapeHtml(animal)} + ${escapeHtml(allocations)} + ${escapeHtml(notes)} + + `; + }).join(''); + + container.innerHTML = ` + + + + + + + + + + + + + + ${rowsHtml} +
DateUserDrugStrengthQuantityAnimalBatch AllocationNotes
+ `; +} + +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 = `

${loadingText}

`; + } + 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 = `

Failed to load report: ${escapeHtml(error.message)}

`; + } + 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); diff --git a/frontend/styles.css b/frontend/styles.css index 5339422..fca6877 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; }