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 from sqlalchemy.orm import sessionmaker, declarative_base
import os 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 # For SQLite, ensure the directory exists
if "sqlite" in DATABASE_URL: if "sqlite" in DATABASE_URL:
@@ -18,10 +18,6 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() 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(): def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
+1006 -37
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 sqlalchemy.sql import func
from .database import Base from .database import Base
@@ -18,6 +18,7 @@ class Drug(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False) name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
is_controlled = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), 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) id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False) 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) quantity = Column(Float, nullable=False)
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional) animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
user_name = Column(String, nullable=False) # User who dispensed user_name = Column(String, nullable=False) # User who dispensed
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
notes = Column(String, nullable=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_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print}
- MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status} - MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status}
- LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label} - LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label}
- NOTES_TEMPLATE_ID=${NOTES_TEMPLATE_ID:-notes_1}
- LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false} - LABEL_TEST=${LABEL_TEST:-false}
@@ -35,7 +36,7 @@ services:
frontend: frontend:
image: nginx:alpine image: nginx:alpine
container_name: drugsprod container_name: drugsdev
volumes: volumes:
- ./frontend:/usr/share/nginx/html:ro - ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+544 -6
View File
@@ -2,6 +2,7 @@ const API_URL = '/api';
let allDrugs = []; let allDrugs = [];
let currentDrug = null; let currentDrug = null;
let showLowStockOnly = false; let showLowStockOnly = false;
let selectedLocationFilter = '';
let searchTerm = ''; let searchTerm = '';
let expandedDrugs = new Set(); let expandedDrugs = new Set();
let currentUser = null; let currentUser = null;
@@ -91,6 +92,16 @@ function showMainApp() {
adminBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; 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 // Hide action buttons for read-only users
const isReadOnly = currentUser.role === 'readonly'; const isReadOnly = currentUser.role === 'readonly';
const addDrugBtn = document.getElementById('addDrugBtn'); const addDrugBtn = document.getElementById('addDrugBtn');
@@ -200,6 +211,7 @@ function setupEventListeners() {
const prescribeModal = document.getElementById('prescribeModal'); const prescribeModal = document.getElementById('prescribeModal');
const editModal = document.getElementById('editModal'); const editModal = document.getElementById('editModal');
const printNotesModal = document.getElementById('printNotesModal'); const printNotesModal = document.getElementById('printNotesModal');
const batchReceiveModal = document.getElementById('batchReceiveModal');
const addDrugBtn = document.getElementById('addDrugBtn'); const addDrugBtn = document.getElementById('addDrugBtn');
const dispenseBtn = document.getElementById('dispenseBtn'); const dispenseBtn = document.getElementById('dispenseBtn');
const printNotesBtn = document.getElementById('printNotesBtn'); const printNotesBtn = document.getElementById('printNotesBtn');
@@ -209,10 +221,14 @@ function setupEventListeners() {
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn'); const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn'); const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
const cancelEditBtn = document.getElementById('cancelEditBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn');
const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn');
const showAllBtn = document.getElementById('showAllBtn'); const showAllBtn = document.getElementById('showAllBtn');
const showLowStockBtn = document.getElementById('showLowStockBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn');
const locationFilterSelect = document.getElementById('locationFilterSelect');
const userMenuBtn = document.getElementById('userMenuBtn'); const userMenuBtn = document.getElementById('userMenuBtn');
const adminBtn = document.getElementById('adminBtn'); const adminBtn = document.getElementById('adminBtn');
const locationsBtn = document.getElementById('locationsBtn');
const reportsBtn = document.getElementById('reportsBtn');
const logoutBtn = document.getElementById('logoutBtn'); const logoutBtn = document.getElementById('logoutBtn');
const changePasswordBtn = document.getElementById('changePasswordBtn'); const changePasswordBtn = document.getElementById('changePasswordBtn');
@@ -227,6 +243,10 @@ function setupEventListeners() {
if (editForm) editForm.addEventListener('submit', handleEditDrug); if (editForm) editForm.addEventListener('submit', handleEditDrug);
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes); 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 (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
if (dispenseBtn) dispenseBtn.addEventListener('click', () => { if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
@@ -246,10 +266,16 @@ function setupEventListeners() {
const closeHistoryBtn = document.getElementById('closeHistoryBtn'); const closeHistoryBtn = document.getElementById('closeHistoryBtn');
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal'))); if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn'); const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal'))); 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'); const changePasswordForm = document.getElementById('changePasswordForm');
if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword); if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword);
@@ -277,6 +303,10 @@ function setupEventListeners() {
updateFilterButtons(); updateFilterButtons();
renderDrugs(); renderDrugs();
}); });
if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => {
selectedLocationFilter = e.target.value;
renderDrugs();
});
// User menu // User menu
if (userMenuBtn) userMenuBtn.addEventListener('click', () => { if (userMenuBtn) userMenuBtn.addEventListener('click', () => {
@@ -286,6 +316,8 @@ function setupEventListeners() {
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal); if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
if (adminBtn) adminBtn.addEventListener('click', openUserManagement); if (adminBtn) adminBtn.addEventListener('click', openUserManagement);
if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement);
if (reportsBtn) reportsBtn.addEventListener('click', openReportsPage);
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout); if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
// Search functionality // Search functionality
@@ -315,7 +347,8 @@ async function loadDrugs() {
const response = await apiCall('/drugs'); const response = await apiCall('/drugs');
if (!response.ok) throw new Error('Failed to load drugs'); if (!response.ok) throw new Error('Failed to load drugs');
allDrugs = await response.json(); allDrugs = await response.json();
updateLocationFilterOptions();
renderDrugs(); renderDrugs();
updateDispenseDrugSelect(); updateDispenseDrugSelect();
} catch (error) { } 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 // Render drugs list
function renderDrugs() { function renderDrugs() {
const drugsList = document.getElementById('drugsList'); 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 // Sort alphabetically by drug name
drugsToShow = drugsToShow.sort((a, b) => drugsToShow = drugsToShow.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()) a.name.toLowerCase().localeCompare(b.name.toLowerCase())
@@ -405,6 +705,7 @@ function renderDrugs() {
const isLowStock = lowStockVariants > 0; const isLowStock = lowStockVariants > 0;
const isExpanded = expandedDrugs.has(drug.id); const isExpanded = expandedDrugs.has(drug.id);
const isReadOnly = currentUser.role === 'readonly'; const isReadOnly = currentUser.role === 'readonly';
const isControlled = drug.is_controlled;
const variantsHtml = isExpanded ? ` const variantsHtml = isExpanded ? `
${drug.variants.map(variant => { ${drug.variants.map(variant => {
@@ -424,6 +725,7 @@ function renderDrugs() {
</div> </div>
<div class="variant-actions"> <div class="variant-actions">
${!isReadOnly ? ` ${!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-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-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button> <button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
@@ -437,7 +739,10 @@ function renderDrugs() {
return ` return `
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})"> <div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
<div class="drug-info"> <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-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-quantity">${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)</div>
<div class="drug-status"> <div class="drug-status">
@@ -471,7 +776,8 @@ async function handleAddDrug(e) {
const drugData = { const drugData = {
name: document.getElementById('drugName').value, name: document.getElementById('drugName').value,
description: document.getElementById('drugDescription').value description: document.getElementById('drugDescription').value,
is_controlled: document.getElementById('drugIsControlled').checked
}; };
try { try {
@@ -520,9 +826,11 @@ async function handleDispenseDrug(e) {
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
const quantity = parseFloat(document.getElementById('dispenseQuantity').value); const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
const preferredBatchIdValue = document.getElementById('dispenseBatchSelect').value;
const animalName = document.getElementById('dispenseAnimal').value; const animalName = document.getElementById('dispenseAnimal').value;
const userName = document.getElementById('dispenseUser').value; const userName = document.getElementById('dispenseUser').value;
const notes = document.getElementById('dispenseNotes').value; const notes = document.getElementById('dispenseNotes').value;
const allowSplit = document.getElementById('dispenseAllowSplit').checked;
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) { if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning'); showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
@@ -532,9 +840,11 @@ async function handleDispenseDrug(e) {
const dispensingData = { const dispensingData = {
drug_variant_id: variantId, drug_variant_id: variantId,
quantity: quantity, quantity: quantity,
batch_id: preferredBatchIdValue ? parseInt(preferredBatchIdValue) : null,
animal_name: animalName || null, animal_name: animalName || null,
user_name: userName, user_name: userName,
notes: notes || null notes: notes || null,
allow_split: allowSplit
}; };
try { try {
@@ -566,6 +876,7 @@ function openEditModal(drugId) {
document.getElementById('editDrugId').value = drug.id; document.getElementById('editDrugId').value = drug.id;
document.getElementById('editDrugName').value = drug.name; document.getElementById('editDrugName').value = drug.name;
document.getElementById('editDrugDescription').value = drug.description || ''; document.getElementById('editDrugDescription').value = drug.description || '';
document.getElementById('editDrugIsControlled').checked = drug.is_controlled || false;
document.getElementById('editModal').classList.add('show'); document.getElementById('editModal').classList.add('show');
} }
@@ -686,6 +997,9 @@ function dispenseVariant(variantId) {
const drugSelect = document.getElementById('dispenseDrugSelect'); const drugSelect = document.getElementById('dispenseDrugSelect');
drugSelect.value = variantId; drugSelect.value = variantId;
// Update batch info for selected variant
updateBatchInfo();
// Open dispense modal // Open dispense modal
openModal(document.getElementById('dispenseModal')); openModal(document.getElementById('dispenseModal'));
} }
@@ -961,7 +1275,8 @@ async function handleEditDrug(e) {
const drugId = parseInt(document.getElementById('editDrugId').value); const drugId = parseInt(document.getElementById('editDrugId').value);
const drugData = { const drugData = {
name: document.getElementById('editDrugName').value, name: document.getElementById('editDrugName').value,
description: document.getElementById('editDrugDescription').value description: document.getElementById('editDrugDescription').value,
is_controlled: document.getElementById('editDrugIsControlled').checked
}; };
try { try {
@@ -1111,6 +1426,13 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
async function openReportsPage() {
const dropdown = document.getElementById('userDropdown');
if (dropdown) dropdown.style.display = 'none';
window.location.href = 'reports.html';
}
// User Management // User Management
async function openUserManagement() { async function openUserManagement() {
const modal = document.getElementById('userManagementModal'); const modal = document.getElementById('userManagementModal');
@@ -1213,3 +1535,219 @@ async function deleteUser(userId) {
showToast('Failed to delete user: ' + error.message, 'error'); 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');
}
}
+133 -18
View File
@@ -13,7 +13,7 @@
<!-- Login Page --> <!-- Login Page -->
<div id="loginPage" class="login-page"> <div id="loginPage" class="login-page">
<div class="login-container"> <div class="login-container">
<h1>🐶 MTAR Drug Inventory System 🐶</h1> <h1>MTAR Drug Inventory System</h1>
<form id="loginForm" class="login-form"> <form id="loginForm" class="login-form">
<h2>Login</h2> <h2>Login</h2>
<div class="form-group"> <div class="form-group">
@@ -36,13 +36,15 @@
<div class="container"> <div class="container">
<header> <header>
<div class="header-top"> <div class="header-top">
<h1>🐶 MTAR Drug Inventory System 🐶</h1> <h1>MTAR Drug Inventory System</h1>
<div class="user-menu"> <div class="user-menu">
<span id="currentUser">User</span> <span id="currentUser">User</span>
<button id="userMenuBtn" class="btn btn-small"></button> <button id="userMenuBtn" class="btn btn-small"></button>
<div id="userDropdown" class="user-dropdown" style="display: none;"> <div id="userDropdown" class="user-dropdown" style="display: none;">
<button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button> <button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button>
<button id="adminBtn" class="dropdown-item" style="display: none;">👤 Admin</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> <button id="logoutBtn" class="dropdown-item">🚪 Logout</button>
</div> </div>
</div> </div>
@@ -54,20 +56,23 @@
<section id="listSection" class="list-section"> <section id="listSection" class="list-section">
<div class="section-header"> <div class="section-header">
<h2>Current Inventory</h2> <h2>Current Inventory</h2>
<div class="header-actions"> <div class="inventory-toolbar">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button> <div class="header-actions">
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button> <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> </div>
<div class="filters">
<button id="showAllBtn" class="filter-btn active">All</button>
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
</div> </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"> <div id="drugsList" class="drugs-list">
<p class="loading">Loading drugs...</p> <p class="loading">Loading drugs...</p>
@@ -97,7 +102,12 @@
<input type="text" id="editDrugDescription"> <input type="text" id="editDrugDescription">
</div> </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="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
</div> </div>
@@ -121,6 +131,13 @@
<input type="text" id="drugDescription"> <input type="text" id="drugDescription">
</div> </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;"> <hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3> <h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
@@ -161,20 +178,49 @@
<!-- Dispense Drug Modal --> <!-- Dispense Drug Modal -->
<div id="dispenseModal" class="modal"> <div id="dispenseModal" class="modal">
<div class="modal-content"> <div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span> <span class="close">&times;</span>
<h2>Dispense Drug</h2> <h2>Dispense Drug</h2>
<form id="dispenseForm" novalidate> <form id="dispenseForm" novalidate>
<div class="form-group"> <div class="form-group">
<label for="dispenseDrugSelect">Drug Variant *</label> <label for="dispenseDrugSelect">Drug Variant *</label>
<select id="dispenseDrugSelect"> <select id="dispenseDrugSelect" onchange="updateBatchInfo()">
<option value="">-- Select a drug variant --</option> <option value="">-- Select a drug variant --</option>
</select> </select>
</div> </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"> <div class="form-group">
<label for="dispenseQuantity">Quantity *</label> <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>
<div class="form-group"> <div class="form-group">
@@ -437,6 +483,32 @@
</div> </div>
</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 --> <!-- Print Notes Modal -->
<div id="printNotesModal" class="modal"> <div id="printNotesModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -460,6 +532,49 @@
</form> </form>
</div> </div>
</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> </div>
<script src="app.js"></script> <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 { header {
background: var(--primary-color); background: var(--primary-color);
color: var(--white); color: var(--white);
padding: 40px 20px; padding: 12px 20px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 30px; margin-bottom: 22px;
text-align: center; text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
@@ -152,7 +152,8 @@ header {
.header-top h1 { .header-top h1 {
flex: 1; flex: 1;
font-size: 2.5em; font-size: 3em;
line-height: 1;
} }
.user-menu { .user-menu {
@@ -214,8 +215,8 @@ header {
} }
header h1 { header h1 {
font-size: 2.5em; font-size: 3em;
margin-bottom: 10px; margin-bottom: 0;
} }
} }
@@ -368,16 +369,34 @@ textarea:focus {
margin-bottom: 15px; margin-bottom: 15px;
} }
.inventory-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: nowrap;
}
.header-actions { .header-actions {
margin-bottom: 15px;
display: flex; display: flex;
gap: 10px; gap: 10px;
width: fit-content; width: fit-content;
flex-shrink: 0;
}
.toolbar-search {
flex: 1;
min-width: 260px;
max-width: 520px;
} }
.filters { .filters {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center;
margin-left: auto;
flex-wrap: nowrap;
flex-shrink: 0;
} }
.filter-btn { .filter-btn {
@@ -388,6 +407,17 @@ textarea:focus {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-dark); 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 { .filter-btn:hover {
@@ -401,6 +431,22 @@ textarea:focus {
border-color: var(--secondary-color); 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 { .drugs-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -571,10 +617,10 @@ footer {
.search-input { .search-input {
width: 100%; width: 100%;
max-width: none; max-width: none;
padding: 12px 16px; padding: 10px 14px;
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
font-size: 1em; font-size: 0.95em;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
@@ -627,6 +673,39 @@ footer {
overflow-y: auto; 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 { .close {
color: var(--text-light); color: var(--text-light);
float: right; float: right;
@@ -706,6 +785,98 @@ footer {
color: var(--text-dark); 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 */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
main { main {
@@ -722,6 +893,26 @@ footer {
gap: 15px; 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 { .filters {
width: 100%; width: 100%;
} }
@@ -730,6 +921,20 @@ footer {
flex: 1; flex: 1;
} }
.filter-select {
min-width: 160px;
flex: 1;
}
.reports-controls {
flex-direction: column;
align-items: stretch;
}
.report-control {
min-width: 0;
}
.drug-details { .drug-details {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -741,6 +946,20 @@ footer {
.modal-content { .modal-content {
width: 95%; 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 */ /* Variants Section */
@@ -918,7 +1137,7 @@ footer {
/* Responsive Styles */ /* Responsive Styles */
@media (max-width: 768px) { @media (max-width: 768px) {
header { header {
padding: 20px 10px; padding: 10px 10px;
} }
.header-top { .header-top {
@@ -929,7 +1148,8 @@ footer {
.header-top h1 { .header-top h1 {
flex: none; flex: none;
font-size: 1.8em; font-size: 2.4em;
line-height: 1;
} }
.user-menu { .user-menu {