WIP compliance
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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">×</span>
|
<span class="close">×</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">×</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">×</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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user