diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c07867 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..52f2acb --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..2e7ce6f --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..66b63aa --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..6de779a --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..55acc1c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8001f1f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..bcee764 --- /dev/null +++ b/frontend/app.js @@ -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 = + '

Error loading drugs. Make sure the backend is running on http://localhost:8000

'; + } +} + +// 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 = ''; + + 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 = '

No drugs found matching your criteria

'; + 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 ` +
+
+
+
${escapeHtml(drug.name)} ${escapeHtml(variant.strength)}
+
${variant.quantity} ${escapeHtml(variant.unit)}
+
+
+ + ${variantIsLowStock ? 'Low Stock' : 'OK'} + +
+
+
+ + + +
+
+ `; + }).join('')}` : ''; + + return ` +
+
+
${escapeHtml(drug.name)}
+
${drug.description ? escapeHtml(drug.description) : 'No description'}
+
${totalVariants} variant${totalVariants !== 1 ? 's' : ''} (${totalQuantity} total units)
+
+ + ${isLowStock ? `${lowStockVariants} low` : 'All OK'} + +
+
+
+ + + + + ${isExpanded ? '▼' : '▶'} +
+
+
+ ${variantsHtml} +
+ `; + }).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 = '

Loading history...

'; + 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 = '

No dispensing history for this drug.

'; + 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 ` +
+
+
${drug.name} ${variant.strength}
+
${date} ${time}
+
+
+
+ Quantity: + ${item.quantity} ${variant.unit} +
+
+ Animal: + ${escapeHtml(item.animal_name)} +
+
+ User: + ${escapeHtml(item.user_name)} +
+ ${item.notes ? ` +
+ Notes: + ${escapeHtml(item.notes)} +
+ ` : ''} +
+
+ `; + }).join(''); + + historyContent.innerHTML = historyHtml; + } catch (error) { + console.error('Error fetching history:', error); + historyContent.innerHTML = '

Failed to load history. Check the console for details.

'; + } +} + +// 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; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c1d8400 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,239 @@ + + + + + + Drug Inventory System + + + +
+
+

🐶 MTAR Drug Inventory System 🐶

+
+ +
+ +
+
+

Current Inventory

+
+ +
+
+ + +
+
+ + +
+ +
+ +
+

Loading drugs...

+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..666216b --- /dev/null +++ b/frontend/styles.css @@ -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; + } +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..4527cbe --- /dev/null +++ b/nginx.conf @@ -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; + } +} +