WIP compliance

This commit is contained in:
2026-03-28 14:41:15 -04:00
parent 1c9fbbda6c
commit 0521b8dfd6
7 changed files with 1931 additions and 76 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:
+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 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)
+528 -4
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,11 @@ 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';
}
// 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 +206,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 +216,13 @@ 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 logoutBtn = document.getElementById('logoutBtn'); const logoutBtn = document.getElementById('logoutBtn');
const changePasswordBtn = document.getElementById('changePasswordBtn'); const changePasswordBtn = document.getElementById('changePasswordBtn');
@@ -227,6 +237,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', () => {
@@ -250,6 +264,12 @@ function setupEventListeners() {
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 +297,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 +310,7 @@ 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 (logoutBtn) logoutBtn.addEventListener('click', handleLogout); if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
// Search functionality // Search functionality
@@ -316,6 +341,7 @@ async function loadDrugs() {
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 +393,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 +670,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 +698,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 +718,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 +732,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 +769,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 +819,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 +833,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 +869,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 +990,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 +1268,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 {
@@ -1213,3 +1521,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');
}
}
+132 -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,14 @@
<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="logoutBtn" class="dropdown-item">🚪 Logout</button> <button id="logoutBtn" class="dropdown-item">🚪 Logout</button>
</div> </div>
</div> </div>
@@ -54,20 +55,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 +101,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 +130,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 +177,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 +482,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 +531,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>
+129 -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;
@@ -722,6 +801,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 +829,11 @@ footer {
flex: 1; flex: 1;
} }
.filter-select {
min-width: 160px;
flex: 1;
}
.drug-details { .drug-details {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -741,6 +845,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 +1036,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 +1047,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 {