Files
2026-06-18 20:15:24 +01:00

1116 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_URL = '/api';
let accessToken = null;
let currentUser = null;
let allDrugs = [];
let auditTrailRows = [];
let dispensingRows = [];
let globalInventoryRows = [];
let batchAttentionRows = [];
let activeReportType = 'dispensing';
const batchLookupById = new Map();
const loadedBatchVariants = new Set();
function openModal(modal) {
if (!modal) return;
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove('show');
document.body.style.overflow = 'auto';
}
function resetDisposeBatchModal() {
const form = document.getElementById('disposeBatchForm');
if (form) {
form.reset();
}
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
if (batchIdInput) batchIdInput.value = '';
if (batchNameInput) batchNameInput.value = '';
}
function closeDisposeBatchModal() {
resetDisposeBatchModal();
closeModal(document.getElementById('disposeBatchModal'));
}
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() {
if (activeReportType === 'dispensing') return dispensingRows;
if (activeReportType === 'global_inventory') return globalInventoryRows;
if (activeReportType === 'batch_attention') return batchAttentionRows;
return auditTrailRows;
}
function getRowUser(row) {
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
if (activeReportType === 'global_inventory') return '';
if (activeReportType === 'batch_attention') return '';
return row.actor_username || 'system';
}
function getRowDrug(row) {
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
if (activeReportType === 'global_inventory') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
return extractDrugLabelFromAuditRow(row);
}
function getRowDate(row) {
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
if (activeReportType === 'global_inventory') return row.expiry_date ? new Date(row.expiry_date) : null;
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
return new Date(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).filter(Boolean))).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 vet = row.prescribing_vet || '-';
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(vet)}</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>Prescribing Vet</th>
<th>Batch Allocation</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderGlobalInventoryTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No inventory lines match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '-';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '');
const locationText = row.location_name || '-';
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(locationText)}</td>
<td>${escapeHtml(expiryText)}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Variant</th>
<th>Batch</th>
<th>Quantity</th>
<th>Location</th>
<th>Expiry</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderBatchAttentionTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No expired batches match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const statusText = 'Expired';
const isExpired = true;
const packState = row.current_loose_base_units > 0
? `${row.current_full_pack_count || 0} full packs + ${row.current_loose_base_units} loose ${row.unit || 'units'}`
: `${row.current_full_pack_count || 0} full packs`;
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(row.batch_number || '')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(packState)}</td>
<td>${escapeHtml(row.location || '-')}</td>
<td>${escapeHtml(expiryText)}</td>
<td>${escapeHtml(statusText)}</td>
<td>${isExpired ? `<button type="button" class="btn btn-danger btn-small" onclick="disposeBatchFromReport(${row.batch_id}, '${String(row.batch_number || '').replace(/'/g, "\\'")}')">Dispose Expired Batch</button>` : '-'}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Strength</th>
<th>Batch</th>
<th>Quantity</th>
<th>Pack State</th>
<th>Location</th>
<th>Expiry</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function disposeBatchFromReport(batchId, batchNumber) {
const modal = document.getElementById('disposeBatchModal');
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
const notesInput = document.getElementById('disposeBatchNotes');
if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
showToast('Dispose batch modal is unavailable.', 'error');
return;
}
batchIdInput.value = String(batchId);
batchNameInput.value = batchNumber;
notesInput.value = '';
openModal(modal);
}
async function handleDisposeBatch(e) {
e.preventDefault();
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
if (!batchId) {
showToast('Batch disposal context is unavailable.', 'error');
return;
}
try {
const response = await apiCall(`/batches/${batchId}/dispose`, {
method: 'POST',
body: JSON.stringify({ notes: notes || null })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to dispose batch');
}
closeDisposeBatchModal();
await loadActiveReport();
showToast('Expired batch marked as disposed.', 'success');
} catch (error) {
console.error('Error disposing batch from report:', error);
showToast('Failed to dispose batch: ' + error.message, 'error');
}
}
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 || rowDate >= fromDate;
const toMatch = !toDate || !rowDate || 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.prescribing_vet || '',
row.notes || '',
formatDispenseAllocation(row)
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'global_inventory') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.inventory_source || '',
row.location_name || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'batch_attention') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.location || '',
row.status || '',
row.unit || ''
].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'
: activeReportType === 'global_inventory'
? 'inventory lines'
: activeReportType === 'batch_attention'
? 'expired batches'
: 'audit events';
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
}
if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows);
} else if (activeReportType === 'global_inventory') {
renderGlobalInventoryTable(filteredRows);
} else if (activeReportType === 'batch_attention') {
renderBatchAttentionTable(filteredRows);
} else {
renderAuditTable(filteredRows);
}
return filteredRows;
}
function updateReportHeading() {
const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch');
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else if (activeReportType === 'global_inventory') {
heading.textContent = 'Global Inventory';
searchInput.placeholder = 'Search drug, variant, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = '';
} else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else {
heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
}
}
function formatDisplayDate(value) {
if (!value) return '-';
const date = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T00:00:00`)
: new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString();
}
function getReportFilterSummary() {
const drugFilter = document.getElementById('reportDrugFilter')?.value || '';
const fromDate = document.getElementById('reportFromDate')?.value || '';
const toDate = document.getElementById('reportToDate')?.value || '';
const searchText = document.getElementById('reportActionSearch')?.value.trim() || '';
const parts = [];
if (drugFilter) parts.push(`Drug: ${drugFilter}`);
if (fromDate) parts.push(`From: ${formatDisplayDate(fromDate)}`);
if (toDate) parts.push(`To: ${formatDisplayDate(toDate)}`);
if (searchText) parts.push(`Search: ${searchText}`);
return parts.length ? parts.join(' | ') : 'All inventory lines';
}
function getStockCheckRows() {
const previousReportType = activeReportType;
activeReportType = 'global_inventory';
const rows = applyCurrentFilters() || [];
activeReportType = previousReportType;
return rows;
}
function buildStockCheckPdfHtml(rows) {
const groupedRows = new Map();
const sortedRows = [...rows].sort((a, b) => {
const drugCompare = String(a.drug_name || '').localeCompare(String(b.drug_name || ''), undefined, { sensitivity: 'base' });
if (drugCompare !== 0) return drugCompare;
const strengthCompare = String(a.strength || '').localeCompare(String(b.strength || ''), undefined, { numeric: true, sensitivity: 'base' });
if (strengthCompare !== 0) return strengthCompare;
const expiryCompare = String(a.expiry_date || '9999-12-31').localeCompare(String(b.expiry_date || '9999-12-31'));
if (expiryCompare !== 0) return expiryCompare;
return String(a.batch_number || '').localeCompare(String(b.batch_number || ''), undefined, { numeric: true, sensitivity: 'base' });
});
sortedRows.forEach(row => {
const drugName = row.drug_name || 'Unknown Drug';
if (!groupedRows.has(drugName)) groupedRows.set(drugName, []);
groupedRows.get(drugName).push(row);
});
const generatedAt = new Date().toLocaleString();
const filterSummary = getReportFilterSummary();
const totalLines = rows.length;
const sectionsHtml = Array.from(groupedRows.entries()).map(([drugName, drugRows]) => {
const bodyHtml = drugRows.map(row => {
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '-');
const quantityText = `${row.quantity ?? 0} ${row.unit || 'units'}`;
const controlledText = row.is_controlled ? 'Yes' : 'No';
return `
<tr>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(row.location_name || '-')}</td>
<td>${escapeHtml(formatDisplayDate(row.expiry_date))}</td>
<td>${escapeHtml(controlledText)}</td>
<td class="manual-entry"></td>
<td class="manual-entry notes-cell"></td>
</tr>
`;
}).join('');
return `
<section class="drug-section">
<h2>${escapeHtml(drugName)}</h2>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Batch</th>
<th>System Qty</th>
<th>Location</th>
<th>Expiry</th>
<th>CD</th>
<th>Actual</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${bodyHtml}</tbody>
</table>
</section>
`;
}).join('');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manual Stock Check</title>
<style>
@page { size: A4 portrait; margin: 8mm; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #111827;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.5pt;
}
header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
margin-bottom: 5mm;
border-bottom: 1.5px solid #111827;
padding-bottom: 3mm;
}
h1 {
margin: 0 0 1.5mm;
font-size: 14pt;
line-height: 1.1;
}
.meta {
color: #374151;
font-size: 7.5pt;
line-height: 1.35;
}
.signoff {
min-width: 52mm;
display: grid;
gap: 2mm;
font-size: 7.5pt;
}
.signoff-line {
border-bottom: 1px solid #6b7280;
height: 5mm;
}
.empty {
border: 1px solid #d1d5db;
padding: 8mm;
color: #4b5563;
}
.drug-section {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 4mm;
}
h2 {
margin: 0;
padding: 1mm 1.5mm;
background: #e5e7eb;
border: 1px solid #9ca3af;
border-bottom: 0;
font-size: 9.5pt;
line-height: 1.15;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #9ca3af;
padding: 1.2mm 1.4mm;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
line-height: 1.2;
}
th {
background: #f3f4f6;
font-size: 6.8pt;
text-transform: uppercase;
letter-spacing: 0;
padding-top: 1mm;
padding-bottom: 1mm;
}
th:nth-child(1) { width: 13%; }
th:nth-child(2) { width: 15%; }
th:nth-child(3) { width: 12%; }
th:nth-child(4) { width: 13%; }
th:nth-child(5) { width: 10%; }
th:nth-child(6) { width: 5%; }
th:nth-child(7) { width: 12%; }
th:nth-child(8) { width: 20%; }
td.manual-entry {
height: 7mm;
background: #ffffff;
}
.notes-cell {
min-height: 7mm;
}
@media print {
body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Manual Stock Check</h1>
<div class="meta">Generated: ${escapeHtml(generatedAt)}</div>
<div class="meta">Filters: ${escapeHtml(filterSummary)}</div>
<div class="meta">Inventory lines: ${escapeHtml(String(totalLines))}</div>
</div>
<div class="signoff">
<div>Checked by<div class="signoff-line"></div></div>
<div>Date<div class="signoff-line"></div></div>
</div>
</header>
${sectionsHtml || '<div class="empty">No inventory lines match the selected filters.</div>'}
<script>
window.addEventListener('load', () => {
window.focus();
window.print();
});
</script>
</body>
</html>
`;
}
function generateStockCheckPdf() {
if (activeReportType !== 'global_inventory') {
showToast('Select Global Inventory to generate a stock check PDF.', 'warning');
return;
}
const rows = getStockCheckRows();
if (!rows.length) {
showToast('No inventory lines match the selected filters.', 'warning');
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) {
showToast('Allow pop-ups to generate the stock check PDF.', 'warning');
return;
}
printWindow.document.open();
printWindow.document.write(buildStockCheckPdfHtml(rows));
printWindow.document.close();
}
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...'
: activeReportType === 'global_inventory'
? 'Loading global inventory...'
: activeReportType === 'batch_attention'
? 'Loading expired batches...'
: '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 if (activeReportType === 'global_inventory') {
const response = await apiCall('/reports/global-inventory');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load global inventory');
}
globalInventoryRows = await response.json();
populateCommonFilters(globalInventoryRows);
} else if (activeReportType === 'batch_attention') {
const response = await apiCall('/reports/batch-attention');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load batch attention report');
}
batchAttentionRows = await response.json();
populateCommonFilters(batchAttentionRows);
} 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 stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn');
const disposeBatchForm = document.getElementById('disposeBatchForm');
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
const closeButtons = document.querySelectorAll('.close');
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 (stockCheckPdfBtn) stockCheckPdfBtn.addEventListener('click', generateStockCheckPdf);
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';
});
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
const modal = e.target.closest('.modal');
if (modal?.id === 'disposeBatchModal') {
closeDisposeBatchModal();
return;
}
closeModal(modal);
}));
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
if (e.target.id === 'disposeBatchModal') {
closeDisposeBatchModal();
return;
}
closeModal(e.target);
}
});
}
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);