first upload
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
node_modules/
|
||||||
|
.npm
|
||||||
|
.cache/
|
||||||
|
.pytest_cache/
|
||||||
|
drugs.db
|
||||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
30
backend/app/database.py
Normal file
30
backend/app/database.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./drugs.db")
|
||||||
|
|
||||||
|
# For SQLite, ensure the directory exists
|
||||||
|
if "sqlite" in DATABASE_URL:
|
||||||
|
# Extract path from sqlite:/// URL
|
||||||
|
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Drop and recreate all tables (for development only)
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
314
backend/app/main.py
Normal file
314
backend/app/main.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException, APIRouter
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from .database import engine, get_db, Base
|
||||||
|
from .models import Drug, DrugVariant, Dispensing
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Drug Inventory API")
|
||||||
|
|
||||||
|
# CORS middleware for frontend
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # In production, restrict this
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a router with /api prefix
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
# Pydantic schemas
|
||||||
|
class DrugCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str = None
|
||||||
|
|
||||||
|
class DrugUpdate(BaseModel):
|
||||||
|
name: str = None
|
||||||
|
description: str = None
|
||||||
|
|
||||||
|
class DrugResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class DrugVariantCreate(BaseModel):
|
||||||
|
strength: str
|
||||||
|
quantity: float
|
||||||
|
unit: str = "units"
|
||||||
|
low_stock_threshold: float = 10
|
||||||
|
|
||||||
|
class DrugVariantUpdate(BaseModel):
|
||||||
|
strength: str = None
|
||||||
|
quantity: float = None
|
||||||
|
unit: str = None
|
||||||
|
low_stock_threshold: float = None
|
||||||
|
|
||||||
|
class DrugVariantResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
drug_id: int
|
||||||
|
strength: str
|
||||||
|
quantity: float
|
||||||
|
unit: str
|
||||||
|
low_stock_threshold: float
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class DrugWithVariantsResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str = None
|
||||||
|
variants: List[DrugVariantResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class DispensingCreate(BaseModel):
|
||||||
|
drug_variant_id: int
|
||||||
|
quantity: float
|
||||||
|
animal_name: str
|
||||||
|
user_name: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class DispensingResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
drug_variant_id: int
|
||||||
|
quantity: float
|
||||||
|
animal_name: str
|
||||||
|
user_name: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
dispensed_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@router.get("/")
|
||||||
|
def read_root():
|
||||||
|
return {"message": "Drug Inventory API"}
|
||||||
|
|
||||||
|
@router.get("/drugs", response_model=List[DrugWithVariantsResponse])
|
||||||
|
def list_drugs(db: Session = Depends(get_db)):
|
||||||
|
"""Get all drugs with their variants"""
|
||||||
|
drugs = db.query(Drug).all()
|
||||||
|
result = []
|
||||||
|
for drug in drugs:
|
||||||
|
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||||
|
drug_dict = drug.__dict__.copy()
|
||||||
|
drug_dict['variants'] = variants
|
||||||
|
result.append(drug_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
||||||
|
def low_stock_drugs(db: Session = Depends(get_db)):
|
||||||
|
"""Get drugs with low stock variants"""
|
||||||
|
# Get variants that are low on stock
|
||||||
|
low_stock_variants = db.query(DrugVariant).filter(
|
||||||
|
DrugVariant.quantity <= DrugVariant.low_stock_threshold
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get unique drug IDs
|
||||||
|
drug_ids = list(set(v.drug_id for v in low_stock_variants))
|
||||||
|
drugs = db.query(Drug).filter(Drug.id.in_(drug_ids)).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for drug in drugs:
|
||||||
|
variants = [v for v in low_stock_variants if v.drug_id == drug.id]
|
||||||
|
drug_dict = drug.__dict__.copy()
|
||||||
|
drug_dict['variants'] = variants
|
||||||
|
result.append(drug_dict)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
||||||
|
def get_drug(drug_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get a specific drug with its variants"""
|
||||||
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
|
if not drug:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
|
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||||
|
drug_dict = drug.__dict__.copy()
|
||||||
|
drug_dict['variants'] = variants
|
||||||
|
return drug_dict
|
||||||
|
|
||||||
|
@router.post("/drugs", response_model=DrugWithVariantsResponse)
|
||||||
|
def create_drug(drug: DrugCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Create a new drug"""
|
||||||
|
# Check if drug name already exists
|
||||||
|
existing = db.query(Drug).filter(Drug.name == drug.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Drug with this name already exists")
|
||||||
|
|
||||||
|
db_drug = Drug(name=drug.name, description=drug.description)
|
||||||
|
db.add(db_drug)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_drug)
|
||||||
|
|
||||||
|
# Return drug with empty variants list
|
||||||
|
drug_dict = db_drug.__dict__.copy()
|
||||||
|
drug_dict['variants'] = []
|
||||||
|
return drug_dict
|
||||||
|
|
||||||
|
@router.put("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
||||||
|
def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Update a drug"""
|
||||||
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
|
if not drug:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
|
for field, value in drug_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(drug, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(drug)
|
||||||
|
|
||||||
|
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||||
|
drug_dict = drug.__dict__.copy()
|
||||||
|
drug_dict['variants'] = variants
|
||||||
|
return drug_dict
|
||||||
|
|
||||||
|
@router.delete("/drugs/{drug_id}")
|
||||||
|
def delete_drug(drug_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Delete a drug and all its variants"""
|
||||||
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
|
if not drug:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
|
# Delete all variants first
|
||||||
|
db.query(DrugVariant).filter(DrugVariant.drug_id == drug_id).delete()
|
||||||
|
# Delete the drug
|
||||||
|
db.delete(drug)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Drug and all variants deleted successfully"}
|
||||||
|
|
||||||
|
# Drug Variant endpoints
|
||||||
|
@router.post("/drugs/{drug_id}/variants", response_model=DrugVariantResponse)
|
||||||
|
def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Create a new variant for a drug"""
|
||||||
|
# Check if drug exists
|
||||||
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
|
if not drug:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
|
# Check if variant with same strength already exists for this drug
|
||||||
|
existing = db.query(DrugVariant).filter(
|
||||||
|
DrugVariant.drug_id == drug_id,
|
||||||
|
DrugVariant.strength == variant.strength
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Variant with this strength already exists for this drug")
|
||||||
|
|
||||||
|
db_variant = DrugVariant(
|
||||||
|
drug_id=drug_id,
|
||||||
|
strength=variant.strength,
|
||||||
|
quantity=variant.quantity,
|
||||||
|
unit=variant.unit,
|
||||||
|
low_stock_threshold=variant.low_stock_threshold
|
||||||
|
)
|
||||||
|
db.add(db_variant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_variant)
|
||||||
|
return db_variant
|
||||||
|
|
||||||
|
@router.get("/variants/{variant_id}", response_model=DrugVariantResponse)
|
||||||
|
def get_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get a specific drug variant"""
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
return variant
|
||||||
|
|
||||||
|
@router.put("/variants/{variant_id}", response_model=DrugVariantResponse)
|
||||||
|
def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Update a drug variant"""
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
for field, value in variant_update.dict(exclude_unset=True).items():
|
||||||
|
setattr(variant, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(variant)
|
||||||
|
return variant
|
||||||
|
|
||||||
|
@router.delete("/variants/{variant_id}")
|
||||||
|
def delete_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Delete a drug variant"""
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
db.delete(variant)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Drug variant deleted successfully"}
|
||||||
|
|
||||||
|
# Dispensing endpoints
|
||||||
|
@router.post("/dispense", response_model=DispensingResponse)
|
||||||
|
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Record a drug dispensing and reduce inventory"""
|
||||||
|
# Check if drug variant exists
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
# Check if enough quantity available
|
||||||
|
if variant.quantity < dispensing.quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reduce variant quantity
|
||||||
|
variant.quantity -= dispensing.quantity
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create dispensing record
|
||||||
|
db_dispensing = Dispensing(**dispensing.dict())
|
||||||
|
db.add(db_dispensing)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_dispensing)
|
||||||
|
return db_dispensing
|
||||||
|
|
||||||
|
@router.get("/dispense/history", response_model=List[DispensingResponse])
|
||||||
|
def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
|
"""Get dispensing records (audit log)"""
|
||||||
|
return db.query(Dispensing).order_by(Dispensing.dispensed_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
@router.get("/drugs/{drug_id}/dispense/history", response_model=List[DispensingResponse])
|
||||||
|
def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get dispensing history for a specific drug (all variants)"""
|
||||||
|
# Verify drug exists
|
||||||
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
|
if not drug:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
|
# Get all variant IDs for this drug
|
||||||
|
variant_ids = db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).subquery()
|
||||||
|
|
||||||
|
return db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).order_by(Dispensing.dispensed_at.desc()).all()
|
||||||
|
|
||||||
|
@router.get("/variants/{variant_id}/dispense/history", response_model=List[DispensingResponse])
|
||||||
|
def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get dispensing history for a specific drug variant"""
|
||||||
|
# Verify variant exists
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
return db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all()
|
||||||
|
|
||||||
|
# Include router with /api prefix
|
||||||
|
app.include_router(router)
|
||||||
37
backend/app/models.py
Normal file
37
backend/app/models.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
class Drug(Base):
|
||||||
|
__tablename__ = "drugs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
description = 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 DrugVariant(Base):
|
||||||
|
__tablename__ = "drug_variants"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
drug_id = Column(Integer, ForeignKey("drugs.id"), nullable=False)
|
||||||
|
strength = Column(String, nullable=False) # e.g., "5.4mg", "16mg", "100ml"
|
||||||
|
quantity = Column(Float, nullable=False, default=0)
|
||||||
|
unit = Column(String, nullable=False, default="units") # tablets, bottles, ml, etc
|
||||||
|
low_stock_threshold = Column(Float, nullable=False, default=10)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Dispensing(Base):
|
||||||
|
__tablename__ = "dispensings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False)
|
||||||
|
quantity = Column(Float, nullable=False)
|
||||||
|
animal_name = Column(String, nullable=False) # Name/ID of the animal
|
||||||
|
user_name = Column(String, nullable=False) # User who dispensed
|
||||||
|
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
pydantic==2.5.0
|
||||||
|
python-multipart==0.0.6
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/app/app
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:///./data/drugs.db
|
||||||
|
command: uvicorn app.main:app --host ${BACKEND_HOST} --port 8000 --reload
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT}:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
567
frontend/app.js
Normal file
567
frontend/app.js
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
const API_URL = '/api';
|
||||||
|
let allDrugs = [];
|
||||||
|
let currentDrug = null;
|
||||||
|
let showLowStockOnly = false;
|
||||||
|
let searchTerm = '';
|
||||||
|
let expandedDrugs = new Set();
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const drugForm = document.getElementById('drugForm');
|
||||||
|
const variantForm = document.getElementById('variantForm');
|
||||||
|
const editVariantForm = document.getElementById('editVariantForm');
|
||||||
|
const dispenseForm = document.getElementById('dispenseForm');
|
||||||
|
const editForm = document.getElementById('editForm');
|
||||||
|
const addModal = document.getElementById('addModal');
|
||||||
|
const addVariantModal = document.getElementById('addVariantModal');
|
||||||
|
const editVariantModal = document.getElementById('editVariantModal');
|
||||||
|
const dispenseModal = document.getElementById('dispenseModal');
|
||||||
|
const editModal = document.getElementById('editModal');
|
||||||
|
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||||
|
const cancelAddBtn = document.getElementById('cancelAddBtn');
|
||||||
|
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
|
||||||
|
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
|
||||||
|
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
|
||||||
|
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||||||
|
const showAllBtn = document.getElementById('showAllBtn');
|
||||||
|
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||||||
|
|
||||||
|
// Modal close buttons
|
||||||
|
const closeButtons = document.querySelectorAll('.close');
|
||||||
|
|
||||||
|
drugForm.addEventListener('submit', handleAddDrug);
|
||||||
|
variantForm.addEventListener('submit', handleAddVariant);
|
||||||
|
editVariantForm.addEventListener('submit', handleEditVariant);
|
||||||
|
dispenseForm.addEventListener('submit', handleDispenseDrug);
|
||||||
|
editForm.addEventListener('submit', handleEditDrug);
|
||||||
|
|
||||||
|
addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||||
|
|
||||||
|
cancelAddBtn.addEventListener('click', () => closeModal(addModal));
|
||||||
|
cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
||||||
|
cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
||||||
|
cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||||||
|
cancelEditBtn.addEventListener('click', closeEditModal);
|
||||||
|
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||||||
|
closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||||||
|
|
||||||
|
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||||||
|
const modal = e.target.closest('.modal');
|
||||||
|
closeModal(modal);
|
||||||
|
}));
|
||||||
|
|
||||||
|
showAllBtn.addEventListener('click', () => {
|
||||||
|
showLowStockOnly = false;
|
||||||
|
updateFilterButtons();
|
||||||
|
renderDrugs();
|
||||||
|
});
|
||||||
|
showLowStockBtn.addEventListener('click', () => {
|
||||||
|
showLowStockOnly = true;
|
||||||
|
updateFilterButtons();
|
||||||
|
renderDrugs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
const drugSearch = document.getElementById('drugSearch');
|
||||||
|
if (drugSearch) {
|
||||||
|
let searchTimeout;
|
||||||
|
drugSearch.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchTerm = e.target.value.toLowerCase().trim();
|
||||||
|
renderDrugs();
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('modal')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadDrugs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load drugs from API
|
||||||
|
async function loadDrugs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/drugs`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load drugs');
|
||||||
|
allDrugs = await response.json();
|
||||||
|
|
||||||
|
renderDrugs();
|
||||||
|
updateDispenseDrugSelect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading drugs:', error);
|
||||||
|
document.getElementById('drugsList').innerHTML =
|
||||||
|
'<p class="empty">Error loading drugs. Make sure the backend is running on http://localhost:8000</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal utility functions
|
||||||
|
function openModal(modal) {
|
||||||
|
modal.classList.add('show');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
closeModal(document.getElementById('editModal'));
|
||||||
|
}
|
||||||
|
function updateDispenseDrugSelect() {
|
||||||
|
const select = document.getElementById('dispenseDrugSelect');
|
||||||
|
select.innerHTML = '<option value="">-- Select a drug variant --</option>';
|
||||||
|
|
||||||
|
allDrugs.forEach(drug => {
|
||||||
|
drug.variants.forEach(variant => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = variant.id;
|
||||||
|
option.textContent = `${drug.name} ${variant.strength} (${variant.quantity} ${variant.unit})`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render drugs list
|
||||||
|
function renderDrugs() {
|
||||||
|
const drugsList = document.getElementById('drugsList');
|
||||||
|
let drugsToShow = allDrugs;
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
drugsToShow = drugsToShow.filter(drug =>
|
||||||
|
drug.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
(drug.description && drug.description.toLowerCase().includes(searchTerm)) ||
|
||||||
|
drug.variants.some(variant => variant.strength.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply stock filter
|
||||||
|
if (showLowStockOnly) {
|
||||||
|
drugsToShow = drugsToShow.filter(drug =>
|
||||||
|
drug.variants.some(variant => variant.quantity <= variant.low_stock_threshold)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drugsToShow.length === 0) {
|
||||||
|
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drugsList.innerHTML = drugsToShow.map(drug => {
|
||||||
|
const totalVariants = drug.variants.length;
|
||||||
|
const lowStockVariants = drug.variants.filter(v => v.quantity <= v.low_stock_threshold).length;
|
||||||
|
const totalQuantity = drug.variants.reduce((sum, v) => sum + v.quantity, 0);
|
||||||
|
const isLowStock = lowStockVariants > 0;
|
||||||
|
const isExpanded = expandedDrugs.has(drug.id);
|
||||||
|
|
||||||
|
const variantsHtml = isExpanded ? `
|
||||||
|
${drug.variants.map(variant => {
|
||||||
|
const variantIsLowStock = variant.quantity <= variant.low_stock_threshold;
|
||||||
|
return `
|
||||||
|
<div class="variant-item ${variantIsLowStock ? 'low-stock' : ''}">
|
||||||
|
<div class="variant-info">
|
||||||
|
<div class="variant-details">
|
||||||
|
<div class="variant-name">${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}</div>
|
||||||
|
<div class="variant-quantity">${variant.quantity} ${escapeHtml(variant.unit)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="variant-status">
|
||||||
|
<span class="variant-badge ${variantIsLowStock ? 'badge-low' : 'badge-normal'}">
|
||||||
|
${variantIsLowStock ? 'Low Stock' : 'OK'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="variant-actions">
|
||||||
|
<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-danger btn-small" onclick="deleteVariant(${variant.id})">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="drug-item ${isLowStock ? 'low-stock' : ''} ${isExpanded ? 'expanded' : ''}" onclick="toggleDrugExpansion(${drug.id})">
|
||||||
|
<div class="drug-info">
|
||||||
|
<div class="drug-name">${escapeHtml(drug.name)}</div>
|
||||||
|
<div class="drug-description">${drug.description ? escapeHtml(drug.description) : 'No description'}</div>
|
||||||
|
<div class="drug-quantity">${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)</div>
|
||||||
|
<div class="drug-status">
|
||||||
|
<span class="drug-badge ${isLowStock ? 'badge-low' : 'badge-normal'}">
|
||||||
|
${isLowStock ? `${lowStockVariants} low` : 'All OK'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drug-actions">
|
||||||
|
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})">➕ Add</button>
|
||||||
|
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
|
||||||
|
<button class="btn btn-warning btn-small" onclick="event.stopPropagation(); openEditModal(${drug.id})">Edit Drug</button>
|
||||||
|
<button class="btn btn-danger btn-small" onclick="event.stopPropagation(); deleteDrug(${drug.id})">Delete</button>
|
||||||
|
<span class="expand-icon">${isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drug-variants ${isExpanded ? 'expanded' : ''}" id="variants-${drug.id}">
|
||||||
|
${variantsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add drug form
|
||||||
|
async function handleAddDrug(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const drugData = {
|
||||||
|
name: document.getElementById('drugName').value,
|
||||||
|
description: document.getElementById('drugDescription').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/drugs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(drugData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to add drug');
|
||||||
|
|
||||||
|
document.getElementById('drugForm').reset();
|
||||||
|
closeModal(document.getElementById('addModal'));
|
||||||
|
await loadDrugs();
|
||||||
|
alert('Drug added successfully! Now add variants for this drug.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding drug:', error);
|
||||||
|
alert('Failed to add drug. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dispense drug form
|
||||||
|
async function handleDispenseDrug(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const variantId = parseInt(document.getElementById('dispenseDrugSelect').value);
|
||||||
|
const quantity = parseFloat(document.getElementById('dispenseQuantity').value);
|
||||||
|
const animalName = document.getElementById('dispenseAnimal').value;
|
||||||
|
const userName = document.getElementById('dispenseUser').value;
|
||||||
|
const notes = document.getElementById('dispenseNotes').value;
|
||||||
|
|
||||||
|
if (!variantId || !quantity || !animalName || !userName) {
|
||||||
|
alert('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispensingData = {
|
||||||
|
drug_variant_id: variantId,
|
||||||
|
quantity: quantity,
|
||||||
|
animal_name: animalName,
|
||||||
|
user_name: userName,
|
||||||
|
notes: notes || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dispense`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(dispensingData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to dispense drug');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dispenseForm').reset();
|
||||||
|
closeModal(document.getElementById('dispenseModal'));
|
||||||
|
await loadDrugs();
|
||||||
|
alert('Drug dispensed successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error dispensing drug:', error);
|
||||||
|
alert('Failed to dispense drug: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
function openEditModal(drugId) {
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
if (!drug) return;
|
||||||
|
|
||||||
|
document.getElementById('editDrugId').value = drug.id;
|
||||||
|
document.getElementById('editDrugName').value = drug.name;
|
||||||
|
document.getElementById('editDrugDescription').value = drug.description || '';
|
||||||
|
|
||||||
|
document.getElementById('editModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close edit modal
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('editModal').classList.remove('show');
|
||||||
|
document.getElementById('editForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show variants for a drug
|
||||||
|
function toggleDrugExpansion(drugId) {
|
||||||
|
if (expandedDrugs.has(drugId)) {
|
||||||
|
expandedDrugs.delete(drugId);
|
||||||
|
} else {
|
||||||
|
expandedDrugs.add(drugId);
|
||||||
|
}
|
||||||
|
renderDrugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open add variant modal
|
||||||
|
function openAddVariantModal(drugId) {
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
if (!drug) return;
|
||||||
|
|
||||||
|
currentDrug = drug;
|
||||||
|
document.getElementById('variantDrugId').value = drug.id;
|
||||||
|
document.getElementById('addVariantModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle add variant form
|
||||||
|
async function handleAddVariant(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const drugId = parseInt(document.getElementById('variantDrugId').value);
|
||||||
|
const variantData = {
|
||||||
|
strength: document.getElementById('variantStrength').value,
|
||||||
|
quantity: parseFloat(document.getElementById('variantQuantity').value),
|
||||||
|
unit: document.getElementById('variantUnit').value,
|
||||||
|
low_stock_threshold: parseFloat(document.getElementById('variantThreshold').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/drugs/${drugId}/variants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(variantData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to add variant');
|
||||||
|
|
||||||
|
document.getElementById('variantForm').reset();
|
||||||
|
closeModal(document.getElementById('addVariantModal'));
|
||||||
|
await loadDrugs();
|
||||||
|
renderDrugs();
|
||||||
|
alert('Variant added successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding variant:', error);
|
||||||
|
alert('Failed to add variant. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit variant modal
|
||||||
|
function openEditVariantModal(variantId) {
|
||||||
|
const variant = currentDrug.variants.find(v => v.id === variantId);
|
||||||
|
if (!variant) return;
|
||||||
|
|
||||||
|
document.getElementById('editVariantId').value = variant.id;
|
||||||
|
document.getElementById('editVariantStrength').value = variant.strength;
|
||||||
|
document.getElementById('editVariantQuantity').value = variant.quantity;
|
||||||
|
document.getElementById('editVariantUnit').value = variant.unit;
|
||||||
|
document.getElementById('editVariantThreshold').value = variant.low_stock_threshold;
|
||||||
|
|
||||||
|
document.getElementById('editVariantModal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edit variant form
|
||||||
|
async function handleEditVariant(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const variantId = parseInt(document.getElementById('editVariantId').value);
|
||||||
|
const variantData = {
|
||||||
|
strength: document.getElementById('editVariantStrength').value,
|
||||||
|
quantity: parseFloat(document.getElementById('editVariantQuantity').value),
|
||||||
|
unit: document.getElementById('editVariantUnit').value,
|
||||||
|
low_stock_threshold: parseFloat(document.getElementById('editVariantThreshold').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/variants/${variantId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(variantData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update variant');
|
||||||
|
|
||||||
|
closeModal(document.getElementById('editVariantModal'));
|
||||||
|
await loadDrugs();
|
||||||
|
renderDrugs();
|
||||||
|
alert('Variant updated successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating variant:', error);
|
||||||
|
alert('Failed to update variant. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispense from variant
|
||||||
|
function dispenseVariant(variantId) {
|
||||||
|
// Update the dropdown display with all variants
|
||||||
|
updateDispenseDrugSelect();
|
||||||
|
|
||||||
|
// Pre-select the variant in the dispense modal
|
||||||
|
const drugSelect = document.getElementById('dispenseDrugSelect');
|
||||||
|
drugSelect.value = variantId;
|
||||||
|
|
||||||
|
// Open dispense modal
|
||||||
|
openModal(document.getElementById('dispenseModal'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete variant
|
||||||
|
async function deleteVariant(variantId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this variant?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/variants/${variantId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete variant');
|
||||||
|
|
||||||
|
await loadDrugs();
|
||||||
|
renderDrugs();
|
||||||
|
alert('Variant deleted successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting variant:', error);
|
||||||
|
alert('Failed to delete variant. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show dispensing history for a drug
|
||||||
|
async function showDrugHistory(drugId) {
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
if (!drug) return;
|
||||||
|
|
||||||
|
const historyModal = document.getElementById('historyModal');
|
||||||
|
const historyContent = document.getElementById('historyContent');
|
||||||
|
document.getElementById('historyDrugName').textContent = drug.name;
|
||||||
|
|
||||||
|
historyContent.innerHTML = '<p class="loading">Loading history...</p>';
|
||||||
|
openModal(historyModal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/dispense/history`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch history');
|
||||||
|
|
||||||
|
const allHistory = await response.json();
|
||||||
|
|
||||||
|
// Filter history for this drug's variants
|
||||||
|
const variantIds = drug.variants.map(v => v.id);
|
||||||
|
const drugHistory = allHistory.filter(item => variantIds.includes(item.drug_variant_id));
|
||||||
|
|
||||||
|
if (drugHistory.length === 0) {
|
||||||
|
historyContent.innerHTML = '<p class="empty">No dispensing history for this drug.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by dispensed_at descending (most recent first)
|
||||||
|
drugHistory.sort((a, b) => new Date(b.dispensed_at) - new Date(a.dispensed_at));
|
||||||
|
|
||||||
|
const historyHtml = drugHistory.map(item => {
|
||||||
|
const variant = drug.variants.find(v => v.id === item.drug_variant_id);
|
||||||
|
const date = new Date(item.dispensed_at).toLocaleDateString();
|
||||||
|
const time = new Date(item.dispensed_at).toLocaleTimeString();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-header">
|
||||||
|
<div class="history-variant">${drug.name} ${variant.strength}</div>
|
||||||
|
<div class="history-datetime">${date} ${time}</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-details">
|
||||||
|
<div class="history-row">
|
||||||
|
<span class="history-label">Quantity:</span>
|
||||||
|
<span class="history-value">${item.quantity} ${variant.unit}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-row">
|
||||||
|
<span class="history-label">Animal:</span>
|
||||||
|
<span class="history-value">${escapeHtml(item.animal_name)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-row">
|
||||||
|
<span class="history-label">User:</span>
|
||||||
|
<span class="history-value">${escapeHtml(item.user_name)}</span>
|
||||||
|
</div>
|
||||||
|
${item.notes ? `
|
||||||
|
<div class="history-row">
|
||||||
|
<span class="history-label">Notes:</span>
|
||||||
|
<span class="history-value">${escapeHtml(item.notes)}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
historyContent.innerHTML = historyHtml;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching history:', error);
|
||||||
|
historyContent.innerHTML = '<p class="error">Failed to load history. Check the console for details.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edit drug form
|
||||||
|
async function handleEditDrug(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const drugId = parseInt(document.getElementById('editDrugId').value);
|
||||||
|
const drugData = {
|
||||||
|
name: document.getElementById('editDrugName').value,
|
||||||
|
description: document.getElementById('editDrugDescription').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/drugs/${drugId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(drugData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update drug');
|
||||||
|
|
||||||
|
closeEditModal();
|
||||||
|
await loadDrugs();
|
||||||
|
alert('Drug updated successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating drug:', error);
|
||||||
|
alert('Failed to update drug. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete drug
|
||||||
|
async function deleteDrug(drugId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this drug?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/drugs/${drugId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete drug');
|
||||||
|
|
||||||
|
await loadDrugs();
|
||||||
|
alert('Drug deleted successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting drug:', error);
|
||||||
|
alert('Failed to delete drug. Check the console for details.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter button states
|
||||||
|
function updateFilterButtons() {
|
||||||
|
document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly);
|
||||||
|
document.getElementById('showLowStockBtn').classList.toggle('active', showLowStockOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
239
frontend/index.html
Normal file
239
frontend/index.html
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Drug Inventory System</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Drug List Section -->
|
||||||
|
<section id="listSection" class="list-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Current Inventory</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<p class="loading">Loading drugs...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Many Tears Confidential</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Drug Modal -->
|
||||||
|
<div id="editModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Edit Drug</h2>
|
||||||
|
<form id="editForm">
|
||||||
|
<input type="hidden" id="editDrugId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editDrugName">Drug Name *</label>
|
||||||
|
<input type="text" id="editDrugName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editDrugDescription">Description</label>
|
||||||
|
<input type="text" id="editDrugDescription">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Drug Modal -->
|
||||||
|
<div id="addModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Add New Drug</h2>
|
||||||
|
<form id="drugForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="drugName">Drug Name *</label>
|
||||||
|
<input type="text" id="drugName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="drugDescription">Description</label>
|
||||||
|
<input type="text" id="drugDescription">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Drug</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelAddBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dispense Drug Modal -->
|
||||||
|
<div id="dispenseModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Dispense Drug</h2>
|
||||||
|
<form id="dispenseForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseDrugSelect">Drug Variant *</label>
|
||||||
|
<select id="dispenseDrugSelect" required>
|
||||||
|
<option value="">-- Select a drug variant --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseQuantity">Quantity *</label>
|
||||||
|
<input type="number" id="dispenseQuantity" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseAnimal">Animal Name/ID *</label>
|
||||||
|
<input type="text" id="dispenseAnimal" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseUser">User Name *</label>
|
||||||
|
<input type="text" id="dispenseUser" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseNotes">Notes</label>
|
||||||
|
<input type="text" id="dispenseNotes" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Dispense</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelDispenseBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Variant Modal -->
|
||||||
|
<div id="addVariantModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Add Variant</h2>
|
||||||
|
<form id="variantForm">
|
||||||
|
<input type="hidden" id="variantDrugId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="variantStrength">Strength *</label>
|
||||||
|
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="variantQuantity">Quantity *</label>
|
||||||
|
<input type="number" id="variantQuantity" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="variantUnit">Unit *</label>
|
||||||
|
<select id="variantUnit">
|
||||||
|
<option value="tablets">Tablets</option>
|
||||||
|
<option value="bottles">Bottles</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="vials">Vials</option>
|
||||||
|
<option value="units">Units</option>
|
||||||
|
<option value="packets">Packets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="variantThreshold">Low Stock Threshold *</label>
|
||||||
|
<input type="number" id="variantThreshold" step="0.1" value="10" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Variant</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelVariantBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Variant Modal -->
|
||||||
|
<div id="editVariantModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Edit Variant</h2>
|
||||||
|
<form id="editVariantForm">
|
||||||
|
<input type="hidden" id="editVariantId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editVariantStrength">Strength *</label>
|
||||||
|
<input type="text" id="editVariantStrength" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editVariantQuantity">Quantity *</label>
|
||||||
|
<input type="number" id="editVariantQuantity" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editVariantUnit">Unit *</label>
|
||||||
|
<select id="editVariantUnit">
|
||||||
|
<option value="tablets">Tablets</option>
|
||||||
|
<option value="bottles">Bottles</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="vials">Vials</option>
|
||||||
|
<option value="units">Units</option>
|
||||||
|
<option value="packets">Packets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editVariantThreshold">Low Stock Threshold *</label>
|
||||||
|
<input type="number" id="editVariantThreshold" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelEditVariantBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dispensing History Modal -->
|
||||||
|
<div id="historyModal" class="modal">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Dispensing History - <span id="historyDrugName"></span></h2>
|
||||||
|
<div id="historyContent" class="history-content">
|
||||||
|
<p class="loading">Loading history...</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="closeHistoryBtn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
672
frontend/styles.css
Normal file
672
frontend/styles.css
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #2c3e50;
|
||||||
|
--secondary-color: #3498db;
|
||||||
|
--success-color: #27ae60;
|
||||||
|
--warning-color: #f39c12;
|
||||||
|
--danger-color: #e74c3c;
|
||||||
|
--light-bg: #ecf0f1;
|
||||||
|
--white: #ffffff;
|
||||||
|
--text-dark: #2c3e50;
|
||||||
|
--text-light: #7f8c8d;
|
||||||
|
--border-color: #bdc3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--light-bg);
|
||||||
|
color: var(--text-dark);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 40px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5em;
|
||||||
|
border-bottom: 2px solid var(--secondary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--white);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--text-light);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a686f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background-color: #d68910;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info {
|
||||||
|
background-color: #17a2b8;
|
||||||
|
color: var(--white);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-info:hover {
|
||||||
|
background-color: #138496;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drugs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.95em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-item:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-item.low-stock {
|
||||||
|
border-left: 4px solid var(--warning-color);
|
||||||
|
background: #fffdf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-description {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.9em;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-quantity {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-unit {
|
||||||
|
color: var(--text-light);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-status {
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-normal {
|
||||||
|
background-color: #d5f4e6;
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-low {
|
||||||
|
background-color: #fdeef0;
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-item.expanded .expand-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-variants {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-in-out;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-variants.expanded {
|
||||||
|
max-height: 1000px; /* Adjust based on content */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 40px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: var(--white);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions Section */
|
||||||
|
.actions-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons .btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 1em;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Section */
|
||||||
|
.search-section {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1em;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Section */
|
||||||
|
.list-section {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
animation: fadeIn 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--white);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
animation: slideIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content.modal-large {
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: var(--text-light);
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History Styles */
|
||||||
|
.history-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-variant {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-datetime {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dark);
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-value {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants Section */
|
||||||
|
.variants-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-item {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-item.low-stock {
|
||||||
|
border-left: 4px solid var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-name {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-strength {
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-quantity {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-unit {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-status {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-normal {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-low {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
nginx.conf
Normal file
20
nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user