Compare commits

...

2 Commits

Author SHA1 Message Date
jamesp e00669ae2c WIP 2026-03-28 15:10:11 -04:00
jamesp 0521b8dfd6 WIP compliance 2026-03-28 14:41:15 -04:00
10 changed files with 2720 additions and 78 deletions
+1 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./drugs.db")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/drugs.db")
# For SQLite, ensure the directory exists
if "sqlite" in DATABASE_URL:
@@ -18,10 +18,6 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Drop and recreate all tables (for development only)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
+1003 -34
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
"""
Compliance schema migration helpers.
This module applies additive migrations for SQLite databases used by this project.
It is intentionally lightweight and idempotent because the project does not yet
use Alembic-style versioned migrations.
"""
import os
import sqlite3
from pathlib import Path
DEFAULT_DB_URL = "sqlite:///./data/drugs.db"
def _resolve_sqlite_path(db_url: str) -> Path | None:
if not db_url.startswith("sqlite:///"):
print(f"Unsupported database URL for compliance migration: {db_url}")
return None
raw_path = db_url.replace("sqlite:///", "")
if raw_path.startswith("/"):
return Path(raw_path)
return Path(raw_path)
def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [row[1] for row in cursor.fetchall()]
return column_name in columns
def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
return cursor.fetchone() is not None
def migrate_compliance_schema() -> None:
"""Apply additive schema changes needed for compliance features."""
db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
db_path = _resolve_sqlite_path(db_url)
if db_path is None:
return
if not db_path.exists():
print(f"Database does not exist at {db_path}, skipping compliance migration")
return
print(f"Running compliance migration on {db_path}")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
try:
if _table_exists(cursor, "drugs") and not _column_exists(cursor, "drugs", "is_controlled"):
cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0")
print("Added drugs.is_controlled")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "batch_id"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN batch_id INTEGER")
print("Added dispensings.batch_id")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "actor_user_id"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER")
print("Added dispensings.actor_user_id")
# Seed default locations once table exists (created via SQLAlchemy create_all).
if _table_exists(cursor, "locations"):
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Fridge', 1)")
print("Ensured default locations exist")
conn.commit()
print("Compliance migration completed")
except sqlite3.Error as exc:
conn.rollback()
raise RuntimeError(f"Compliance migration failed: {exc}") from exc
finally:
conn.close()
+51 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
from sqlalchemy import Column, Integer, String, Float, DateTime, Date, ForeignKey, Boolean, Text
from sqlalchemy.sql import func
from .database import Base
@@ -18,6 +18,7 @@ class Drug(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True)
is_controlled = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
@@ -40,8 +41,57 @@ class Dispensing(Base):
id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False)
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
quantity = Column(Float, nullable=False)
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
user_name = Column(String, nullable=False) # User who dispensed
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
notes = Column(String, nullable=True)
class Location(Base):
__tablename__ = "locations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class Batch(Base):
__tablename__ = "batches"
id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
batch_number = Column(String, nullable=False, index=True)
quantity = Column(Float, nullable=False, default=0)
expiry_date = Column(Date, nullable=False, index=True)
location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True)
received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
notes = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class DispensingAllocation(Base):
__tablename__ = "dispensing_allocations"
id = Column(Integer, primary_key=True, index=True)
dispensing_id = Column(Integer, ForeignKey("dispensings.id"), nullable=False, index=True)
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=False, index=True)
quantity = Column(Float, nullable=False)
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
action = Column(String, nullable=False, index=True)
entity_type = Column(String, nullable=False, index=True)
entity_id = Column(Integer, nullable=True, index=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
actor_username = Column(String, nullable=False, index=True)
details = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+2 -1
View File
@@ -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
+542 -4
View File
@@ -2,6 +2,7 @@ const API_URL = '/api';
let allDrugs = [];
let currentDrug = null;
let showLowStockOnly = false;
let selectedLocationFilter = '';
let searchTerm = '';
let expandedDrugs = new Set();
let currentUser = null;
@@ -91,6 +92,16 @@ function showMainApp() {
adminBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none';
}
const locationsBtn = document.getElementById('locationsBtn');
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';
const addDrugBtn = document.getElementById('addDrugBtn');
@@ -200,6 +211,7 @@ function setupEventListeners() {
const prescribeModal = document.getElementById('prescribeModal');
const editModal = document.getElementById('editModal');
const printNotesModal = document.getElementById('printNotesModal');
const batchReceiveModal = document.getElementById('batchReceiveModal');
const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn');
const printNotesBtn = document.getElementById('printNotesBtn');
@@ -209,10 +221,14 @@ function setupEventListeners() {
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn');
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn');
const locationFilterSelect = document.getElementById('locationFilterSelect');
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');
@@ -227,6 +243,10 @@ function setupEventListeners() {
if (editForm) editForm.addEventListener('submit', handleEditDrug);
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
const batchReceiveForm = document.getElementById('batchReceiveForm');
if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive);
if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal));
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
@@ -250,6 +270,12 @@ function setupEventListeners() {
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal')));
const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn');
if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal')));
const createLocationForm = document.getElementById('createLocationForm');
if (createLocationForm) createLocationForm.addEventListener('submit', createLocation);
const changePasswordForm = document.getElementById('changePasswordForm');
if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword);
@@ -277,6 +303,10 @@ function setupEventListeners() {
updateFilterButtons();
renderDrugs();
});
if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => {
selectedLocationFilter = e.target.value;
renderDrugs();
});
// User menu
if (userMenuBtn) userMenuBtn.addEventListener('click', () => {
@@ -286,6 +316,8 @@ 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
@@ -316,6 +348,7 @@ async function loadDrugs() {
if (!response.ok) throw new Error('Failed to load drugs');
allDrugs = await response.json();
updateLocationFilterOptions();
renderDrugs();
updateDispenseDrugSelect();
} catch (error) {
@@ -367,6 +400,262 @@ function updateDispenseDrugSelect() {
});
}
function formatDisplayDate(value) {
if (!value) {
return 'Unknown';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString();
}
function getBatchLocationLabel(batch) {
return batch.location_name || batch.location?.name || `Location #${batch.location_id}`;
}
function updateLocationFilterOptions() {
const locationFilterSelect = document.getElementById('locationFilterSelect');
if (!locationFilterSelect) return;
const previousValue = selectedLocationFilter;
const locations = new Set();
allDrugs.forEach(drug => {
drug.variants.forEach(variant => {
(variant.batches || []).forEach(batch => {
if (batch.quantity > 0) {
locations.add(getBatchLocationLabel(batch));
}
});
});
});
locationFilterSelect.innerHTML = '<option value="">All Locations</option>';
Array.from(locations)
.sort((a, b) => a.localeCompare(b))
.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
locationFilterSelect.appendChild(option);
});
if (previousValue && locations.has(previousValue)) {
selectedLocationFilter = previousValue;
locationFilterSelect.value = previousValue;
} else {
selectedLocationFilter = '';
locationFilterSelect.value = '';
}
}
function populateDispenseBatchSelect(activeBatches) {
const batchSelect = document.getElementById('dispenseBatchSelect');
const previousValue = batchSelect.value;
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
activeBatches.forEach((batch, index) => {
const option = document.createElement('option');
const expiryLabel = formatDisplayDate(batch.expiry_date);
const locationLabel = getBatchLocationLabel(batch);
const fefoLabel = index === 0 ? ' [FEFO default]' : '';
option.value = batch.id;
option.textContent = `${batch.batch_number} | ${batch.quantity} units | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`;
batchSelect.appendChild(option);
});
if (previousValue && activeBatches.some(batch => String(batch.id) === previousValue)) {
batchSelect.value = previousValue;
}
}
// Update batch info display when variant is selected
async function updateBatchInfo() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const batchInfoSection = document.getElementById('batchInfoSection');
const batchInfoContent = document.getElementById('batchInfoContent');
const batchSelect = document.getElementById('dispenseBatchSelect');
if (!variantId) {
batchInfoSection.style.display = 'none';
batchSelect.innerHTML = '<option value="">Automatic FEFO Selection</option>';
return;
}
batchInfoSection.style.display = 'block';
batchInfoContent.innerHTML = '<p class="loading">Loading batches...</p>';
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (!response.ok) throw new Error('Failed to load batches');
const batches = await response.json();
// Filter out empty batches
const activeBatches = batches.filter(b => b.quantity > 0);
if (activeBatches.length === 0) {
populateDispenseBatchSelect([]);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available for this variant</p>';
return;
}
// Sort by expiry date (FEFO order)
activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
populateDispenseBatchSelect(activeBatches);
const batchHtml = activeBatches.map((batch, index) => {
const expiryDate = new Date(batch.expiry_date);
const locationLabel = getBatchLocationLabel(batch);
const expiryLabel = formatDisplayDate(batch.expiry_date);
const today = new Date();
const isExpired = expiryDate < today;
const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
let expiryStatus = '✓ OK';
let statusColor = '#4caf50';
if (isExpired) {
expiryStatus = '✕ EXPIRED';
statusColor = '#d32f2f';
} else if (daysToExpiry <= 7) {
expiryStatus = `⚠️ ${daysToExpiry}d left`;
statusColor = '#ff9800';
}
const isFEFO = index === 0;
return `
<div style="padding: 8px; margin: 5px 0; background: white; border: 1px solid #e0e0e0; border-radius: 3px; ${isFEFO ? 'border-left: 3px solid #2196F3; background: #f0f8ff;' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${batch.batch_number}</strong> ${isFEFO ? '<span style="background: #2196F3; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.8em; margin-left: 5px;">FIRST</span>' : ''}
<div style="font-size: 0.9em; color: #666; margin-top: 3px;">
Qty: <strong>${batch.quantity}</strong> |
Location: <strong>${escapeHtml(locationLabel)}</strong> |
Expiry: <strong>${expiryLabel}</strong> <span style="color: ${statusColor};">(${expiryStatus})</span>
</div>
</div>
</div>
</div>
`;
}).join('');
batchInfoContent.innerHTML = batchHtml;
} catch (error) {
console.error('Error loading batches:', error);
batchInfoContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error loading batches</p>';
}
// Update allocation preview when batches load
updateAllocationPreview();
}
// Update allocation preview based on quantity and allow_split flag
async function updateAllocationPreview() {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value);
const allocationPreviewSection = document.getElementById('allocationPreviewSection');
const allocationPreviewContent = document.getElementById('allocationPreviewContent');
if (!variantId || isNaN(quantity) || quantity <= 0) {
allocationPreviewSection.style.display = 'none';
return;
}
allocationPreviewSection.style.display = 'block';
allocationPreviewContent.innerHTML = '<p class="loading">Calculating allocation...</p>';
try {
const response = await apiCall(`/variants/${variantId}/batches`);
if (!response.ok) throw new Error('Failed to load batches');
const batches = await response.json();
let activeBatches = batches.filter(b => b.quantity > 0)
.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date));
if (activeBatches.length === 0) {
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">⚠️ No active batches available</p>';
return;
}
if (!Number.isNaN(preferredBatchId)) {
const preferredBatch = activeBatches.find(batch => batch.id === preferredBatchId);
if (!preferredBatch) {
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">✕ Selected preferred batch is no longer available.</p>';
return;
}
activeBatches = [preferredBatch, ...activeBatches.filter(batch => batch.id !== preferredBatchId)];
}
// Simulate FEFO allocation
const allocations = [];
let remainingQty = quantity;
for (const batch of activeBatches) {
if (remainingQty <= 0) break;
const allocQty = Math.min(remainingQty, batch.quantity);
allocations.push({
batchNumber: batch.batch_number,
batchId: batch.id,
quantity: allocQty,
location: getBatchLocationLabel(batch),
expiryDate: batch.expiry_date,
preferred: !Number.isNaN(preferredBatchId) && batch.id === preferredBatchId
});
remainingQty -= allocQty;
if (!allowSplit) break;
}
if (remainingQty > 0 && !allowSplit) {
const failureContext = !Number.isNaN(preferredBatchId)
? 'Preferred batch cannot fully satisfy this request. Enable split to fall through to FEFO batches.'
: 'Insufficient stock in first batch. Check "Allow Split" to use multiple batches.';
allocationPreviewContent.innerHTML = `<p style="color: #d32f2f; margin: 0;">✕ ${failureContext}</p>`;
return;
}
if (remainingQty > 0 && allowSplit) {
allocationPreviewContent.innerHTML = `
<p style="color: #d32f2f; margin: 0 0 10px 0;">✕ Warning: Only ${quantity - remainingQty} units available across all batches (${remainingQty} short)</p>
<div>${allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div>
`).join('')}</div>
`;
return;
}
const allocationHtml = allocations.map(a => `
<div style="padding: 5px; margin: 3px 0; background: white; border-radius: 2px; font-size: 0.9em;">
<strong>${a.batchNumber}</strong>${a.preferred ? ' <span style="color: #1565c0;">(preferred)</span>' : ''}: ${a.quantity} units (${escapeHtml(a.location)}) - Expires ${formatDisplayDate(a.expiryDate)}
</div>
`).join('');
const pluralText = allocations.length === 1 ? 'batch' : 'batches';
const introText = !Number.isNaN(preferredBatchId)
? `✓ Will start from your preferred batch, then use FEFO for any remainder across <strong>${allocations.length} ${pluralText}</strong>:`
: `✓ Will dispense from <strong>${allocations.length} ${pluralText}</strong>:`;
allocationPreviewContent.innerHTML = `
<p style="margin: 0 0 8px 0; color: #333;">${introText}</p>
<div>${allocationHtml}</div>
`;
} catch (error) {
console.error('Error calculating allocation:', error);
allocationPreviewContent.innerHTML = '<p style="color: #d32f2f; margin: 0;">Error calculating allocation</p>';
}
}
// Render drugs list
function renderDrugs() {
const drugsList = document.getElementById('drugsList');
@@ -388,6 +677,17 @@ function renderDrugs() {
);
}
// Apply location filter
if (selectedLocationFilter) {
drugsToShow = drugsToShow.filter(drug =>
drug.variants.some(variant =>
(variant.batches || []).some(batch =>
batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter
)
)
);
}
// Sort alphabetically by drug name
drugsToShow = drugsToShow.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
@@ -405,6 +705,7 @@ function renderDrugs() {
const isLowStock = lowStockVariants > 0;
const isExpanded = expandedDrugs.has(drug.id);
const isReadOnly = currentUser.role === 'readonly';
const isControlled = drug.is_controlled;
const variantsHtml = isExpanded ? `
${drug.variants.map(variant => {
@@ -424,6 +725,7 @@ function renderDrugs() {
</div>
<div class="variant-actions">
${!isReadOnly ? `
<button class="btn btn-success btn-small" onclick="openBatchReceiveModal(${variant.id})">📦 Receive Batch</button>
<button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button>
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
@@ -437,7 +739,10 @@ function renderDrugs() {
return `
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
<div class="drug-info">
<div class="drug-name">${escapeHtml(drug.name)}</div>
<div class="drug-name">
${escapeHtml(drug.name)}
${isControlled ? '<span style="background: #d32f2f; color: white; padding: 2px 6px; border-radius: 2px; font-size: 0.75em; margin-left: 8px; display: inline-block;">⚠️ CONTROLLED</span>' : ''}
</div>
<div class="drug-description">${drug.description ? escapeHtml(drug.description) : 'No description'}</div>
<div class="drug-quantity">${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)</div>
<div class="drug-status">
@@ -471,7 +776,8 @@ async function handleAddDrug(e) {
const drugData = {
name: document.getElementById('drugName').value,
description: document.getElementById('drugDescription').value
description: document.getElementById('drugDescription').value,
is_controlled: document.getElementById('drugIsControlled').checked
};
try {
@@ -520,9 +826,11 @@ async function handleDispenseDrug(e) {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
const animalName = document.getElementById('dispenseAnimal').value;
const userName = document.getElementById('dispenseUser').value;
const notes = document.getElementById('dispenseNotes').value;
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
@@ -532,9 +840,11 @@ async function handleDispenseDrug(e) {
const dispensingData = {
drug_variant_id: variantId,
quantity: quantity,
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
animal_name: animalName || null,
user_name: userName,
notes: notes || null
notes: notes || null,
allow_split: allowSplit
};
try {
@@ -566,6 +876,7 @@ function openEditModal(drugId) {
document.getElementById('editDrugId').value = drug.id;
document.getElementById('editDrugName').value = drug.name;
document.getElementById('editDrugDescription').value = drug.description || '';
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
document.getElementById('editModal').classList.add('show');
}
@@ -686,6 +997,9 @@ function dispenseVariant(variantId) {
const drugSelect = document.getElementById('dispenseDrugSelect');
drugSelect.value = variantId;
// Update batch info for selected variant
updateBatchInfo();
// Open dispense modal
openModal(document.getElementById('dispenseModal'));
}
@@ -961,7 +1275,8 @@ async function handleEditDrug(e) {
const drugId = parseInt(document.getElementById('editDrugId').value);
const drugData = {
name: document.getElementById('editDrugName').value,
description: document.getElementById('editDrugDescription').value
description: document.getElementById('editDrugDescription').value,
is_controlled: document.getElementById('editDrugIsControlled').checked
};
try {
@@ -1111,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');
@@ -1213,3 +1535,219 @@ async function deleteUser(userId) {
showToast('Failed to delete user: ' + error.message, 'error');
}
}
// Location Management
async function openLocationManagement() {
const modal = document.getElementById('locationManagementModal');
document.getElementById('newLocationName').value = '';
const locationsList = document.getElementById('locationsList');
locationsList.innerHTML = '<h3>Active Locations</h3><p class="loading">Loading locations...</p>';
try {
const response = await apiCall('/locations');
if (!response.ok) throw new Error('Failed to load locations');
const locations = await response.json();
const activeLocations = locations.filter(loc => loc.is_active);
const inactiveLocations = locations.filter(loc => !loc.is_active);
let locationsHtml = '<h3>Active Locations</h3>';
if (activeLocations.length === 0) {
locationsHtml += '<p class="empty">No active locations</p>';
} else {
locationsHtml += `<div class="locations-table">
${activeLocations.map(location => `
<div class="location-item">
<div style="flex: 1;">
<strong>${location.name}</strong>
<div style="font-size: 0.85em; color: #666;">Created: ${new Date(location.created_at).toLocaleDateString()}</div>
</div>
<button class="btn btn-danger btn-small" onclick="archiveLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Archive</button>
</div>
`).join('')}
</div>`;
}
if (inactiveLocations.length > 0) {
locationsHtml += `
<h3 style="margin-top: 20px;">Archived Locations</h3>
<div class="locations-table">
${inactiveLocations.map(location => `
<div class="location-item" style="opacity: 0.6;">
<div style="flex: 1;">
<strong>${location.name}</strong> <span style="color: #999;">(archived)</span>
</div>
<button class="btn btn-secondary btn-small" onclick="restoreLocation(${location.id}, '${location.name.replace(/'/g, "\\'")}')">Restore</button>
</div>
`).join('')}
</div>
`;
}
locationsList.innerHTML = locationsHtml;
} catch (error) {
console.error('Error loading locations:', error);
locationsList.innerHTML = '<h3>Active Locations</h3><p class="empty">Error loading locations</p>';
}
openModal(modal);
}
// Create location
async function createLocation(e) {
e.preventDefault();
const name = document.getElementById('newLocationName').value.trim();
if (!name) {
showToast('Please enter a location name', 'warning');
return;
}
try {
const response = await apiCall('/locations', {
method: 'POST',
body: JSON.stringify({ name })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create location');
}
document.getElementById('newLocationName').value = '';
showToast('Location created successfully!', 'success');
openLocationManagement();
} catch (error) {
console.error('Error creating location:', error);
showToast('Failed to create location: ' + error.message, 'error');
}
}
// Archive location
async function archiveLocation(locationId, locationName) {
if (!confirm(`Archive location "${locationName}"?\n\nYou can restore it later if needed.`)) return;
try {
const response = await apiCall(`/locations/${locationId}`, {
method: 'PUT',
body: JSON.stringify({ is_active: false })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to archive location');
}
showToast('Location archived successfully!', 'success');
openLocationManagement();
} catch (error) {
console.error('Error archiving location:', error);
showToast('Failed to archive location: ' + error.message, 'error');
}
}
// Restore location
async function restoreLocation(locationId, locationName) {
if (!confirm(`Restore location "${locationName}"?`)) return;
try {
const response = await apiCall(`/locations/${locationId}`, {
method: 'PUT',
body: JSON.stringify({ is_active: true })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to restore location');
}
showToast('Location restored successfully!', 'success');
openLocationManagement();
} catch (error) {
console.error('Error restoring location:', error);
showToast('Failed to restore location: ' + error.message, 'error');
}
}
// Batch Management
async function openBatchReceiveModal(variantId) {
const batchReceiveModal = document.getElementById('batchReceiveModal');
document.getElementById('batchReceiveForm').reset();
document.getElementById('batchVariantId').value = variantId;
// Initialize locations
await initializeBatchLocations();
openModal(batchReceiveModal);
}
async function initializeBatchLocations() {
const locationSelect = document.getElementById('batchLocation');
try {
const response = await apiCall('/locations');
if (!response.ok) throw new Error('Failed to load locations');
const locations = await response.json();
locationSelect.innerHTML = '<option value="">-- Select location --</option>';
locations.forEach(location => {
if (location.is_active) {
const option = document.createElement('option');
option.value = location.id;
option.textContent = location.name;
locationSelect.appendChild(option);
}
});
} catch (error) {
console.error('Error loading locations:', error);
showToast('Failed to load storage locations', 'error');
}
}
async function handleBatchReceive(e) {
e.preventDefault();
const variantId = parseInt(document.getElementById('batchVariantId').value);
const batchNumber = document.getElementById('batchNumber').value.trim();
const quantity = parseFloat(document.getElementById('batchQuantity').value);
const expiryDate = document.getElementById('batchExpiryDate').value;
const locationId = parseInt(document.getElementById('batchLocation').value);
const notes = document.getElementById('batchNotes').value.trim();
if (!batchNumber || isNaN(quantity) || quantity <= 0 || !expiryDate || !locationId) {
showToast('Please fill in all required fields', 'warning');
return;
}
const batchData = {
batch_number: batchNumber,
quantity: quantity,
expiry_date: expiryDate,
location_id: locationId,
notes: notes || null
};
try {
const response = await apiCall(`/variants/${variantId}/batches`, {
method: 'POST',
body: JSON.stringify(batchData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to receive batch');
}
document.getElementById('batchReceiveForm').reset();
closeModal(document.getElementById('batchReceiveModal'));
await loadDrugs();
showToast('Batch received successfully!', 'success');
} catch (error) {
console.error('Error receiving batch:', error);
showToast('Failed to receive batch: ' + error.message, 'error');
}
}
+125 -10
View File
@@ -13,7 +13,7 @@
<!-- Login Page -->
<div id="loginPage" class="login-page">
<div class="login-container">
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
<h1>MTAR Drug Inventory System</h1>
<form id="loginForm" class="login-form">
<h2>Login</h2>
<div class="form-group">
@@ -36,13 +36,15 @@
<div class="container">
<header>
<div class="header-top">
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
<h1>MTAR Drug Inventory System</h1>
<div class="user-menu">
<span id="currentUser">User</span>
<button id="userMenuBtn" class="btn btn-small"></button>
<div id="userDropdown" class="user-dropdown" style="display: none;">
<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>
@@ -54,19 +56,22 @@
<section id="listSection" class="list-section">
<div class="section-header">
<h2>Current Inventory</h2>
<div class="inventory-toolbar">
<div class="header-actions">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button>
</div>
<div class="toolbar-search">
<input type="text" id="drugSearch" placeholder="Search drugs by name..." class="search-input">
</div>
<div class="filters">
<button id="showAllBtn" class="filter-btn active">All</button>
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
<select id="locationFilterSelect" class="filter-select" aria-label="Filter by location">
<option value="">All Locations</option>
</select>
</div>
</div>
<!-- Search Section -->
<div class="search-section">
<input type="text" id="drugSearch" placeholder="🔍 Search drugs by name..." class="search-input">
</div>
<div id="drugsList" class="drugs-list">
@@ -97,7 +102,12 @@
<input type="text" id="editDrugDescription">
</div>
<div class="form-actions">
<div class="form-group">
<label>
<input type="checkbox" id="editDrugIsControlled">
This is a Controlled Substance
</label>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
</div>
@@ -121,6 +131,13 @@
<input type="text" id="drugDescription">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="drugIsControlled">
This is a Controlled Substance
</label>
</div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
@@ -161,20 +178,49 @@
<!-- Dispense Drug Modal -->
<div id="dispenseModal" class="modal">
<div class="modal-content">
<div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span>
<h2>Dispense Drug</h2>
<form id="dispenseForm" novalidate>
<div class="form-group">
<label for="dispenseDrugSelect">Drug Variant *</label>
<select id="dispenseDrugSelect">
<select id="dispenseDrugSelect" onchange="updateBatchInfo()">
<option value="">-- Select a drug variant --</option>
</select>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0;">Available Batches (FEFO Order)</h4>
<div id="batchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div>
<div class="form-group">
<label for="dispenseBatchSelect">Preferred Batch Override</label>
<select id="dispenseBatchSelect" onchange="updateAllocationPreview()">
<option value="">Automatic FEFO Selection</option>
</select>
<small style="display: block; margin-top: 6px; color: #666;">Leave on automatic to use the earliest-expiry batch first. Choose a batch here to consume that batch first instead.</small>
</div>
<div class="form-group">
<label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1">
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
Allow Split Across Multiple Batches
</label>
</div>
<div id="allocationPreviewSection" style="display: none; margin: 15px 0; padding: 10px; background: #f0f8ff; border-radius: 4px; border-left: 3px solid #2196F3;">
<h4 style="margin-top: 0;">Allocation Preview</h4>
<div id="allocationPreviewContent">
<p class="loading">Loading allocation...</p>
</div>
</div>
<div class="form-group">
@@ -437,6 +483,32 @@
</div>
</div>
<!-- Location Management Modal -->
<div id="locationManagementModal" class="modal">
<div class="modal-content modal-large">
<span class="close">&times;</span>
<h2>Storage Locations</h2>
<div class="location-management-content">
<div class="form-group">
<h3>Add New Location</h3>
<form id="createLocationForm">
<div class="form-row">
<input type="text" id="newLocationName" placeholder="Location name (e.g., Fridge, Cupboard)" required>
<button type="submit" class="btn btn-primary btn-small">Add Location</button>
</div>
</form>
</div>
<div id="locationsList" class="locations-list">
<h3>Active Locations</h3>
<p class="loading">Loading locations...</p>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="closeLocationManagementBtn">Close</button>
</div>
</div>
</div>
<!-- Print Notes Modal -->
<div id="printNotesModal" class="modal">
<div class="modal-content">
@@ -460,6 +532,49 @@
</form>
</div>
</div>
<!-- Batch Receive Modal -->
<div id="batchReceiveModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Receive New Batch</h2>
<form id="batchReceiveForm" novalidate>
<input type="hidden" id="batchVariantId">
<div class="form-group">
<label for="batchNumber">Batch Number *</label>
<input type="text" id="batchNumber" placeholder="e.g., LOT-2026-001" required>
</div>
<div class="form-group">
<label for="batchQuantity">Quantity *</label>
<input type="number" id="batchQuantity" step="0.1" required>
</div>
<div class="form-group">
<label for="batchExpiryDate">Expiry Date *</label>
<input type="date" id="batchExpiryDate" required>
</div>
<div class="form-group">
<label for="batchLocation">Storage Location *</label>
<select id="batchLocation" required>
<option value="">-- Select location --</option>
</select>
</div>
<div class="form-group">
<label for="batchNotes">Notes</label>
<input type="text" id="batchNotes" placeholder="Optional notes about this batch">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Batch</button>
<button type="button" class="btn btn-secondary" id="cancelBatchReceiveBtn">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<script src="app.js"></script>
+92
View File
@@ -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>
+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);
+230 -10
View File
@@ -136,9 +136,9 @@ body {
header {
background: var(--primary-color);
color: var(--white);
padding: 40px 20px;
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 30px;
margin-bottom: 22px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
@@ -152,7 +152,8 @@ header {
.header-top h1 {
flex: 1;
font-size: 2.5em;
font-size: 3em;
line-height: 1;
}
.user-menu {
@@ -214,8 +215,8 @@ header {
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
font-size: 3em;
margin-bottom: 0;
}
}
@@ -368,16 +369,34 @@ textarea:focus {
margin-bottom: 15px;
}
.inventory-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: nowrap;
}
.header-actions {
margin-bottom: 15px;
display: flex;
gap: 10px;
width: fit-content;
flex-shrink: 0;
}
.toolbar-search {
flex: 1;
min-width: 260px;
max-width: 520px;
}
.filters {
display: flex;
gap: 10px;
align-items: center;
margin-left: auto;
flex-wrap: nowrap;
flex-shrink: 0;
}
.filter-btn {
@@ -388,6 +407,17 @@ textarea:focus {
cursor: pointer;
transition: all 0.2s;
color: var(--text-dark);
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
min-height: 38px;
}
#showLowStockBtn {
padding-left: 14px;
padding-right: 14px;
}
.filter-btn:hover {
@@ -401,6 +431,22 @@ textarea:focus {
border-color: var(--secondary-color);
}
.filter-select {
min-width: 180px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--white);
color: var(--text-dark);
font-size: 0.95em;
}
.filter-select:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.15);
}
.drugs-list {
display: flex;
flex-direction: column;
@@ -571,10 +617,10 @@ footer {
.search-input {
width: 100%;
max-width: none;
padding: 12px 16px;
padding: 10px 14px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 1em;
font-size: 0.95em;
transition: border-color 0.2s;
}
@@ -627,6 +673,39 @@ footer {
overflow-y: auto;
}
#dispenseModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
}
.dispense-modal-content {
max-width: 780px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
#dispenseForm {
display: block;
padding-right: 6px;
}
#batchInfoSection,
#allocationPreviewSection {
max-height: 220px;
overflow-y: auto;
min-height: fit-content;
}
#dispenseModal .form-actions {
position: sticky;
bottom: 0;
margin-top: 16px;
padding-top: 14px;
background: var(--white);
border-top: 1px solid var(--border-color);
}
.close {
color: var(--text-light);
float: right;
@@ -706,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 {
@@ -722,6 +893,26 @@ footer {
gap: 15px;
}
.inventory-toolbar {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.toolbar-search {
min-width: 0;
max-width: none;
width: 100%;
}
.header-actions,
.filters {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.filters {
width: 100%;
}
@@ -730,6 +921,20 @@ footer {
flex: 1;
}
.filter-select {
min-width: 160px;
flex: 1;
}
.reports-controls {
flex-direction: column;
align-items: stretch;
}
.report-control {
min-width: 0;
}
.drug-details {
grid-template-columns: 1fr;
}
@@ -741,6 +946,20 @@ footer {
.modal-content {
width: 95%;
}
.dispense-modal-content {
max-width: 95%;
max-height: calc(100vh - 24px);
}
#dispenseModal.show {
padding: 12px 0;
}
#batchInfoSection,
#allocationPreviewSection {
max-height: 160px;
}
}
/* Variants Section */
@@ -918,7 +1137,7 @@ footer {
/* Responsive Styles */
@media (max-width: 768px) {
header {
padding: 20px 10px;
padding: 10px 10px;
}
.header-top {
@@ -929,7 +1148,8 @@ footer {
.header-top h1 {
flex: none;
font-size: 1.8em;
font-size: 2.4em;
line-height: 1;
}
.user-menu {