Compare commits

..

14 Commits

Author SHA1 Message Date
jamesp 0026ac2274 Backup script 2026-06-19 17:42:28 +01:00
jamesp b093e1a90c Disposal 2026-06-19 17:21:07 +01:00
jamesp 25c3f1fa64 Scroll wheel fix again 2026-06-18 20:20:03 +01:00
jamesp 3f230bb0d7 Stock Take PDF 2026-06-18 20:15:24 +01:00
jamesp 0fea301af1 Scroll wheel fix 2026-05-19 15:58:56 -04:00
jamesp 562494a967 Couple of prod fixes 2026-04-26 16:19:13 -04:00
jamesp 05a093afd3 Dispensing Vet 2026-04-26 16:02:42 -04:00
jamesp 9ec27e245a Scan shortcut on main screen 2026-04-20 14:28:21 -04:00
jamesp 36634dc2bf Refactor - API lazy loading 2026-04-20 14:12:11 -04:00
jamesp 6be571a48c GS1 scanning and workflow improvements 2026-04-20 12:43:29 -04:00
jamesp cfb08bd288 Barcode scanning and GTIN mapping 2026-04-16 15:32:36 -04:00
jamesp 2aeba2f563 Add new pack size modal 2026-04-15 06:39:54 -04:00
jamesp d4753c0754 Ship it 2026-04-12 06:31:03 -04:00
jamesp 225202aacb Minor UI tweaks 2026-04-12 05:37:57 -04:00
14 changed files with 3316 additions and 212 deletions
+1
View File
@@ -20,3 +20,4 @@ node_modules/
.cache/ .cache/
.pytest_cache/ .pytest_cache/
drugs.db drugs.db
data/backups/
+32 -3
View File
@@ -77,9 +77,38 @@ docker-compose up --build
## Database ## Database
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can: SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
- Backup by copying the file
- Share with team members ### Backups
The Docker Compose stack includes a `backup` sidecar that creates a SQLite-safe compressed backup every hour and keeps backups for 7 days.
Backups are stored in:
```bash
./data/backups/
```
The latest backup is also copied to:
```bash
./data/backups/latest.db.gz
```
To restore a backup:
```bash
docker compose stop backend backup
gunzip -c data/backups/drugs-YYYY-MM-DDTHH-MM-SSZ.db.gz > data/drugs.db
docker compose up -d backend backup
```
Backup interval and retention can be changed with:
```bash
BACKUP_INTERVAL_SECONDS=3600
BACKUP_RETENTION_DAYS=7
```
## Configuration ## Configuration
+475 -40
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
import math import math
@@ -18,11 +19,13 @@ from .models import (
Batch, Batch,
AuditLog, AuditLog,
User, User,
GtinMapping,
) )
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES
from .mqtt_service import publish_label_print_with_response from .mqtt_service import publish_label_print_with_response
from .migrate_to_roles import migrate_users_table from .migrate_to_roles import migrate_users_table
from .migrate_compliance import migrate_compliance_schema from .migrate_compliance import migrate_compliance_schema
from .migrate_gtin import migrate_gtin_schema
from pydantic import BaseModel from pydantic import BaseModel
# Run migration to convert is_admin to role # Run migration to convert is_admin to role
@@ -39,6 +42,11 @@ except Exception as e:
# Create tables # Create tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
try:
migrate_gtin_schema()
except Exception as e:
print(f"Warning: GTIN migration failed: {e}. Continuing anyway...")
# Seed default locations after table creation. # Seed default locations after table creation.
try: try:
migrate_compliance_schema() migrate_compliance_schema()
@@ -65,6 +73,9 @@ class UserCreate(BaseModel):
password: str password: str
role: Optional[str] = "user" # admin, user, readonly role: Optional[str] = "user" # admin, user, readonly
class UserRoleUpdate(BaseModel):
role: str
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
current_password: str current_password: str
new_password: str new_password: str
@@ -138,14 +149,37 @@ class BatchDisposeRequest(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class GtinMappingCreate(BaseModel):
gtin: str
drug_variant_id: int
variant_pack_id: int
class GtinMappingResponse(BaseModel):
id: int
gtin: str
drug_variant_id: int
variant_pack_id: int
drug_id: int
drug_name: str
variant_strength: str
variant_unit: str
pack_label: str
pack_size_in_base_units: float
pack_unit_name: str
class Config:
from_attributes = True
class BatchResponse(BaseModel): class BatchResponse(BaseModel):
id: int id: int
drug_variant_id: int drug_variant_id: int
batch_number: str batch_number: str
quantity: float quantity: float
received_pack_id: Optional[int] = None received_pack_id: Optional[int] = None
received_pack_label: Optional[str] = None received_pack_unit_name: Optional[str] = None
received_pack_count: Optional[float] = None
received_pack_size_snapshot: Optional[float] = None received_pack_size_snapshot: Optional[float] = None
current_full_pack_count: Optional[float] = None current_full_pack_count: Optional[float] = None
current_loose_base_units: Optional[float] = None current_loose_base_units: Optional[float] = None
@@ -187,14 +221,12 @@ class DrugVariantUpdate(BaseModel):
class VariantPackCreate(BaseModel): class VariantPackCreate(BaseModel):
label: str
pack_unit_name: str pack_unit_name: str
pack_size_in_base_units: float pack_size_in_base_units: float
is_active: bool = True is_active: bool = True
class VariantPackUpdate(BaseModel): class VariantPackUpdate(BaseModel):
label: Optional[str] = None
pack_unit_name: Optional[str] = None pack_unit_name: Optional[str] = None
pack_size_in_base_units: Optional[float] = None pack_size_in_base_units: Optional[float] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@@ -203,7 +235,6 @@ class VariantPackUpdate(BaseModel):
class VariantPackResponse(BaseModel): class VariantPackResponse(BaseModel):
id: int id: int
drug_variant_id: int drug_variant_id: int
label: str
pack_unit_name: str pack_unit_name: str
pack_size_in_base_units: float pack_size_in_base_units: float
is_active: bool is_active: bool
@@ -236,11 +267,50 @@ class DrugWithVariantsResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class VariantSummaryResponse(BaseModel):
"""Lightweight variant summary returned by GET /drugs list — no packs or batches."""
id: int
drug_id: int
strength: str
quantity: float
unit: str
low_stock_threshold: float
has_inventory_history: bool = False
expired_quantity: float = 0.0
class Config:
from_attributes = True
class DrugSummaryResponse(BaseModel):
"""Lightweight drug summary for the main list — variants without packs or batches."""
id: int
name: str
description: Optional[str] = None
is_controlled: bool = False
locations: List[str] = []
variants: List[VariantSummaryResponse] = []
class Config:
from_attributes = True
class DispensingAllocationCreate(BaseModel): class DispensingAllocationCreate(BaseModel):
batch_id: int batch_id: int
quantity: float quantity: float
class InventoryDisposeRequest(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
dispense_source: str = "batch"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
class DispensingCreate(BaseModel): class DispensingCreate(BaseModel):
drug_variant_id: int drug_variant_id: int
quantity: Optional[float] = None quantity: Optional[float] = None
@@ -249,6 +319,7 @@ class DispensingCreate(BaseModel):
requested_pack_id: Optional[int] = None requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None animal_name: Optional[str] = None
prescribing_vet: Optional[str] = None
user_name: Optional[str] = None user_name: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = [] allocations: List[DispensingAllocationCreate] = []
@@ -271,6 +342,7 @@ class DispensingResponse(BaseModel):
requested_pack_id: Optional[int] = None requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None animal_name: Optional[str] = None
prescribing_vet: Optional[str] = None
user_name: str user_name: str
notes: Optional[str] = None notes: Optional[str] = None
dispensed_at: datetime dispensed_at: datetime
@@ -372,6 +444,40 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
return variant_dict return variant_dict
def serialize_variant_with_packs(db: Session, variant: DrugVariant) -> Dict[str, Any]:
"""Return variant data with packs but without batch details (level-2 detail)."""
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant.id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant.id)
.first()
is not None
)
packs = (
db.query(VariantPack)
.filter(VariantPack.drug_variant_id == variant.id)
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
.all()
)
return {
"id": variant.id,
"drug_id": variant.drug_id,
"strength": variant.strength,
"quantity": variant.quantity,
"unit": variant.unit,
"base_unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold,
"has_inventory_history": has_batch_history or has_dispense_history,
"packs": [serialize_variant_pack(pack) for pack in packs],
"batches": [],
}
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]: def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
location = db.query(Location).filter(Location.id == batch.location_id).first() location = db.query(Location).filter(Location.id == batch.location_id).first()
pack = None pack = None
@@ -383,7 +489,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
"batch_number": batch.batch_number, "batch_number": batch.batch_number,
"quantity": batch.quantity, "quantity": batch.quantity,
"received_pack_id": batch.received_pack_id, "received_pack_id": batch.received_pack_id,
"received_pack_label": pack.label if pack else None, "received_pack_unit_name": pack.pack_unit_name if pack else None,
"received_pack_count": batch.received_pack_count, "received_pack_count": batch.received_pack_count,
"received_pack_size_snapshot": batch.received_pack_size_snapshot, "received_pack_size_snapshot": batch.received_pack_size_snapshot,
"current_full_pack_count": batch.current_full_pack_count, "current_full_pack_count": batch.current_full_pack_count,
@@ -429,7 +535,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
return { return {
"id": pack.id, "id": pack.id,
"drug_variant_id": pack.drug_variant_id, "drug_variant_id": pack.drug_variant_id,
"label": pack.label,
"pack_unit_name": pack.pack_unit_name, "pack_unit_name": pack.pack_unit_name,
"pack_size_in_base_units": pack.pack_size_in_base_units, "pack_size_in_base_units": pack.pack_size_in_base_units,
"is_active": pack.is_active, "is_active": pack.is_active,
@@ -785,6 +890,34 @@ def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
@router.patch("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(user_id: int, role_data: UserRoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Update a user's role (admin only)"""
if current_user.id == user_id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
valid_roles = ["admin", "user", "readonly"]
if role_data.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
old_role = user.role
user.role = role_data.role
write_audit_log(
db,
action="user.role.update",
entity_type="user",
entity_id=user.id,
actor=current_user,
details={"username": user.username, "old_role": old_role, "new_role": user.role},
)
db.commit()
db.refresh(user)
return user
@router.post("/auth/change-password") @router.post("/auth/change-password")
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Change current user's password""" """Change current user's password"""
@@ -840,21 +973,112 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db:
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[DrugSummaryResponse])
def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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 lightweight variant summaries (no packs or batches)."""
drugs = db.query(Drug).all() drugs = db.query(Drug).all()
if not drugs:
return []
drug_ids = [d.id for d in drugs]
all_variants = (
db.query(DrugVariant)
.filter(DrugVariant.drug_id.in_(drug_ids))
.all()
)
variants_by_drug: Dict[int, list] = {}
for v in all_variants:
variants_by_drug.setdefault(v.drug_id, []).append(v)
variant_ids = [v.id for v in all_variants]
if not variant_ids:
return [
{
"id": d.id,
"name": d.name,
"description": d.description,
"is_controlled": bool(d.is_controlled),
"locations": [],
"variants": [],
}
for d in drugs
]
# Variant IDs that have ever had a batch received
batch_history_ids = set(
row[0]
for row in db.query(Batch.drug_variant_id)
.filter(Batch.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Variant IDs that have ever had a dispense
dispense_history_ids = set(
row[0]
for row in db.query(Dispensing.drug_variant_id)
.filter(Dispensing.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Sum of expired active batch stock per variant
today = date.today()
expired_rows = (
db.query(Batch.drug_variant_id, func.sum(Batch.quantity))
.filter(
Batch.drug_variant_id.in_(variant_ids),
Batch.quantity > 0,
Batch.expiry_date < today,
)
.group_by(Batch.drug_variant_id)
.all()
)
expired_by_variant: Dict[int, float] = {row[0]: float(row[1]) for row in expired_rows}
# Distinct location names per drug (from active batch stock)
location_rows = (
db.query(DrugVariant.drug_id, Location.name)
.join(Batch, Batch.drug_variant_id == DrugVariant.id)
.join(Location, Location.id == Batch.location_id)
.filter(
DrugVariant.drug_id.in_(drug_ids),
Batch.quantity > 0,
)
.distinct()
.all()
)
locations_by_drug: Dict[int, list] = {}
for drug_id, loc_name in location_rows:
locations_by_drug.setdefault(drug_id, []).append(loc_name)
result = [] result = []
for drug in drugs: for drug in drugs:
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() drug_variants = variants_by_drug.get(drug.id, [])
drug_dict = { variant_summaries = [
"id": drug.id, {
"name": drug.name, "id": v.id,
"description": drug.description, "drug_id": v.drug_id,
"is_controlled": bool(drug.is_controlled), "strength": v.strength,
"variants": [enrich_variant_with_batches(db, v) for v in variants], "quantity": v.quantity,
} "unit": v.unit,
result.append(drug_dict) "low_stock_threshold": v.low_stock_threshold,
"has_inventory_history": v.id in batch_history_ids or v.id in dispense_history_ids,
"expired_quantity": expired_by_variant.get(v.id, 0.0),
}
for v in drug_variants
]
result.append(
{
"id": drug.id,
"name": drug.name,
"description": drug.description,
"is_controlled": bool(drug.is_controlled),
"locations": locations_by_drug.get(drug.id, []),
"variants": variant_summaries,
}
)
return result return result
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse]) @router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
@@ -884,18 +1108,18 @@ def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends(
@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), current_user: User = Depends(get_current_user)): 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 and packs (no batch detail — level-2 detail)."""
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:
raise HTTPException(status_code=404, detail="Drug not found") raise HTTPException(status_code=404, detail="Drug not found")
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
drug_dict = { drug_dict = {
"id": drug.id, "id": drug.id,
"name": drug.name, "name": drug.name,
"description": drug.description, "description": drug.description,
"is_controlled": bool(drug.is_controlled), "is_controlled": bool(drug.is_controlled),
"variants": [enrich_variant_with_batches(db, v) for v in variants], "variants": [serialize_variant_with_packs(db, v) for v in variants],
} }
return drug_dict return drug_dict
@@ -1050,15 +1274,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
db.flush() db.flush()
# Ensure each variant has at least one active default 1:1 pack representation. # Ensure each variant has at least one active default 1:1 pack representation.
db.add( db.add(VariantPack(
VariantPack(
drug_variant_id=db_variant.id, drug_variant_id=db_variant.id,
label=f"1 {base_unit}",
pack_unit_name=base_unit, pack_unit_name=base_unit,
pack_size_in_base_units=1, pack_size_in_base_units=1,
is_active=True, is_active=True,
) ))
)
write_audit_log( write_audit_log(
db, db,
@@ -1232,10 +1453,7 @@ def create_variant_pack(
if not variant: if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found") raise HTTPException(status_code=404, detail="Drug variant not found")
label = payload.label.strip()
pack_unit_name = payload.pack_unit_name.strip() pack_unit_name = payload.pack_unit_name.strip()
if not label:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
if not pack_unit_name: if not pack_unit_name:
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty") raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
if payload.pack_size_in_base_units <= 0: if payload.pack_size_in_base_units <= 0:
@@ -1243,7 +1461,6 @@ def create_variant_pack(
row = VariantPack( row = VariantPack(
drug_variant_id=variant_id, drug_variant_id=variant_id,
label=label,
pack_unit_name=pack_unit_name, pack_unit_name=pack_unit_name,
pack_size_in_base_units=payload.pack_size_in_base_units, pack_size_in_base_units=payload.pack_size_in_base_units,
is_active=payload.is_active, is_active=payload.is_active,
@@ -1257,7 +1474,6 @@ def create_variant_pack(
actor=current_user, actor=current_user,
details={ details={
"variant_id": variant_id, "variant_id": variant_id,
"label": label,
"pack_unit_name": pack_unit_name, "pack_unit_name": pack_unit_name,
"pack_size_in_base_units": payload.pack_size_in_base_units, "pack_size_in_base_units": payload.pack_size_in_base_units,
"is_active": payload.is_active, "is_active": payload.is_active,
@@ -1281,12 +1497,6 @@ def update_variant_pack(
before = serialize_variant_pack(row) before = serialize_variant_pack(row)
if payload.label is not None:
cleaned = payload.label.strip()
if not cleaned:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
row.label = cleaned
if payload.pack_unit_name is not None: if payload.pack_unit_name is not None:
cleaned = payload.pack_unit_name.strip() cleaned = payload.pack_unit_name.strip()
if not cleaned: if not cleaned:
@@ -1396,6 +1606,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
requested_pack_id=resolved["pack_id"], requested_pack_id=resolved["pack_id"],
requested_pack_count=resolved["pack_count"], requested_pack_count=resolved["pack_count"],
animal_name=dispensing.animal_name, animal_name=dispensing.animal_name,
prescribing_vet=dispensing.prescribing_vet,
user_name=user_name, user_name=user_name,
notes=dispensing.notes, notes=dispensing.notes,
) )
@@ -1451,6 +1662,110 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"allocations": allocation_payload, "allocations": allocation_payload,
} }
@router.post("/dispose")
def dispose_inventory(payload: InventoryDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Dispose selected inventory and reduce stock (admin only)"""
variant = db.query(DrugVariant).filter(DrugVariant.id == payload.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
dispose_mode = (payload.dispense_mode or "subunit").strip().lower()
if dispose_mode not in {"subunit", "pack"}:
raise HTTPException(status_code=400, detail="dispose_mode must be either 'subunit' or 'pack'")
if dispose_mode == "pack":
if payload.requested_pack_id is None or payload.requested_pack_count is None:
raise HTTPException(status_code=400, detail="Pack disposal requires requested_pack_id and requested_pack_count")
if payload.requested_pack_count <= 0:
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
if abs(payload.requested_pack_count - round(payload.requested_pack_count)) > 1e-6:
raise HTTPException(status_code=400, detail="Whole-pack disposal requires an integer pack count")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=None,
pack_id=payload.requested_pack_id,
pack_count=payload.requested_pack_count,
)
else:
if payload.quantity is None or payload.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity disposal requires quantity > 0")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=payload.quantity,
pack_id=None,
pack_count=None,
)
dispose_qty = resolved["quantity"]
if variant.quantity < dispose_qty:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispose_qty}",
)
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispose_qty,
requested_allocations=payload.allocations,
dispense_mode=dispose_mode,
dispense_source=payload.dispense_source,
requested_pack_id=resolved["pack_id"],
)
selected_source = (payload.dispense_source or ("legacy" if not allocations else "batch")).strip().lower()
allocation_payload = []
disposed_at = datetime.utcnow()
for allocation in allocations:
batch = allocation["batch"]
qty = allocation["quantity"]
remaining_before = batch.quantity
batch.quantity -= qty
if batch.quantity <= 1e-6:
batch.quantity = 0
batch.disposed_at = disposed_at
batch.disposed_by_user_id = current_user.id
batch.disposed_quantity = remaining_before
batch.disposal_notes = payload.notes
recompute_batch_pack_state(batch)
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
variant.quantity = max(0, variant.quantity - dispose_qty)
write_audit_log(
db,
action="inventory.dispose",
entity_type="drug_variant",
entity_id=variant.id,
actor=current_user,
details={
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload,
"notes": payload.notes,
},
)
db.commit()
db.refresh(variant)
return {
"message": "Inventory disposed successfully",
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"allocations": allocation_payload,
}
@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), current_user: User = Depends(get_current_user)): 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)"""
@@ -1469,6 +1784,7 @@ def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_
"requested_pack_id": item.requested_pack_id, "requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count, "requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name, "animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "notes": item.notes,
"dispensed_at": item.dispensed_at, "dispensed_at": item.dispensed_at,
@@ -1503,6 +1819,7 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us
"requested_pack_id": item.requested_pack_id, "requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count, "requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name, "animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "notes": item.notes,
"dispensed_at": item.dispensed_at, "dispensed_at": item.dispensed_at,
@@ -1534,6 +1851,7 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
"requested_pack_id": item.requested_pack_id, "requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count, "requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name, "animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name, "user_name": item.user_name,
"notes": item.notes, "notes": item.notes,
"dispensed_at": item.dispensed_at, "dispensed_at": item.dispensed_at,
@@ -1658,7 +1976,41 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
.first() .first()
) )
if existing: if existing:
raise HTTPException(status_code=400, detail="Batch number already exists for this variant") if existing.expiry_date != payload.expiry_date:
raise HTTPException(
status_code=400,
detail="Batch number already exists for this variant with a different expiry date",
)
# Same batch number and expiry — restock the existing batch
existing.quantity += batch_quantity
if existing.received_pack_id == resolved["pack_id"]:
existing.received_pack_count = (existing.received_pack_count or 0) + resolved["pack_count"]
if payload.notes:
existing.notes = (existing.notes + "\n" + payload.notes) if existing.notes else payload.notes
recompute_batch_pack_state(existing)
variant.quantity += batch_quantity
write_audit_log(
db,
action="batch.restock",
entity_type="batch",
entity_id=existing.id,
actor=current_user,
details={
"variant_id": variant_id,
"batch_number": batch_number,
"quantity_added": batch_quantity,
"new_total_quantity": existing.quantity,
"received_pack_id": resolved["pack_id"],
"received_pack_count": resolved["pack_count"],
"expiry_date": str(payload.expiry_date),
"location_id": existing.location_id,
},
)
db.commit()
db.refresh(existing)
return serialize_batch_response(db, existing)
row = Batch( row = Batch(
drug_variant_id=variant_id, drug_variant_id=variant_id,
@@ -1806,7 +2158,7 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse) @router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
batch = db.query(Batch).filter(Batch.id == batch_id).first() batch = db.query(Batch).filter(Batch.id == batch_id).first()
if not batch: if not batch:
raise HTTPException(status_code=404, detail="Batch not found") raise HTTPException(status_code=404, detail="Batch not found")
@@ -2200,7 +2552,7 @@ def report_batch_attention(
"location": location.name, "location": location.name,
"expiry_date": batch.expiry_date, "expiry_date": batch.expiry_date,
"status": "expired", "status": "expired",
"received_pack_label": None, "received_pack_unit_name": None,
"current_full_pack_count": batch.current_full_pack_count, "current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units, "current_loose_base_units": batch.current_loose_base_units,
"is_controlled": bool(drug.is_controlled), "is_controlled": bool(drug.is_controlled),
@@ -2450,5 +2802,88 @@ def print_notes(notes_request: NotesPrintRequest, current_user: User = Depends(g
detail=f"Error sending notes print request: {str(e)}" detail=f"Error sending notes print request: {str(e)}"
) )
@router.get("/gtin/{gtin}", response_model=GtinMappingResponse)
def get_gtin_mapping(
gtin: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mapping = db.query(GtinMapping).filter(GtinMapping.gtin == gtin).first()
if not mapping:
raise HTTPException(status_code=404, detail="GTIN not found")
variant = db.query(DrugVariant).filter(DrugVariant.id == mapping.drug_variant_id).first()
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first() if variant else None
pack = db.query(VariantPack).filter(VariantPack.id == mapping.variant_pack_id).first()
if not variant or not drug or not pack:
# Referenced records no longer exist — delete the stale mapping and treat as unknown
db.delete(mapping)
db.commit()
raise HTTPException(status_code=404, detail="GTIN not found")
return GtinMappingResponse(
id=mapping.id,
gtin=mapping.gtin,
drug_variant_id=mapping.drug_variant_id,
variant_pack_id=mapping.variant_pack_id,
drug_id=drug.id,
drug_name=drug.name,
variant_strength=variant.strength,
variant_unit=variant.unit,
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
pack_size_in_base_units=pack.pack_size_in_base_units,
pack_unit_name=pack.pack_unit_name,
)
@router.post("/gtin", response_model=GtinMappingResponse, status_code=201)
def create_gtin_mapping(
body: GtinMappingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_non_readonly_user),
):
existing = db.query(GtinMapping).filter(GtinMapping.gtin == body.gtin).first()
if existing:
raise HTTPException(status_code=409, detail="GTIN mapping already exists")
variant = db.query(DrugVariant).filter(DrugVariant.id == body.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
pack = db.query(VariantPack).filter(
VariantPack.id == body.variant_pack_id,
VariantPack.drug_variant_id == body.drug_variant_id,
).first()
if not pack:
raise HTTPException(status_code=404, detail="Pack not found for this variant")
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first()
mapping = GtinMapping(
gtin=body.gtin,
drug_variant_id=body.drug_variant_id,
variant_pack_id=body.variant_pack_id,
created_by_user_id=current_user.id,
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return GtinMappingResponse(
id=mapping.id,
gtin=mapping.gtin,
drug_variant_id=mapping.drug_variant_id,
variant_pack_id=mapping.variant_pack_id,
drug_id=drug.id,
drug_name=drug.name,
variant_strength=variant.strength,
variant_unit=variant.unit,
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
pack_size_in_base_units=pack.pack_size_in_base_units,
pack_unit_name=pack.pack_unit_name,
)
# Include router with /api prefix # Include router with /api prefix
app.include_router(router) app.include_router(router)
+5 -3
View File
@@ -62,7 +62,6 @@ def migrate_compliance_schema() -> None:
CREATE TABLE variant_packs ( CREATE TABLE variant_packs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
drug_variant_id INTEGER NOT NULL, drug_variant_id INTEGER NOT NULL,
label VARCHAR NOT NULL,
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack', pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1, pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1, is_active BOOLEAN NOT NULL DEFAULT 1,
@@ -138,9 +137,8 @@ def migrate_compliance_schema() -> None:
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"): if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
cursor.execute( cursor.execute(
""" """
INSERT INTO variant_packs (drug_variant_id, label, pack_unit_name, pack_size_in_base_units, is_active) INSERT INTO variant_packs (drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active)
SELECT v.id, SELECT v.id,
'1 ' || COALESCE(NULLIF(v.unit, ''), 'unit'),
COALESCE(NULLIF(v.unit, ''), 'unit'), COALESCE(NULLIF(v.unit, ''), 'unit'),
1, 1,
1 1
@@ -230,6 +228,10 @@ def migrate_compliance_schema() -> None:
) )
print("Backfilled dispensing mode where missing") print("Backfilled dispensing mode where missing")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "prescribing_vet"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN prescribing_vet VARCHAR")
print("Added dispensings.prescribing_vet")
# Seed default locations once table exists (created via SQLAlchemy create_all). # Seed default locations once table exists (created via SQLAlchemy create_all).
if _table_exists(cursor, "locations"): if _table_exists(cursor, "locations"):
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)") cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
+103
View File
@@ -0,0 +1,103 @@
"""
GTIN mapping table migration.
Creates the gtin_mappings table if it does not already exist.
Idempotent and safe to run on every startup.
"""
import os
import sqlite3
from pathlib import Path
DEFAULT_DB_URL = "sqlite:///./data/drugs.db"
def _resolve_sqlite_path(db_url: str) -> Path | None:
if not db_url.startswith("sqlite:///"):
print(f"Unsupported database URL for GTIN migration: {db_url}")
return None
raw_path = db_url.replace("sqlite:///", "")
if raw_path.startswith("/"):
return Path(raw_path)
return Path(raw_path)
def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
return cursor.fetchone() is not None
def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
return any(row[1] == column_name for row in cursor.fetchall())
def migrate_gtin_schema() -> None:
"""Create gtin_mappings table if it does not exist, and drop label from variant_packs."""
db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
db_path = _resolve_sqlite_path(db_url)
if db_path is None:
return
if not db_path.exists():
print(f"Database does not exist at {db_path}, skipping GTIN migration")
return
print(f"Running GTIN migration on {db_path}")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
try:
if not _table_exists(cursor, "gtin_mappings"):
cursor.execute("""
CREATE TABLE gtin_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gtin VARCHAR(14) NOT NULL,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
variant_pack_id INTEGER NOT NULL REFERENCES variant_packs(id),
created_by_user_id INTEGER REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE UNIQUE INDEX ix_gtin_mappings_gtin ON gtin_mappings (gtin)")
cursor.execute("CREATE INDEX ix_gtin_mappings_drug_variant_id ON gtin_mappings (drug_variant_id)")
cursor.execute("CREATE INDEX ix_gtin_mappings_variant_pack_id ON gtin_mappings (variant_pack_id)")
print("Created gtin_mappings table")
else:
print("gtin_mappings table already exists, skipping")
# Drop label column from variant_packs if it still exists
if _table_exists(cursor, "variant_packs") and _column_exists(cursor, "variant_packs", "label"):
print("Dropping label column from variant_packs")
cursor.execute("""
CREATE TABLE variant_packs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
INSERT INTO variant_packs_new (id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at)
SELECT id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at
FROM variant_packs
""")
# Re-create indexes
cursor.execute("DROP INDEX IF EXISTS ix_variant_packs_drug_variant_id")
cursor.execute("DROP TABLE variant_packs")
cursor.execute("ALTER TABLE variant_packs_new RENAME TO variant_packs")
cursor.execute("CREATE INDEX ix_variant_packs_drug_variant_id ON variant_packs (drug_variant_id)")
print("Dropped label column from variant_packs")
else:
print("variant_packs.label already absent, skipping")
conn.commit()
finally:
conn.close()
+12 -1
View File
@@ -41,7 +41,6 @@ class VariantPack(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, index=True) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
label = Column(String, nullable=False)
pack_unit_name = Column(String, nullable=False, default="pack") pack_unit_name = Column(String, nullable=False, default="pack")
pack_size_in_base_units = Column(Float, nullable=False, default=1) pack_size_in_base_units = Column(Float, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
@@ -61,6 +60,7 @@ class Dispensing(Base):
requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True) requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
requested_pack_count = Column(Float, nullable=True) requested_pack_count = Column(Float, nullable=True)
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional) animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
prescribing_vet = Column(String, nullable=True) # Prescribing vet's name (required for controlled drugs)
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)
@@ -109,6 +109,17 @@ class DispensingAllocation(Base):
quantity = Column(Float, nullable=False) quantity = Column(Float, nullable=False)
class GtinMapping(Base):
__tablename__ = "gtin_mappings"
id = Column(Integer, primary_key=True, index=True)
gtin = Column(String(14), unique=True, index=True, nullable=False)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
variant_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=False, index=True)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
+6 -6
View File
@@ -1,8 +1,8 @@
fastapi==0.104.1 fastapi==0.137.2
uvicorn==0.24.0 uvicorn==0.49.0
sqlalchemy==2.0.23 sqlalchemy==2.0.51
pydantic==2.5.0 pydantic==2.13.4
python-multipart==0.0.6 python-multipart==0.0.32
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4 passlib[argon2]==1.7.4
paho-mqtt==1.6.1 paho-mqtt==1.6.1
+18 -5
View File
@@ -10,8 +10,8 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- DATABASE_URL=sqlite:///./data/drugs.db - DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto} - MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883} - MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_USERNAME=${MQTT_USERNAME:-}
@@ -23,6 +23,20 @@ services:
- LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false} - LABEL_TEST=${LABEL_TEST:-false}
backup:
image: python:3.11-slim
user: "${PUID:-1000}:${PGID:-1000}"
volumes:
- ./data:/data
- ./scripts/backup_sqlite.py:/usr/local/bin/backup_sqlite.py:ro
environment:
- SQLITE_DB_PATH=/data/drugs.db
- BACKUP_DIR=/data/backups
- BACKUP_INTERVAL_SECONDS=${BACKUP_INTERVAL_SECONDS:-3600}
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-7}
command: ["python", "/usr/local/bin/backup_sqlite.py"]
restart: unless-stopped
mosquitto: mosquitto:
image: eclipse-mosquitto:latest image: eclipse-mosquitto:latest
volumes: volumes:
@@ -31,12 +45,11 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log - mosquitto_logs:/mosquitto/log
environment: environment:
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
frontend: frontend:
image: nginx:alpine image: nginx:alpine
container_name: drugsdev
volumes: volumes:
- ./frontend:/usr/share/nginx/html:ro - ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+1995 -122
View File
File diff suppressed because it is too large Load Diff
+185 -5
View File
@@ -59,6 +59,7 @@
<div class="inventory-toolbar"> <div class="inventory-toolbar">
<div class="header-actions"> <div class="header-actions">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button> <button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
<button id="receiveDeliveryBtn" class="btn btn-success btn-small">📦 Receive Delivery</button>
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button> <button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button>
</div> </div>
<div class="toolbar-search"> <div class="toolbar-search">
@@ -175,7 +176,7 @@
<div class="form-group" id="dispenseQuantityGroup"> <div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label> <label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1"> <input type="number" id="dispenseQuantity" step="1.0">
</div> </div>
<div class="form-row" id="dispensePackRow" style="display: none;"> <div class="form-row" id="dispensePackRow" style="display: none;">
@@ -227,6 +228,11 @@
<input type="text" id="dispenseAnimal"> <input type="text" id="dispenseAnimal">
</div> </div>
<div class="form-group">
<label for="dispenseVet" id="dispenseVetLabel">Prescribing Vet</label>
<input type="text" id="dispenseVet" placeholder="Vet's name">
</div>
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;"> <div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;"> <label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
<input type="checkbox" id="dispensePrintEnabled"> <input type="checkbox" id="dispensePrintEnabled">
@@ -258,6 +264,95 @@
</div> </div>
</div> </div>
<!-- Dispose Inventory Modal -->
<div id="disposeInventoryModal" class="modal">
<div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span>
<h2>Dispose Inventory</h2>
<form id="disposeInventoryForm" novalidate>
<div class="form-group">
<label for="disposeDrugSelect">Drug Variant *</label>
<select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
<option value="">-- Select a drug variant --</option>
</select>
</div>
<div class="form-group">
<label>Disposal Mode *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group" id="disposeQuantityGroup">
<label for="disposeQuantity">Quantity *</label>
<input type="number" id="disposeQuantity" step="1.0">
</div>
<div class="form-row" id="disposePackRow" style="display: none;">
<div class="form-group">
<label for="disposePackSelect">Pack Type *</label>
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="disposePackCount">Pack Count *</label>
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group" id="disposeSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="disposeAllocationSummaryContent"></div>
</div>
<div id="disposeBatchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div>
<div class="form-group">
<label for="disposeNotes">Disposal Note</label>
<textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger">Dispose</button>
<button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Add Variant Modal --> <!-- Add Variant Modal -->
<div id="addVariantModal" class="modal"> <div id="addVariantModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -267,14 +362,14 @@
<input type="hidden" id="variantDrugId"> <input type="hidden" id="variantDrugId">
<div class="form-group"> <div class="form-group">
<label for="variantStrength">Strength *</label> <label for="variantStrength">Strength *</label>
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required> <input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 0.5mg/ml" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="variantUnit">Base Unit *</label> <label for="variantUnit">Base Unit *</label>
<select id="variantUnit"> <select id="variantUnit">
<option value="ml">ml</option> <option value="ml">ml</option>
<option value="tablets">tablets</option> <option value="tablets" selected>tablets</option>
<option value="capsules">capsules</option> <option value="capsules">capsules</option>
<option value="units">units</option> <option value="units">units</option>
<option value="vials">vials</option> <option value="vials">vials</option>
@@ -426,7 +521,7 @@
<span class="close">&times;</span> <span class="close">&times;</span>
<h2>User Management</h2> <h2>User Management</h2>
<div class="user-management-content"> <div class="user-management-content">
<div class="form-group"> <section class="user-create-panel">
<h3>Create New User</h3> <h3>Create New User</h3>
<form id="createUserForm"> <form id="createUserForm">
<div class="form-row"> <div class="form-row">
@@ -441,7 +536,7 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-small">Create User</button> <button type="submit" class="btn btn-primary btn-small">Create User</button>
</form> </form>
</div> </section>
<div id="usersList" class="users-list"> <div id="usersList" class="users-list">
<h3>Users</h3> <h3>Users</h3>
<p class="loading">Loading users...</p> <p class="loading">Loading users...</p>
@@ -516,6 +611,11 @@
<input type="text" id="disposeBatchName" disabled> <input type="text" id="disposeBatchName" disabled>
</div> </div>
<div class="form-group">
<label for="disposeBatchStockSummary">Stock to Dispose</label>
<input type="text" id="disposeBatchStockSummary" disabled>
</div>
<div class="form-group"> <div class="form-group">
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p> <p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
</div> </div>
@@ -576,6 +676,41 @@
</div> </div>
</div> </div>
<!-- Add Pack Size Modal -->
<div id="addPackSizeModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Add Pack Size</h2>
<p id="addPackSizeDrugLabel" style="margin: 4px 0 14px; color: #666; font-weight: 600;"></p>
<form id="addPackSizeForm" novalidate>
<div class="form-group">
<label for="addPackSizeVariantSelect">Variant *</label>
<select id="addPackSizeVariantSelect" required>
<option value="">-- Select variant --</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeType">Pack Type *</label>
<select id="addPackSizeType" required>
<option value="box" selected>Box</option>
<option value="bottle">Bottle</option>
<option value="vial">Vial</option>
<option value="packet">Packet</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeCount">Pack Size (units) *</label>
<input type="number" id="addPackSizeCount" min="0.0001" step="0.0001" placeholder="e.g., 28" required>
<small id="addPackSizeHint" style="display: block; margin-top: 4px; color: #666;"></small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Pack Size</button>
<button type="button" class="btn btn-secondary" id="cancelAddPackSizeBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Receive Delivery Modal --> <!-- Receive Delivery Modal -->
<div id="receiveDeliveryModal" class="modal"> <div id="receiveDeliveryModal" class="modal">
<div class="modal-content modal-large receive-delivery-modal-content"> <div class="modal-content modal-large receive-delivery-modal-content">
@@ -587,6 +722,7 @@
<div class="delivery-toolbar"> <div class="delivery-toolbar">
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button> <button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button> <button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
<button type="button" id="addPackSizeFromDeliveryBtn" class="btn btn-info">+ Add Pack Size</button>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Delivery</button> <button type="submit" class="btn btn-primary">Receive Delivery</button>
@@ -596,6 +732,50 @@
</div> </div>
</div> </div>
<!-- GTIN Mapping Modal -->
<div id="gtinMappingModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Unknown Barcode — Map GTIN</h2>
<p style="color:#666; margin-bottom:16px;">This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.</p>
<div class="form-group">
<label>GTIN</label>
<input type="text" id="gtinMappingGtin" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>Drug</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingDrugSelect" onchange="onGtinMappingDrugChange()" style="flex:1">
<option value="">-- Select drug --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddDrug()">+ New Drug</button>
</div>
</div>
<div class="form-group">
<label>Variant</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingVariantSelect" onchange="onGtinMappingVariantChange()" style="flex:1">
<option value="">-- Select variant --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddVariant()">+ New Variant</button>
</div>
</div>
<div class="form-group">
<label>Pack</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingPackSelect" style="flex:1">
<option value="">-- Select pack --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddPack()">+ New Pack</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="handleSaveGtinMapping()">Save Mapping</button>
<button type="button" class="btn btn-secondary" id="cancelGtinMappingBtn">Cancel</button>
</div>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+3 -2
View File
@@ -31,7 +31,7 @@
<label for="reportTypeSelect">Report</label> <label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect"> <select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option> <option value="dispensing" selected>Dispensing History</option>
<option value="global_inventory">Global Inventory</option> <option value="global_inventory">Stock Check</option>
<option value="batch_attention">Expired Batches</option> <option value="batch_attention">Expired Batches</option>
<option value="audit">Audit Trail (Raw)</option> <option value="audit">Audit Trail (Raw)</option>
</select> </select>
@@ -64,6 +64,7 @@
<button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button> <button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button>
<button id="clearReportFiltersBtn" type="button" class="btn btn-secondary btn-small">Clear</button> <button id="clearReportFiltersBtn" type="button" class="btn btn-secondary btn-small">Clear</button>
<button id="refreshReportsBtn" type="button" class="btn btn-secondary btn-small">Refresh</button> <button id="refreshReportsBtn" type="button" class="btn btn-secondary btn-small">Refresh</button>
<button id="stockCheckPdfBtn" type="button" class="btn btn-secondary btn-small" style="display: none;">Print Stock Check</button>
</div> </div>
</div> </div>
</div> </div>
@@ -120,4 +121,4 @@
<script src="reports.js"></script> <script src="reports.js"></script>
</body> </body>
</html> </html>
+261
View File
@@ -317,6 +317,7 @@ function renderDispensingTable(rows) {
const info = getVariantInfoById(row.drug_variant_id); const info = getVariantInfoById(row.drug_variant_id);
const quantityText = `${row.quantity} ${info.unit || 'units'}`; const quantityText = `${row.quantity} ${info.unit || 'units'}`;
const animal = row.animal_name || '-'; const animal = row.animal_name || '-';
const vet = row.prescribing_vet || '-';
const notes = row.notes || '-'; const notes = row.notes || '-';
const allocations = formatDispenseAllocation(row); const allocations = formatDispenseAllocation(row);
@@ -328,6 +329,7 @@ function renderDispensingTable(rows) {
<td>${escapeHtml(info.strength || '-')}</td> <td>${escapeHtml(info.strength || '-')}</td>
<td>${escapeHtml(quantityText)}</td> <td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(animal)}</td> <td>${escapeHtml(animal)}</td>
<td>${escapeHtml(vet)}</td>
<td>${escapeHtml(allocations)}</td> <td>${escapeHtml(allocations)}</td>
<td>${escapeHtml(notes)}</td> <td>${escapeHtml(notes)}</td>
</tr> </tr>
@@ -344,6 +346,7 @@ function renderDispensingTable(rows) {
<th>Strength</th> <th>Strength</th>
<th>Quantity</th> <th>Quantity</th>
<th>Animal</th> <th>Animal</th>
<th>Prescribing Vet</th>
<th>Batch Allocation</th> <th>Batch Allocation</th>
<th>Notes</th> <th>Notes</th>
</tr> </tr>
@@ -530,6 +533,7 @@ function applyCurrentFilters() {
info.drugName || '', info.drugName || '',
info.strength || '', info.strength || '',
row.animal_name || '', row.animal_name || '',
row.prescribing_vet || '',
row.notes || '', row.notes || '',
formatDispenseAllocation(row) formatDispenseAllocation(row)
].join(' ').toLowerCase(); ].join(' ').toLowerCase();
@@ -588,33 +592,288 @@ function applyCurrentFilters() {
} else { } else {
renderAuditTable(filteredRows); renderAuditTable(filteredRows);
} }
return filteredRows;
} }
function updateReportHeading() { function updateReportHeading() {
const heading = document.getElementById('reportsHeading'); const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch'); const searchInput = document.getElementById('reportActionSearch');
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control'); const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
if (!heading || !searchInput) return; if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') { if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History'; heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...'; searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = ''; if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else if (activeReportType === 'global_inventory') { } else if (activeReportType === 'global_inventory') {
heading.textContent = 'Global Inventory'; heading.textContent = 'Global Inventory';
searchInput.placeholder = 'Search drug, variant, batch, location...'; searchInput.placeholder = 'Search drug, variant, batch, location...';
if (userFilter) userFilter.style.display = 'none'; if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = '';
} else if (activeReportType === 'batch_attention') { } else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches'; heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...'; searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none'; if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else { } else {
heading.textContent = 'Audit Trail (Raw)'; heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...'; searchInput.placeholder = 'Search action, entity, details...';
if (userFilter) userFilter.style.display = ''; if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} }
} }
function formatDisplayDate(value) {
if (!value) return '-';
const date = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T00:00:00`)
: new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString();
}
function getReportFilterSummary() {
const drugFilter = document.getElementById('reportDrugFilter')?.value || '';
const fromDate = document.getElementById('reportFromDate')?.value || '';
const toDate = document.getElementById('reportToDate')?.value || '';
const searchText = document.getElementById('reportActionSearch')?.value.trim() || '';
const parts = [];
if (drugFilter) parts.push(`Drug: ${drugFilter}`);
if (fromDate) parts.push(`From: ${formatDisplayDate(fromDate)}`);
if (toDate) parts.push(`To: ${formatDisplayDate(toDate)}`);
if (searchText) parts.push(`Search: ${searchText}`);
return parts.length ? parts.join(' | ') : 'All inventory lines';
}
function getStockCheckRows() {
const previousReportType = activeReportType;
activeReportType = 'global_inventory';
const rows = applyCurrentFilters() || [];
activeReportType = previousReportType;
return rows;
}
function buildStockCheckPdfHtml(rows) {
const groupedRows = new Map();
const sortedRows = [...rows].sort((a, b) => {
const drugCompare = String(a.drug_name || '').localeCompare(String(b.drug_name || ''), undefined, { sensitivity: 'base' });
if (drugCompare !== 0) return drugCompare;
const strengthCompare = String(a.strength || '').localeCompare(String(b.strength || ''), undefined, { numeric: true, sensitivity: 'base' });
if (strengthCompare !== 0) return strengthCompare;
const expiryCompare = String(a.expiry_date || '9999-12-31').localeCompare(String(b.expiry_date || '9999-12-31'));
if (expiryCompare !== 0) return expiryCompare;
return String(a.batch_number || '').localeCompare(String(b.batch_number || ''), undefined, { numeric: true, sensitivity: 'base' });
});
sortedRows.forEach(row => {
const drugName = row.drug_name || 'Unknown Drug';
if (!groupedRows.has(drugName)) groupedRows.set(drugName, []);
groupedRows.get(drugName).push(row);
});
const generatedAt = new Date().toLocaleString();
const filterSummary = getReportFilterSummary();
const totalLines = rows.length;
const sectionsHtml = Array.from(groupedRows.entries()).map(([drugName, drugRows]) => {
const bodyHtml = drugRows.map(row => {
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '-');
const quantityText = `${row.quantity ?? 0} ${row.unit || 'units'}`;
const controlledText = row.is_controlled ? 'Yes' : 'No';
return `
<tr>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(row.location_name || '-')}</td>
<td>${escapeHtml(formatDisplayDate(row.expiry_date))}</td>
<td>${escapeHtml(controlledText)}</td>
<td class="manual-entry"></td>
<td class="manual-entry notes-cell"></td>
</tr>
`;
}).join('');
return `
<section class="drug-section">
<h2>${escapeHtml(drugName)}</h2>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Batch</th>
<th>System Qty</th>
<th>Location</th>
<th>Expiry</th>
<th>CD</th>
<th>Actual</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${bodyHtml}</tbody>
</table>
</section>
`;
}).join('');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manual Stock Check</title>
<style>
@page { size: A4 portrait; margin: 8mm; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #111827;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.5pt;
}
header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
margin-bottom: 5mm;
border-bottom: 1.5px solid #111827;
padding-bottom: 3mm;
}
h1 {
margin: 0 0 1.5mm;
font-size: 14pt;
line-height: 1.1;
}
.meta {
color: #374151;
font-size: 7.5pt;
line-height: 1.35;
}
.signoff {
min-width: 52mm;
display: grid;
gap: 2mm;
font-size: 7.5pt;
}
.signoff-line {
border-bottom: 1px solid #6b7280;
height: 5mm;
}
.empty {
border: 1px solid #d1d5db;
padding: 8mm;
color: #4b5563;
}
.drug-section {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 4mm;
}
h2 {
margin: 0;
padding: 1mm 1.5mm;
background: #e5e7eb;
border: 1px solid #9ca3af;
border-bottom: 0;
font-size: 9.5pt;
line-height: 1.15;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #9ca3af;
padding: 1.2mm 1.4mm;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
line-height: 1.2;
}
th {
background: #f3f4f6;
font-size: 6.8pt;
text-transform: uppercase;
letter-spacing: 0;
padding-top: 1mm;
padding-bottom: 1mm;
}
th:nth-child(1) { width: 13%; }
th:nth-child(2) { width: 15%; }
th:nth-child(3) { width: 12%; }
th:nth-child(4) { width: 13%; }
th:nth-child(5) { width: 10%; }
th:nth-child(6) { width: 5%; }
th:nth-child(7) { width: 12%; }
th:nth-child(8) { width: 20%; }
td.manual-entry {
height: 7mm;
background: #ffffff;
}
.notes-cell {
min-height: 7mm;
}
@media print {
body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Manual Stock Check</h1>
<div class="meta">Generated: ${escapeHtml(generatedAt)}</div>
<div class="meta">Filters: ${escapeHtml(filterSummary)}</div>
<div class="meta">Inventory lines: ${escapeHtml(String(totalLines))}</div>
</div>
<div class="signoff">
<div>Checked by<div class="signoff-line"></div></div>
<div>Date<div class="signoff-line"></div></div>
</div>
</header>
${sectionsHtml || '<div class="empty">No inventory lines match the selected filters.</div>'}
<script>
window.addEventListener('load', () => {
window.focus();
window.print();
});
</script>
</body>
</html>
`;
}
function generateStockCheckPdf() {
if (activeReportType !== 'global_inventory') {
showToast('Select Global Inventory to generate a stock check PDF.', 'warning');
return;
}
const rows = getStockCheckRows();
if (!rows.length) {
showToast('No inventory lines match the selected filters.', 'warning');
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) {
showToast('Allow pop-ups to generate the stock check PDF.', 'warning');
return;
}
printWindow.document.open();
printWindow.document.write(buildStockCheckPdfHtml(rows));
printWindow.document.close();
}
async function apiCall(endpoint, options = {}) { async function apiCall(endpoint, options = {}) {
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -732,6 +991,7 @@ function setupEventListeners() {
const applyBtn = document.getElementById('applyReportFiltersBtn'); const applyBtn = document.getElementById('applyReportFiltersBtn');
const clearBtn = document.getElementById('clearReportFiltersBtn'); const clearBtn = document.getElementById('clearReportFiltersBtn');
const refreshBtn = document.getElementById('refreshReportsBtn'); const refreshBtn = document.getElementById('refreshReportsBtn');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
const backBtn = document.getElementById('backToInventoryBtn'); const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn'); const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn'); const goToLoginBtn = document.getElementById('goToLoginBtn');
@@ -755,6 +1015,7 @@ function setupEventListeners() {
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters); if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport); if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
if (stockCheckPdfBtn) stockCheckPdfBtn.addEventListener('click', generateStockCheckPdf);
if (clearBtn) { if (clearBtn) {
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
+117 -25
View File
@@ -685,7 +685,8 @@ footer {
max-height: calc(100vh - 48px) !important; max-height: calc(100vh - 48px) !important;
} }
#dispenseModal.show { #dispenseModal.show,
#disposeInventoryModal.show {
align-items: flex-start; align-items: flex-start;
overflow-y: auto; overflow-y: auto;
padding: 24px 0; padding: 24px 0;
@@ -697,21 +698,22 @@ footer {
overflow-y: auto; overflow-y: auto;
} }
#dispenseForm { #dispenseForm,
#disposeInventoryForm {
display: block; display: block;
padding-right: 6px; padding-right: 6px;
} }
#batchInfoSection, #batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection { #allocationPreviewSection {
max-height: 220px; max-height: 220px;
overflow-y: auto; overflow-y: auto;
min-height: fit-content; min-height: fit-content;
} }
#dispenseModal .form-actions { #dispenseModal .form-actions,
position: sticky; #disposeInventoryModal .form-actions {
bottom: 0;
margin-top: 16px; margin-top: 16px;
padding-top: 14px; padding-top: 14px;
background: var(--white); background: var(--white);
@@ -844,6 +846,8 @@ footer {
.report-actions { .report-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
flex-wrap: wrap;
} }
.reports-summary { .reports-summary {
@@ -913,7 +917,7 @@ footer {
.delivery-line-grid { .delivery-line-grid {
display: grid; display: grid;
grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto; grid-template-columns: 1.9fr 1.8fr 1.5fr 0.8fr 1.2fr 1.3fr auto;
gap: 12px; gap: 12px;
align-items: end; align-items: end;
} }
@@ -1069,11 +1073,13 @@ footer {
max-height: calc(100vh - 24px); max-height: calc(100vh - 24px);
} }
#dispenseModal.show { #dispenseModal.show,
#disposeInventoryModal.show {
padding: 12px 0; padding: 12px 0;
} }
#batchInfoSection, #batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection { #allocationPreviewSection {
max-height: 160px; max-height: 160px;
} }
@@ -1195,22 +1201,29 @@ footer {
} }
.user-management-content h3 { .user-management-content h3 {
margin-top: 20px; margin-top: 0;
margin-bottom: 15px; margin-bottom: 15px;
color: var(--primary-color); color: var(--primary-color);
} }
.user-create-panel {
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #f8fafc;
}
.user-management-content .form-row { .user-management-content .form-row {
display: flex; display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
gap: 10px; gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
flex-wrap: wrap;
} }
.user-management-content input, .user-management-content input,
.user-management-content select { .user-management-content select {
flex: 1; width: 100%;
min-width: 150px; min-width: 0;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
@@ -1222,33 +1235,103 @@ footer {
} }
.users-list { .users-list {
margin-top: 20px; margin-top: 24px;
} }
.users-table { .users-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
} }
.user-item { .user-item {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
align-items: center; align-items: center;
padding: 12px; padding: 12px 14px;
background: #f8f9fa; background: var(--white);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 8px;
gap: 15px; gap: 12px;
}
.user-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-identity strong {
overflow-wrap: anywhere;
}
.current-user-label,
.self-note {
color: #64748b;
font-size: 0.85em;
}
.current-user-label {
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
} }
.admin-badge { .admin-badge {
padding: 4px 12px; justify-self: start;
background: var(--warning-color); padding: 5px 10px;
color: var(--white); color: var(--white);
border-radius: 20px; border-radius: 999px;
font-size: 0.9em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
white-space: nowrap;
}
.admin-badge.role-admin {
background: var(--warning-color);
}
.admin-badge.role-user {
background: var(--secondary-color);
}
.admin-badge.role-readonly {
background: #64748b;
}
.role-control {
display: flex;
align-items: center;
gap: 8px;
}
.role-control span {
color: #64748b;
font-size: 0.85em;
}
.role-control select {
margin: 0;
}
.role-control select:disabled {
color: #64748b;
background: #f1f5f9;
cursor: not-allowed;
}
.user-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-actions .btn {
margin-bottom: 0;
} }
/* Responsive Styles */ /* Responsive Styles */
@@ -1278,6 +1361,15 @@ footer {
max-width: none; max-width: none;
} }
.user-management-content .form-row,
.user-item {
grid-template-columns: 1fr;
}
.user-actions {
justify-content: flex-start;
}
.drug-item { .drug-item {
flex-direction: column; flex-direction: column;
} }
@@ -1386,4 +1478,4 @@ footer {
min-width: auto; min-width: auto;
width: 100%; width: 100%;
} }
} }
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import gzip
import os
import shutil
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/drugs.db"))
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
INTERVAL_SECONDS = int(os.getenv("BACKUP_INTERVAL_SECONDS", "3600"))
RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "7"))
LATEST_BACKUP = BACKUP_DIR / "latest.db.gz"
def utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def log(message: str) -> None:
print(f"[{utc_stamp()}] {message}", flush=True)
def run_integrity_check(db_path: Path) -> None:
with sqlite3.connect(str(db_path)) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()
if not result or result[0] != "ok":
detail = result[0] if result else "no result"
raise RuntimeError(f"SQLite integrity check failed: {detail}")
def create_backup() -> None:
if not DB_PATH.exists():
log(f"database not found at {DB_PATH}; skipping backup")
return
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = utc_stamp()
temp_db = BACKUP_DIR / f".{stamp}.db.tmp"
temp_gz = BACKUP_DIR / f".{stamp}.db.gz.tmp"
final_gz = BACKUP_DIR / f"drugs-{stamp}.db.gz"
for path in (temp_db, temp_gz):
if path.exists():
path.unlink()
source_uri = f"file:{DB_PATH}?mode=ro"
with sqlite3.connect(source_uri, uri=True) as source:
with sqlite3.connect(str(temp_db)) as target:
source.backup(target)
run_integrity_check(temp_db)
with temp_db.open("rb") as raw, gzip.open(temp_gz, "wb", compresslevel=6) as compressed:
shutil.copyfileobj(raw, compressed)
temp_gz.replace(final_gz)
shutil.copy2(final_gz, LATEST_BACKUP)
temp_db.unlink(missing_ok=True)
size_kb = final_gz.stat().st_size / 1024
log(f"created {final_gz.name} ({size_kb:.1f} KiB)")
def prune_old_backups() -> None:
if RETENTION_DAYS <= 0 or not BACKUP_DIR.exists():
return
cutoff = time.time() - (RETENTION_DAYS * 24 * 60 * 60)
deleted = 0
for backup in BACKUP_DIR.glob("drugs-*.db.gz"):
if backup.stat().st_mtime < cutoff:
backup.unlink()
deleted += 1
if deleted:
log(f"deleted {deleted} backup(s) older than {RETENTION_DAYS} day(s)")
def main() -> None:
log(
"starting SQLite backup loop: "
f"db={DB_PATH}, dir={BACKUP_DIR}, interval={INTERVAL_SECONDS}s, "
f"retention={RETENTION_DAYS}d"
)
while True:
try:
create_backup()
prune_old_backups()
except Exception as exc:
log(f"backup failed: {exc}")
time.sleep(INTERVAL_SECONDS)
if __name__ == "__main__":
main()