Added auth
This commit is contained in:
93
backend/app/auth.py
Normal file
93
backend/app/auth.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .database import get_db
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SECRET_KEY = "your-secret-key-change-in-production" # Change this in production!
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 720
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a password against its hash"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
"""Create a JWT access token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)) -> User:
|
||||||
|
"""Get the current authenticated user from JWT token"""
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing authorization header",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheme, credentials = authorization.split()
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
raise ValueError("Invalid auth scheme")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authorization header format",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Get the current user and verify they are an admin"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not enough permissions. Admin access required."
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, APIRouter
|
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from .database import engine, get_db, Base
|
from .database import engine, get_db, Base
|
||||||
from .models import Drug, DrugVariant, Dispensing
|
from .models import Drug, DrugVariant, Dispensing, User
|
||||||
|
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Create tables
|
# Create tables
|
||||||
@@ -25,18 +26,42 @@ app.add_middleware(
|
|||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
# Pydantic schemas
|
# Pydantic schemas
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
class AdminPasswordChange(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
class DrugCreate(BaseModel):
|
class DrugCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
class DrugUpdate(BaseModel):
|
class DrugUpdate(BaseModel):
|
||||||
name: str = None
|
name: Optional[str] = None
|
||||||
description: str = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
class DrugResponse(BaseModel):
|
class DrugResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
description: str = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -67,7 +92,7 @@ class DrugVariantResponse(BaseModel):
|
|||||||
class DrugWithVariantsResponse(BaseModel):
|
class DrugWithVariantsResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
description: str = None
|
description: Optional[str] = None
|
||||||
variants: List[DrugVariantResponse] = []
|
variants: List[DrugVariantResponse] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -92,13 +117,153 @@ class DispensingResponse(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
# Authentication Routes
|
||||||
|
@router.post("/auth/register", response_model=TokenResponse)
|
||||||
|
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Register the first admin user (only allowed if no users exist)"""
|
||||||
|
# Check if users already exist
|
||||||
|
user_count = db.query(User).count()
|
||||||
|
if user_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Registration is disabled. Contact an administrator to create an account."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already registered")
|
||||||
|
|
||||||
|
# First (and only allowed) user is admin
|
||||||
|
hashed_password = hash_password(user_data.password)
|
||||||
|
db_user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
is_admin=True
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": db_user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": db_user
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/auth/login", response_model=TokenResponse)
|
||||||
|
def login(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Login with username and password"""
|
||||||
|
user = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if not user or not verify_password(user_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": user
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/auth/me", response_model=UserResponse)
|
||||||
|
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Get current user info"""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# User Management Routes (Admin only)
|
||||||
|
@router.get("/users", response_model=List[UserResponse])
|
||||||
|
def list_users(db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
|
||||||
|
"""List all users (admin only)"""
|
||||||
|
return db.query(User).all()
|
||||||
|
|
||||||
|
@router.post("/users", response_model=UserResponse)
|
||||||
|
def create_user(user_data: UserCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
|
||||||
|
"""Create a new user (admin only)"""
|
||||||
|
# Check if user already exists
|
||||||
|
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
|
|
||||||
|
hashed_password = hash_password(user_data.password)
|
||||||
|
db_user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
is_admin=False
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
|
||||||
|
"""Delete a user (admin only)"""
|
||||||
|
# Don't allow deleting yourself
|
||||||
|
if current_user.id == user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete your own user account")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
@router.post("/auth/change-password")
|
||||||
|
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
"""Change current user's password"""
|
||||||
|
user = db.query(User).filter(User.id == current_user.id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Verify current password
|
||||||
|
if not verify_password(password_data.current_password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.hashed_password = hash_password(password_data.new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Password changed successfully"}
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/change-password")
|
||||||
|
def admin_change_password(user_id: int, password_data: AdminPasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
|
||||||
|
"""Change a user's password (admin only)"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Don't allow changing yourself via this endpoint
|
||||||
|
if current_user.id == user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Use /auth/change-password to change your own password")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.hashed_password = hash_password(password_data.new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Password changed successfully"}
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "Drug Inventory API"}
|
return {"message": "Drug Inventory API"}
|
||||||
|
|
||||||
@router.get("/drugs", response_model=List[DrugWithVariantsResponse])
|
@router.get("/drugs", response_model=List[DrugWithVariantsResponse])
|
||||||
def list_drugs(db: Session = Depends(get_db)):
|
def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get all drugs with their variants"""
|
"""Get all drugs with their variants"""
|
||||||
drugs = db.query(Drug).all()
|
drugs = db.query(Drug).all()
|
||||||
result = []
|
result = []
|
||||||
@@ -110,7 +275,7 @@ def list_drugs(db: Session = Depends(get_db)):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
||||||
def low_stock_drugs(db: Session = Depends(get_db)):
|
def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get drugs with low stock variants"""
|
"""Get drugs with low stock variants"""
|
||||||
# Get variants that are low on stock
|
# Get variants that are low on stock
|
||||||
low_stock_variants = db.query(DrugVariant).filter(
|
low_stock_variants = db.query(DrugVariant).filter(
|
||||||
@@ -130,7 +295,7 @@ def low_stock_drugs(db: Session = Depends(get_db)):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
@router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
||||||
def get_drug(drug_id: int, db: Session = Depends(get_db)):
|
def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get a specific drug with its variants"""
|
"""Get a specific drug with its variants"""
|
||||||
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
if not drug:
|
if not drug:
|
||||||
@@ -142,7 +307,7 @@ def get_drug(drug_id: int, db: Session = Depends(get_db)):
|
|||||||
return drug_dict
|
return drug_dict
|
||||||
|
|
||||||
@router.post("/drugs", response_model=DrugWithVariantsResponse)
|
@router.post("/drugs", response_model=DrugWithVariantsResponse)
|
||||||
def create_drug(drug: DrugCreate, db: Session = Depends(get_db)):
|
def create_drug(drug: DrugCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Create a new drug"""
|
"""Create a new drug"""
|
||||||
# Check if drug name already exists
|
# Check if drug name already exists
|
||||||
existing = db.query(Drug).filter(Drug.name == drug.name).first()
|
existing = db.query(Drug).filter(Drug.name == drug.name).first()
|
||||||
@@ -160,7 +325,7 @@ def create_drug(drug: DrugCreate, db: Session = Depends(get_db)):
|
|||||||
return drug_dict
|
return drug_dict
|
||||||
|
|
||||||
@router.put("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
@router.put("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
|
||||||
def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get_db)):
|
def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Update a drug"""
|
"""Update a drug"""
|
||||||
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
if not drug:
|
if not drug:
|
||||||
@@ -178,7 +343,7 @@ def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get
|
|||||||
return drug_dict
|
return drug_dict
|
||||||
|
|
||||||
@router.delete("/drugs/{drug_id}")
|
@router.delete("/drugs/{drug_id}")
|
||||||
def delete_drug(drug_id: int, db: Session = Depends(get_db)):
|
def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Delete a drug and all its variants"""
|
"""Delete a drug and all its variants"""
|
||||||
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
if not drug:
|
if not drug:
|
||||||
@@ -194,7 +359,7 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# Drug Variant endpoints
|
# Drug Variant endpoints
|
||||||
@router.post("/drugs/{drug_id}/variants", response_model=DrugVariantResponse)
|
@router.post("/drugs/{drug_id}/variants", response_model=DrugVariantResponse)
|
||||||
def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = Depends(get_db)):
|
def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Create a new variant for a drug"""
|
"""Create a new variant for a drug"""
|
||||||
# Check if drug exists
|
# Check if drug exists
|
||||||
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
@@ -222,7 +387,7 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
|||||||
return db_variant
|
return db_variant
|
||||||
|
|
||||||
@router.get("/variants/{variant_id}", response_model=DrugVariantResponse)
|
@router.get("/variants/{variant_id}", response_model=DrugVariantResponse)
|
||||||
def get_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
def get_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get a specific drug variant"""
|
"""Get a specific drug variant"""
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
if not variant:
|
if not variant:
|
||||||
@@ -230,7 +395,7 @@ def get_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
|||||||
return variant
|
return variant
|
||||||
|
|
||||||
@router.put("/variants/{variant_id}", response_model=DrugVariantResponse)
|
@router.put("/variants/{variant_id}", response_model=DrugVariantResponse)
|
||||||
def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db)):
|
def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Update a drug variant"""
|
"""Update a drug variant"""
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
if not variant:
|
if not variant:
|
||||||
@@ -244,7 +409,7 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db:
|
|||||||
return variant
|
return variant
|
||||||
|
|
||||||
@router.delete("/variants/{variant_id}")
|
@router.delete("/variants/{variant_id}")
|
||||||
def delete_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Delete a drug variant"""
|
"""Delete a drug variant"""
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
if not variant:
|
if not variant:
|
||||||
@@ -257,7 +422,7 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# Dispensing endpoints
|
# Dispensing endpoints
|
||||||
@router.post("/dispense", response_model=DispensingResponse)
|
@router.post("/dispense", response_model=DispensingResponse)
|
||||||
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db)):
|
def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Record a drug dispensing and reduce inventory"""
|
"""Record a drug dispensing and reduce inventory"""
|
||||||
# Check if drug variant exists
|
# Check if drug variant exists
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first()
|
||||||
@@ -283,12 +448,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db)):
|
|||||||
return db_dispensing
|
return db_dispensing
|
||||||
|
|
||||||
@router.get("/dispense/history", response_model=List[DispensingResponse])
|
@router.get("/dispense/history", response_model=List[DispensingResponse])
|
||||||
def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get dispensing records (audit log)"""
|
"""Get dispensing records (audit log)"""
|
||||||
return db.query(Dispensing).order_by(Dispensing.dispensed_at.desc()).offset(skip).limit(limit).all()
|
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])
|
@router.get("/drugs/{drug_id}/dispense/history", response_model=List[DispensingResponse])
|
||||||
def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db)):
|
def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get dispensing history for a specific drug (all variants)"""
|
"""Get dispensing history for a specific drug (all variants)"""
|
||||||
# Verify drug exists
|
# Verify drug exists
|
||||||
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
@@ -301,7 +466,7 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db)):
|
|||||||
return db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).order_by(Dispensing.dispensed_at.desc()).all()
|
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])
|
@router.get("/variants/{variant_id}/dispense/history", response_model=List[DispensingResponse])
|
||||||
def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db)):
|
def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Get dispensing history for a specific drug variant"""
|
"""Get dispensing history for a specific drug variant"""
|
||||||
# Verify variant exists
|
# Verify variant exists
|
||||||
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first()
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class Drug(Base):
|
class Drug(Base):
|
||||||
__tablename__ = "drugs"
|
__tablename__ = "drugs"
|
||||||
|
|
||||||
@@ -31,7 +41,7 @@ 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)
|
||||||
quantity = Column(Float, nullable=False)
|
quantity = Column(Float, nullable=False)
|
||||||
animal_name = Column(String, nullable=False) # Name/ID of the animal
|
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)
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ uvicorn==0.24.0
|
|||||||
sqlalchemy==2.0.23
|
sqlalchemy==2.0.23
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[argon2]==1.7.4
|
||||||
|
|||||||
429
frontend/app.js
429
frontend/app.js
@@ -4,9 +4,137 @@ let currentDrug = null;
|
|||||||
let showLowStockOnly = false;
|
let showLowStockOnly = false;
|
||||||
let searchTerm = '';
|
let searchTerm = '';
|
||||||
let expandedDrugs = new Set();
|
let expandedDrugs = new Set();
|
||||||
|
let currentUser = null;
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
// Initialize
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
checkAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if user is already logged in
|
||||||
|
function checkAuth() {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
const user = localStorage.getItem('currentUser');
|
||||||
|
|
||||||
|
if (token && user) {
|
||||||
|
accessToken = token;
|
||||||
|
currentUser = JSON.parse(user);
|
||||||
|
showMainApp();
|
||||||
|
} else {
|
||||||
|
showLoginPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login page
|
||||||
|
function showLoginPage() {
|
||||||
|
document.getElementById('loginPage').style.display = 'flex';
|
||||||
|
document.getElementById('mainApp').style.display = 'none';
|
||||||
|
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
if (loginForm) loginForm.addEventListener('submit', handleLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app
|
||||||
|
function showMainApp() {
|
||||||
|
document.getElementById('loginPage').style.display = 'none';
|
||||||
|
document.getElementById('mainApp').style.display = 'block';
|
||||||
|
|
||||||
|
const userDisplay = document.getElementById('currentUser');
|
||||||
|
if (userDisplay) {
|
||||||
|
userDisplay.textContent = `👤 ${currentUser.username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminBtn = document.getElementById('adminBtn');
|
||||||
|
if (adminBtn) {
|
||||||
|
adminBtn.style.display = currentUser.is_admin ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners();
|
||||||
|
loadDrugs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById('loginUsername').value;
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
accessToken = data.access_token;
|
||||||
|
currentUser = data.user;
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||||||
|
|
||||||
|
document.getElementById('loginForm').reset();
|
||||||
|
const errorDiv = document.getElementById('loginError');
|
||||||
|
if (errorDiv) errorDiv.style.display = 'none';
|
||||||
|
showMainApp();
|
||||||
|
} catch (error) {
|
||||||
|
const errorDiv = document.getElementById('loginError');
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.textContent = error.message;
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle register
|
||||||
|
// Logout
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('currentUser');
|
||||||
|
accessToken = null;
|
||||||
|
currentUser = null;
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
if (loginForm) loginForm.reset();
|
||||||
|
const registerForm = document.getElementById('registerForm');
|
||||||
|
if (registerForm) {
|
||||||
|
registerForm.style.display = 'none';
|
||||||
|
}
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
if (form) form.style.display = 'block';
|
||||||
|
showLoginPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// API helper with authentication
|
||||||
|
async function apiCall(endpoint, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleLogout();
|
||||||
|
throw new Error('Authentication expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
function setupEventListeners() {
|
||||||
const drugForm = document.getElementById('drugForm');
|
const drugForm = document.getElementById('drugForm');
|
||||||
const variantForm = document.getElementById('variantForm');
|
const variantForm = document.getElementById('variantForm');
|
||||||
const editVariantForm = document.getElementById('editVariantForm');
|
const editVariantForm = document.getElementById('editVariantForm');
|
||||||
@@ -18,6 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const dispenseModal = document.getElementById('dispenseModal');
|
const dispenseModal = document.getElementById('dispenseModal');
|
||||||
const editModal = document.getElementById('editModal');
|
const editModal = document.getElementById('editModal');
|
||||||
const addDrugBtn = document.getElementById('addDrugBtn');
|
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||||
|
const dispenseBtn = document.getElementById('dispenseBtn');
|
||||||
const cancelAddBtn = document.getElementById('cancelAddBtn');
|
const cancelAddBtn = document.getElementById('cancelAddBtn');
|
||||||
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
|
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
|
||||||
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
|
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
|
||||||
@@ -25,41 +154,75 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||||||
const showAllBtn = document.getElementById('showAllBtn');
|
const showAllBtn = document.getElementById('showAllBtn');
|
||||||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||||||
|
const userMenuBtn = document.getElementById('userMenuBtn');
|
||||||
|
const adminBtn = document.getElementById('adminBtn');
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
const changePasswordBtn = document.getElementById('changePasswordBtn');
|
||||||
|
|
||||||
// Modal close buttons
|
// Modal close buttons
|
||||||
const closeButtons = document.querySelectorAll('.close');
|
const closeButtons = document.querySelectorAll('.close');
|
||||||
|
|
||||||
drugForm.addEventListener('submit', handleAddDrug);
|
if (drugForm) drugForm.addEventListener('submit', handleAddDrug);
|
||||||
variantForm.addEventListener('submit', handleAddVariant);
|
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
|
||||||
editVariantForm.addEventListener('submit', handleEditVariant);
|
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
|
||||||
dispenseForm.addEventListener('submit', handleDispenseDrug);
|
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
|
||||||
editForm.addEventListener('submit', handleEditDrug);
|
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
||||||
|
|
||||||
addDrugBtn.addEventListener('click', () => openModal(addModal));
|
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||||
|
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||||||
|
updateDispenseDrugSelect();
|
||||||
|
openModal(dispenseModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelAddBtn) cancelAddBtn.addEventListener('click', () => closeModal(addModal));
|
||||||
|
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
||||||
|
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
||||||
|
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||||||
|
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
||||||
|
|
||||||
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');
|
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||||||
closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||||||
|
|
||||||
|
const closeUserManagementBtn = document.getElementById('closeUserManagementBtn');
|
||||||
|
if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal')));
|
||||||
|
|
||||||
|
const changePasswordForm = document.getElementById('changePasswordForm');
|
||||||
|
if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword);
|
||||||
|
|
||||||
|
const cancelChangePasswordBtn = document.getElementById('cancelChangePasswordBtn');
|
||||||
|
if (cancelChangePasswordBtn) cancelChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('changePasswordModal')));
|
||||||
|
|
||||||
|
const adminChangePasswordForm = document.getElementById('adminChangePasswordForm');
|
||||||
|
if (adminChangePasswordForm) adminChangePasswordForm.addEventListener('submit', handleAdminChangePassword);
|
||||||
|
|
||||||
|
const cancelAdminChangePasswordBtn = document.getElementById('cancelAdminChangePasswordBtn');
|
||||||
|
if (cancelAdminChangePasswordBtn) cancelAdminChangePasswordBtn.addEventListener('click', () => closeModal(document.getElementById('adminChangePasswordModal')));
|
||||||
|
|
||||||
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
|
||||||
const modal = e.target.closest('.modal');
|
const modal = e.target.closest('.modal');
|
||||||
closeModal(modal);
|
closeModal(modal);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
showAllBtn.addEventListener('click', () => {
|
if (showAllBtn) showAllBtn.addEventListener('click', () => {
|
||||||
showLowStockOnly = false;
|
showLowStockOnly = false;
|
||||||
updateFilterButtons();
|
updateFilterButtons();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
});
|
});
|
||||||
showLowStockBtn.addEventListener('click', () => {
|
if (showLowStockBtn) showLowStockBtn.addEventListener('click', () => {
|
||||||
showLowStockOnly = true;
|
showLowStockOnly = true;
|
||||||
updateFilterButtons();
|
updateFilterButtons();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User menu
|
||||||
|
if (userMenuBtn) userMenuBtn.addEventListener('click', () => {
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
if (dropdown) dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal);
|
||||||
|
if (adminBtn) adminBtn.addEventListener('click', openUserManagement);
|
||||||
|
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
const drugSearch = document.getElementById('drugSearch');
|
const drugSearch = document.getElementById('drugSearch');
|
||||||
@@ -80,14 +243,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
closeModal(e.target);
|
closeModal(e.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
loadDrugs();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load drugs from API
|
// Load drugs from API
|
||||||
async function loadDrugs() {
|
async function loadDrugs() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/drugs`);
|
const response = await apiCall('/drugs');
|
||||||
if (!response.ok) throw new Error('Failed to load drugs');
|
if (!response.ok) throw new Error('Failed to load drugs');
|
||||||
allDrugs = await response.json();
|
allDrugs = await response.json();
|
||||||
|
|
||||||
@@ -96,18 +257,32 @@ async function loadDrugs() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading drugs:', error);
|
console.error('Error loading drugs:', error);
|
||||||
document.getElementById('drugsList').innerHTML =
|
document.getElementById('drugsList').innerHTML =
|
||||||
'<p class="empty">Error loading drugs. Make sure the backend is running on http://localhost:8000</p>';
|
'<p class="empty">Error loading drugs. Make sure the backend is running.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal utility functions
|
// Modal utility functions
|
||||||
function openModal(modal) {
|
function openModal(modal) {
|
||||||
|
// Find the highest z-index among currently visible modals
|
||||||
|
const visibleModals = document.querySelectorAll('.modal.show');
|
||||||
|
let maxZIndex = 1000;
|
||||||
|
|
||||||
|
visibleModals.forEach(m => {
|
||||||
|
const zIndex = parseInt(window.getComputedStyle(m).zIndex, 10) || 1000;
|
||||||
|
if (zIndex > maxZIndex) {
|
||||||
|
maxZIndex = zIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the new modal's z-index higher than any existing modal
|
||||||
|
modal.style.zIndex = (maxZIndex + 100).toString();
|
||||||
modal.classList.add('show');
|
modal.classList.add('show');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(modal) {
|
function closeModal(modal) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
|
modal.style.zIndex = '1000';
|
||||||
document.body.style.overflow = 'auto';
|
document.body.style.overflow = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +399,8 @@ async function handleAddDrug(e) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the drug first
|
// Create the drug first
|
||||||
const drugResponse = await fetch(`${API_URL}/drugs`, {
|
const drugResponse = await apiCall('/drugs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(drugData)
|
body: JSON.stringify(drugData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,9 +417,8 @@ async function handleAddDrug(e) {
|
|||||||
low_stock_threshold: parseFloat(document.getElementById('initialVariantThreshold').value) || 10
|
low_stock_threshold: parseFloat(document.getElementById('initialVariantThreshold').value) || 10
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantResponse = await fetch(`${API_URL}/drugs/${createdDrug.id}/variants`, {
|
const variantResponse = await apiCall(`/drugs/${createdDrug.id}/variants`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(variantData)
|
body: JSON.stringify(variantData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,15 +455,14 @@ async function handleDispenseDrug(e) {
|
|||||||
const dispensingData = {
|
const dispensingData = {
|
||||||
drug_variant_id: variantId,
|
drug_variant_id: variantId,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
animal_name: animalName,
|
animal_name: animalName || null,
|
||||||
user_name: userName,
|
user_name: userName,
|
||||||
notes: notes || null
|
notes: notes || null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dispense`, {
|
const response = await apiCall('/dispense', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(dispensingData)
|
body: JSON.stringify(dispensingData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,9 +532,8 @@ async function handleAddVariant(e) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/drugs/${drugId}/variants`, {
|
const response = await apiCall(`/drugs/${drugId}/variants`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(variantData)
|
body: JSON.stringify(variantData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,9 +583,8 @@ async function handleEditVariant(e) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/variants/${variantId}`, {
|
const response = await apiCall(`/variants/${variantId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(variantData)
|
body: JSON.stringify(variantData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -448,7 +618,7 @@ async function deleteVariant(variantId) {
|
|||||||
if (!confirm('Are you sure you want to delete this variant?')) return;
|
if (!confirm('Are you sure you want to delete this variant?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/variants/${variantId}`, {
|
const response = await apiCall(`/variants/${variantId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -476,7 +646,7 @@ async function showDrugHistory(drugId) {
|
|||||||
openModal(historyModal);
|
openModal(historyModal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/dispense/history`);
|
const response = await apiCall(`/dispense/history`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch history');
|
if (!response.ok) throw new Error('Failed to fetch history');
|
||||||
|
|
||||||
const allHistory = await response.json();
|
const allHistory = await response.json();
|
||||||
@@ -546,9 +716,8 @@ async function handleEditDrug(e) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/drugs/${drugId}`, {
|
const response = await apiCall(`/drugs/${drugId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(drugData)
|
body: JSON.stringify(drugData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -568,7 +737,7 @@ async function deleteDrug(drugId) {
|
|||||||
if (!confirm('Are you sure you want to delete this drug?')) return;
|
if (!confirm('Are you sure you want to delete this drug?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/drugs/${drugId}`, {
|
const response = await apiCall(`/drugs/${drugId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -582,6 +751,104 @@ async function deleteDrug(drugId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password Management
|
||||||
|
function openChangePasswordModal() {
|
||||||
|
const modal = document.getElementById('changePasswordModal');
|
||||||
|
document.getElementById('changePasswordForm').reset();
|
||||||
|
|
||||||
|
// Close dropdown
|
||||||
|
const dropdown = document.getElementById('userDropdown');
|
||||||
|
if (dropdown) dropdown.style.display = 'none';
|
||||||
|
|
||||||
|
openModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentPassword = document.getElementById('currentPassword').value;
|
||||||
|
const newPassword = document.getElementById('newPassword').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('New passwords do not match!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 1) {
|
||||||
|
alert('New password cannot be empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to change password');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Password changed successfully!');
|
||||||
|
closeModal(document.getElementById('changePasswordModal'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
alert('Failed to change password: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdminChangePasswordModal(userId, username) {
|
||||||
|
const modal = document.getElementById('adminChangePasswordModal');
|
||||||
|
document.getElementById('adminChangePasswordForm').reset();
|
||||||
|
document.getElementById('adminChangePasswordUserId').value = userId;
|
||||||
|
document.getElementById('adminChangePasswordUsername').value = username;
|
||||||
|
openModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminChangePassword(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const userId = document.getElementById('adminChangePasswordUserId').value;
|
||||||
|
const newPassword = document.getElementById('adminChangePasswordNewPassword').value;
|
||||||
|
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('Passwords do not match!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 1) {
|
||||||
|
alert('Password cannot be empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`/users/${userId}/change-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to change password');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Password changed successfully!');
|
||||||
|
closeModal(document.getElementById('adminChangePasswordModal'));
|
||||||
|
openUserManagement();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
alert('Failed to change password: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update filter button states
|
// Update filter button states
|
||||||
function updateFilterButtons() {
|
function updateFilterButtons() {
|
||||||
document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly);
|
document.getElementById('showAllBtn').classList.toggle('active', !showLowStockOnly);
|
||||||
@@ -594,3 +861,93 @@ function escapeHtml(text) {
|
|||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
async function openUserManagement() {
|
||||||
|
const modal = document.getElementById('userManagementModal');
|
||||||
|
document.getElementById('newUsername').value = '';
|
||||||
|
document.getElementById('newUserPassword').value = '';
|
||||||
|
|
||||||
|
const usersList = document.getElementById('usersList');
|
||||||
|
usersList.innerHTML = '<h3>Users</h3><p class="loading">Loading users...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall('/users');
|
||||||
|
if (!response.ok) throw new Error('Failed to load users');
|
||||||
|
|
||||||
|
const users = await response.json();
|
||||||
|
|
||||||
|
const usersHtml = `
|
||||||
|
<h3>Users</h3>
|
||||||
|
<div class="users-table">
|
||||||
|
${users.map(user => `
|
||||||
|
<div class="user-item">
|
||||||
|
<span>${user.username}</span>
|
||||||
|
<span class="admin-badge">${user.is_admin ? '👑 Admin' : 'User'}</span>
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="openAdminChangePasswordModal(${user.id}, '${escapeHtml(user.username)}')">🔑 Password</button>
|
||||||
|
${user.id !== currentUser.id ? `
|
||||||
|
<button class="btn btn-danger btn-small" onclick="deleteUser(${user.id})">Delete</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
usersList.innerHTML = usersHtml;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
usersList.innerHTML = '<h3>Users</h3><p class="empty">Error loading users</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUserForm = document.getElementById('createUserForm');
|
||||||
|
if (createUserForm) {
|
||||||
|
createUserForm.onsubmit = createUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
async function createUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('newUsername').value;
|
||||||
|
const password = document.getElementById('newUserPassword').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('newUsername').value = '';
|
||||||
|
document.getElementById('newUserPassword').value = '';
|
||||||
|
alert('User created successfully!');
|
||||||
|
openUserManagement();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
alert('Failed to create user: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
async function deleteUser(userId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`/users/${userId}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete user');
|
||||||
|
|
||||||
|
alert('User deleted successfully!');
|
||||||
|
openUserManagement();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
alert('Failed to delete user: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,19 +7,53 @@
|
|||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<!-- Login Page -->
|
||||||
<header>
|
<div id="loginPage" class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
|
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
|
||||||
</header>
|
<form id="loginForm" class="login-form">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginUsername">Username</label>
|
||||||
|
<input type="text" id="loginUsername" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginPassword">Password</label>
|
||||||
|
<input type="password" id="loginPassword" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
<p class="login-info">Contact an administrator to create an account</p>
|
||||||
|
</form>
|
||||||
|
<div id="loginError" class="error-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main>
|
<!-- Main App (hidden until logged in) -->
|
||||||
<!-- Drug List Section -->
|
<div id="mainApp" class="main-app" style="display: none;">
|
||||||
<section id="listSection" class="list-section">
|
<div class="container">
|
||||||
<div class="section-header">
|
<header>
|
||||||
<h2>Current Inventory</h2>
|
<div class="header-top">
|
||||||
<div class="header-actions">
|
<h1>🐶 MTAR Drug Inventory System 🐶</h1>
|
||||||
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</button>
|
<div class="user-menu">
|
||||||
|
<span id="currentUser">User</span>
|
||||||
|
<button id="userMenuBtn" class="btn btn-small">⋮</button>
|
||||||
|
<div id="userDropdown" class="user-dropdown" style="display: none;">
|
||||||
|
<button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button>
|
||||||
|
<button id="adminBtn" class="dropdown-item" style="display: none;">👤 Admin</button>
|
||||||
|
<button id="logoutBtn" class="dropdown-item">🚪 Logout</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
<div class="filters">
|
||||||
<button id="showAllBtn" class="filter-btn active">All</button>
|
<button id="showAllBtn" class="filter-btn active">All</button>
|
||||||
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
|
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
|
||||||
@@ -40,219 +74,307 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<p>Many Tears Confidential</p>
|
<p>Many Tears Confidential</p>
|
||||||
</footer>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Drug Modal -->
|
<!-- Edit Drug Modal -->
|
||||||
<div id="addModal" class="modal">
|
<div id="editModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close">×</span>
|
<span class="close">×</span>
|
||||||
<h2>Add New Drug</h2>
|
<h2>Edit Drug</h2>
|
||||||
<form id="drugForm">
|
<form id="editForm">
|
||||||
<div class="form-group">
|
<input type="hidden" id="editDrugId">
|
||||||
<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>
|
|
||||||
|
|
||||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
|
|
||||||
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="initialVariantStrength">Strength</label>
|
|
||||||
<input type="text" id="initialVariantStrength" placeholder="e.g., 10mg, 5.4mg">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="initialVariantQuantity">Quantity</label>
|
|
||||||
<input type="number" id="initialVariantQuantity" placeholder="0" min="0" step="0.1">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="initialVariantUnit">Unit</label>
|
|
||||||
<input type="text" id="initialVariantUnit" placeholder="e.g., tablets, capsules, vials" value="units">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="initialVariantThreshold">Low Stock Threshold</label>
|
|
||||||
<input type="number" id="initialVariantThreshold" placeholder="0" min="0" step="0.1" value="10">
|
|
||||||
</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" novalidate>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dispenseDrugSelect">Drug Variant *</label>
|
|
||||||
<select id="dispenseDrugSelect">
|
|
||||||
<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">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dispenseAnimal">Animal Name/ID</label>
|
|
||||||
<input type="text" id="dispenseAnimal">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="dispenseUser">Dispensed by *</label>
|
|
||||||
<input type="text" id="dispenseUser">
|
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="variantQuantity">Quantity *</label>
|
<label for="editDrugName">Drug Name *</label>
|
||||||
<input type="number" id="variantQuantity" step="0.1" required>
|
<input type="text" id="editDrugName" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="variantUnit">Unit *</label>
|
<label for="editDrugDescription">Description</label>
|
||||||
<select id="variantUnit">
|
<input type="text" id="editDrugDescription">
|
||||||
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-actions">
|
||||||
<label for="editVariantUnit">Unit *</label>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
<select id="editVariantUnit">
|
<button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
|
||||||
<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>
|
</form>
|
||||||
|
|
||||||
<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>
|
||||||
<div class="form-actions">
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" id="closeHistoryBtn">Close</button>
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
|
||||||
|
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="initialVariantStrength">Strength</label>
|
||||||
|
<input type="text" id="initialVariantStrength" placeholder="e.g., 10mg, 5.4mg">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="initialVariantQuantity">Quantity</label>
|
||||||
|
<input type="number" id="initialVariantQuantity" placeholder="0" min="0" step="0.1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="initialVariantUnit">Unit</label>
|
||||||
|
<select id="initialVariantUnit">
|
||||||
|
<option value="tablets">Tablets</option>
|
||||||
|
<option value="bottles">Bottles</option>
|
||||||
|
<option value="boxes">Boxes</option>
|
||||||
|
<option value="vials">Vials</option>
|
||||||
|
<option value="units">Units</option>
|
||||||
|
<option value="packets">Packets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="initialVariantThreshold">Low Stock Threshold</label>
|
||||||
|
<input type="number" id="initialVariantThreshold" placeholder="0" min="0" step="0.1" value="10">
|
||||||
|
</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" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseDrugSelect">Drug Variant *</label>
|
||||||
|
<select id="dispenseDrugSelect">
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseAnimal">Animal Name/ID</label>
|
||||||
|
<input type="text" id="dispenseAnimal">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dispenseUser">Dispensed by *</label>
|
||||||
|
<input type="text" id="dispenseUser">
|
||||||
|
</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="boxes">boxes</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="boxes">Boxes</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>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div id="changePasswordModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<form id="changePasswordForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="currentPassword">Current Password *</label>
|
||||||
|
<input type="password" id="currentPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newPassword">New Password *</label>
|
||||||
|
<input type="password" id="newPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmNewPassword">Confirm New Password *</label>
|
||||||
|
<input type="password" id="confirmNewPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelChangePasswordBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Change User Password Modal -->
|
||||||
|
<div id="adminChangePasswordModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Change User Password</h2>
|
||||||
|
<form id="adminChangePasswordForm">
|
||||||
|
<input type="hidden" id="adminChangePasswordUserId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminChangePasswordUsername">Username</label>
|
||||||
|
<input type="text" id="adminChangePasswordUsername" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminChangePasswordNewPassword">New Password *</label>
|
||||||
|
<input type="password" id="adminChangePasswordNewPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminChangePasswordConfirm">Confirm Password *</label>
|
||||||
|
<input type="password" id="adminChangePasswordConfirm" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Set Password</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelAdminChangePasswordBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Management Modal -->
|
||||||
|
<div id="userManagementModal" class="modal">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<div class="user-management-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<h3>Create New User</h3>
|
||||||
|
<form id="createUserForm">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="newUsername" placeholder="Username" required>
|
||||||
|
<input type="password" id="newUserPassword" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-small">Create User</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="usersList" class="users-list">
|
||||||
|
<h3>Users</h3>
|
||||||
|
<p class="loading">Loading users...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="closeUserManagementBtn">Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,109 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login Page Styles */
|
||||||
|
.login-page {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form.active,
|
||||||
|
.login-form:first-of-type {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-toggle {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-toggle button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--secondary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: var(--danger-color);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main App Styles */
|
||||||
|
.main-app {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -40,14 +143,80 @@ header {
|
|||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top h1 {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userMenuBtn {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: var(--white);
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userMenuBtn:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-bottom: 1px solid var(--light-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: var(--light-bg);
|
||||||
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 2.5em;
|
font-size: 2.5em;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.1em;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -82,6 +251,7 @@ label {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
|
input[type="password"],
|
||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -94,6 +264,7 @@ select {
|
|||||||
|
|
||||||
input[type="text"]:focus,
|
input[type="text"]:focus,
|
||||||
input[type="number"]:focus,
|
input[type="number"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--secondary-color);
|
border-color: var(--secondary-color);
|
||||||
@@ -191,6 +362,9 @@ select:focus {
|
|||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
@@ -670,3 +844,106 @@ footer {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User Management Styles */
|
||||||
|
.user-management-content {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-content h3 {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-content .form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-content input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-content .btn {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--warning-color);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header {
|
||||||
|
padding: 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top h1 {
|
||||||
|
flex: none;
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 90%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drug-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user