Compare commits
9 Commits
dd5aa7c4bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 511e8ebde4 | |||
| 1a82776657 | |||
| fa192247fe | |||
| 65f951dfcc | |||
| 62bb5cb60d | |||
| 7fd2e0050b | |||
| 20d8cefe22 | |||
| d277d5c07b | |||
| 3d1c007609 |
17
README.md
17
README.md
@@ -90,6 +90,23 @@ environment:
|
|||||||
- DATABASE_URL=sqlite:///./drugs.db
|
- DATABASE_URL=sqlite:///./drugs.db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MQTT
|
||||||
|
|
||||||
|
The system includes an MQTT broker (Mosquitto) with WebSocket support:
|
||||||
|
|
||||||
|
- **MQTT**: `localhost:1883`
|
||||||
|
- **WebSocket**: `localhost:9001` or `/mqtt` via nginx
|
||||||
|
|
||||||
|
To create a new MQTT user with a custom password:
|
||||||
|
```bash
|
||||||
|
docker run --rm -v $(pwd)/mosquitto/config:/temp eclipse-mosquitto mosquitto_passwd -b /temp/pwfile username password
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the containers:
|
||||||
|
```bash
|
||||||
|
docker compose restart mosquitto
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
When you run `docker-compose up`, the backend automatically reloads when code changes (`--reload` flag). Just refresh your browser to see updates.
|
When you run `docker-compose up`, the backend automatically reloads when code changes (`--reload` flag). Just refresh your browser to see updates.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status
|
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from .database import engine, get_db, Base
|
from .database import engine, get_db, Base
|
||||||
from .models import Drug, DrugVariant, Dispensing, User
|
from .models import Drug, DrugVariant, Dispensing, User
|
||||||
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, ACCESS_TOKEN_EXPIRE_MINUTES
|
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
from .mqtt_service import publish_label_print_with_response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Create tables
|
# Create tables
|
||||||
@@ -117,6 +118,32 @@ class DispensingResponse(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
class LabelVariables(BaseModel):
|
||||||
|
practice_name: str
|
||||||
|
animal_name: str
|
||||||
|
drug_name: str
|
||||||
|
dosage: str
|
||||||
|
quantity: str
|
||||||
|
expiry_date: str
|
||||||
|
|
||||||
|
class LabelPrintRequest(BaseModel):
|
||||||
|
variables: LabelVariables
|
||||||
|
|
||||||
|
class LabelPrintResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class NotesVariables(BaseModel):
|
||||||
|
animal_name: str
|
||||||
|
notes: str
|
||||||
|
|
||||||
|
class NotesPrintRequest(BaseModel):
|
||||||
|
variables: NotesVariables
|
||||||
|
|
||||||
|
class NotesPrintResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
# Authentication Routes
|
# Authentication Routes
|
||||||
@router.post("/auth/register", response_model=TokenResponse)
|
@router.post("/auth/register", response_model=TokenResponse)
|
||||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
@@ -475,5 +502,150 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
|
|||||||
|
|
||||||
return db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all()
|
return db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all()
|
||||||
|
|
||||||
|
# Helper function to capitalize text for labels
|
||||||
|
def capitalize_label_text(text: str) -> str:
|
||||||
|
"""Capitalize the first letter of each sentence in the text"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Capitalize first letter of the entire string
|
||||||
|
result = text[0].upper() + text[1:] if len(text) > 1 else text.upper()
|
||||||
|
|
||||||
|
# Also capitalize after periods and common sentence breaks
|
||||||
|
for delimiter in ['. ', '! ', '? ']:
|
||||||
|
parts = result.split(delimiter)
|
||||||
|
result = delimiter.join([
|
||||||
|
part[0].upper() + part[1:] if part else part
|
||||||
|
for part in parts
|
||||||
|
])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Label printing endpoint
|
||||||
|
@router.post("/labels/print", response_model=LabelPrintResponse)
|
||||||
|
def print_label(label_request: LabelPrintRequest, current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Print a drug label by publishing an MQTT message
|
||||||
|
|
||||||
|
This endpoint publishes a label print request to the MQTT broker,
|
||||||
|
which will be picked up by the label printing service.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get label configuration from environment
|
||||||
|
import os
|
||||||
|
template_id = os.getenv("LABEL_TEMPLATE_ID", "vet_label")
|
||||||
|
label_size = os.getenv("LABEL_SIZE", "29x90")
|
||||||
|
test_mode = os.getenv("LABEL_TEST", "false").lower() == "true"
|
||||||
|
|
||||||
|
# Capitalize all text fields for better presentation
|
||||||
|
variables = label_request.variables.dict()
|
||||||
|
variables["practice_name"] = capitalize_label_text(variables["practice_name"])
|
||||||
|
variables["animal_name"] = capitalize_label_text(variables["animal_name"])
|
||||||
|
variables["drug_name"] = capitalize_label_text(variables["drug_name"])
|
||||||
|
variables["dosage"] = capitalize_label_text(variables["dosage"])
|
||||||
|
variables["quantity"] = capitalize_label_text(variables["quantity"])
|
||||||
|
# expiry_date doesn't need capitalization
|
||||||
|
|
||||||
|
# Convert the request to the MQTT message format
|
||||||
|
mqtt_message = {
|
||||||
|
"template_id": template_id,
|
||||||
|
"label_size": label_size,
|
||||||
|
"variables": variables,
|
||||||
|
"test": test_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
# Publish to MQTT and wait for response
|
||||||
|
success, response = publish_label_print_with_response(mqtt_message, timeout=10.0)
|
||||||
|
|
||||||
|
print(f"Label print result: success={success}, response={response}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
result = LabelPrintResponse(
|
||||||
|
success=True,
|
||||||
|
message=response.get("message", "Label printed successfully")
|
||||||
|
)
|
||||||
|
print(f"Returning success response: {result}")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Return error details from printer
|
||||||
|
# Check both 'message' and 'error' fields for error details
|
||||||
|
if response:
|
||||||
|
error_msg = response.get("message") or response.get("error", "Unknown error")
|
||||||
|
else:
|
||||||
|
error_msg = "No response from printer"
|
||||||
|
result = LabelPrintResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Print failed: {error_msg}"
|
||||||
|
)
|
||||||
|
print(f"Returning error response: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error sending label print request: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notes printing endpoint
|
||||||
|
@router.post("/notes/print", response_model=NotesPrintResponse)
|
||||||
|
def print_notes(notes_request: NotesPrintRequest, current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Print notes by publishing an MQTT message
|
||||||
|
|
||||||
|
This endpoint publishes a notes print request to the MQTT broker,
|
||||||
|
which will be picked up by the label printing service.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get notes template configuration from environment
|
||||||
|
import os
|
||||||
|
template_id = os.getenv("NOTES_TEMPLATE_ID", "notes_template")
|
||||||
|
label_size = os.getenv("LABEL_SIZE", "29x90")
|
||||||
|
test_mode = os.getenv("LABEL_TEST", "false").lower() == "true"
|
||||||
|
|
||||||
|
# Capitalize text fields for better presentation
|
||||||
|
variables = notes_request.variables.dict()
|
||||||
|
variables["animal_name"] = capitalize_label_text(variables["animal_name"])
|
||||||
|
variables["notes"] = capitalize_label_text(variables["notes"])
|
||||||
|
|
||||||
|
# Convert the request to the MQTT message format
|
||||||
|
mqtt_message = {
|
||||||
|
"template_id": template_id,
|
||||||
|
"label_size": label_size,
|
||||||
|
"variables": variables,
|
||||||
|
"test": test_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
# Publish to MQTT and wait for response
|
||||||
|
success, response = publish_label_print_with_response(mqtt_message, timeout=10.0)
|
||||||
|
|
||||||
|
print(f"Notes print result: success={success}, response={response}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
result = NotesPrintResponse(
|
||||||
|
success=True,
|
||||||
|
message=response.get("message", "Notes printed successfully")
|
||||||
|
)
|
||||||
|
print(f"Returning success response: {result}")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Return error details from printer
|
||||||
|
# Check both 'message' and 'error' fields for error details
|
||||||
|
if response:
|
||||||
|
error_msg = response.get("message") or response.get("error", "Unknown error")
|
||||||
|
else:
|
||||||
|
error_msg = "No response from printer"
|
||||||
|
result = NotesPrintResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Print failed: {error_msg}"
|
||||||
|
)
|
||||||
|
print(f"Returning error response: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error sending notes print request: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Include router with /api prefix
|
# Include router with /api prefix
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|||||||
220
backend/app/mqtt_service.py
Normal file
220
backend/app/mqtt_service.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import threading
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# MQTT Configuration from environment
|
||||||
|
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
|
||||||
|
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
|
||||||
|
MQTT_USERNAME = os.getenv("MQTT_USERNAME", "")
|
||||||
|
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
|
||||||
|
MQTT_LABEL_TOPIC = os.getenv("MQTT_LABEL_TOPIC", "vet/labels/print")
|
||||||
|
MQTT_STATUS_TOPIC = os.getenv("MQTT_STATUS_TOPIC", "vet/labels/status")
|
||||||
|
|
||||||
|
# Store responses by job_id
|
||||||
|
_response_store: Dict[str, Any] = {}
|
||||||
|
_response_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Persistent MQTT client
|
||||||
|
_mqtt_client: Optional[mqtt.Client] = None
|
||||||
|
_client_connected = threading.Event()
|
||||||
|
_client_lock = threading.Lock()
|
||||||
|
_initialization_attempted = False
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, rc):
|
||||||
|
"""Callback when connected to broker"""
|
||||||
|
if rc == 0:
|
||||||
|
print(f"MQTT: Connected to broker at {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
|
||||||
|
# Subscribe to status topic
|
||||||
|
result = client.subscribe(MQTT_STATUS_TOPIC, qos=1)
|
||||||
|
print(f"MQTT: Subscribed to {MQTT_STATUS_TOPIC}, result={result}")
|
||||||
|
_client_connected.set()
|
||||||
|
else:
|
||||||
|
print(f"MQTT: Failed to connect, return code: {rc}")
|
||||||
|
_client_connected.clear()
|
||||||
|
|
||||||
|
def on_disconnect(client, userdata, rc):
|
||||||
|
"""Callback when disconnected from broker"""
|
||||||
|
print(f"MQTT: Disconnected, return code: {rc}")
|
||||||
|
_client_connected.clear()
|
||||||
|
|
||||||
|
def on_status_message(client, userdata, message):
|
||||||
|
"""Callback for status messages"""
|
||||||
|
print(f"MQTT: Received message on topic '{message.topic}': {message.payload.decode()[:200]}")
|
||||||
|
try:
|
||||||
|
payload = json.loads(message.payload.decode())
|
||||||
|
job_id = payload.get("job_id")
|
||||||
|
if job_id:
|
||||||
|
with _response_lock:
|
||||||
|
_response_store[job_id] = payload
|
||||||
|
print(f"MQTT: Stored response for job {job_id}: {payload.get('status')}")
|
||||||
|
else:
|
||||||
|
print(f"MQTT: Message has no job_id: {payload}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MQTT: Error processing status message: {str(e)}")
|
||||||
|
|
||||||
|
def get_mqtt_client():
|
||||||
|
"""Get or initialize the persistent MQTT client"""
|
||||||
|
global _mqtt_client, _initialization_attempted
|
||||||
|
|
||||||
|
with _client_lock:
|
||||||
|
# If client exists and is connected, return it
|
||||||
|
if _mqtt_client is not None and _client_connected.is_set():
|
||||||
|
return _mqtt_client
|
||||||
|
|
||||||
|
# If we already tried and failed recently, don't retry immediately
|
||||||
|
if _initialization_attempted and _mqtt_client is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_initialization_attempted = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"MQTT: Initializing client connection to {MQTT_BROKER_HOST}:{MQTT_BROKER_PORT}")
|
||||||
|
|
||||||
|
# Clean up old client if exists
|
||||||
|
if _mqtt_client is not None:
|
||||||
|
try:
|
||||||
|
_mqtt_client.loop_stop()
|
||||||
|
_mqtt_client.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create new MQTT client
|
||||||
|
_mqtt_client = mqtt.Client(client_id=f"drug-inventory-main")
|
||||||
|
|
||||||
|
# Set username and password if provided
|
||||||
|
if MQTT_USERNAME and MQTT_PASSWORD:
|
||||||
|
_mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||||
|
|
||||||
|
# Set up callbacks
|
||||||
|
_mqtt_client.on_connect = on_connect
|
||||||
|
_mqtt_client.on_disconnect = on_disconnect
|
||||||
|
_mqtt_client.on_message = on_status_message
|
||||||
|
|
||||||
|
# Connect to broker
|
||||||
|
_mqtt_client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
|
||||||
|
|
||||||
|
# Start network loop in background
|
||||||
|
_mqtt_client.loop_start()
|
||||||
|
|
||||||
|
# Wait for connection (max 3 seconds)
|
||||||
|
if _client_connected.wait(timeout=3.0):
|
||||||
|
print("MQTT: Client initialized successfully")
|
||||||
|
return _mqtt_client
|
||||||
|
else:
|
||||||
|
print("MQTT: Connection timeout")
|
||||||
|
_mqtt_client.loop_stop()
|
||||||
|
_mqtt_client = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MQTT: Error initializing client: {str(e)}")
|
||||||
|
_mqtt_client = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def publish_label_print_with_response(label_data: dict, timeout: float = 5.0) -> tuple[bool, Optional[dict]]:
|
||||||
|
"""
|
||||||
|
Publish a label print request to MQTT broker and wait for response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label_data: Dictionary containing label print information
|
||||||
|
timeout: Maximum time to wait for response in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, response_data)
|
||||||
|
"""
|
||||||
|
# Get or initialize MQTT client
|
||||||
|
client = get_mqtt_client()
|
||||||
|
if client is None:
|
||||||
|
print("MQTT: Client not available")
|
||||||
|
return False, {"status": "error", "message": "MQTT client not connected"}
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
label_data["job_id"] = job_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Publish message
|
||||||
|
message = json.dumps(label_data)
|
||||||
|
result = client.publish(MQTT_LABEL_TOPIC, message, qos=1)
|
||||||
|
|
||||||
|
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
print(f"MQTT: Failed to publish, rc={result.rc}")
|
||||||
|
return False, {"status": "error", "message": "Failed to publish message"}
|
||||||
|
|
||||||
|
print(f"MQTT: Published job {job_id}")
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
with _response_lock:
|
||||||
|
if job_id in _response_store:
|
||||||
|
response = _response_store.pop(job_id)
|
||||||
|
|
||||||
|
# Check if print was successful
|
||||||
|
status = response.get("status", "").lower()
|
||||||
|
success = status in ["success", "completed", "ok"]
|
||||||
|
print(f"MQTT: Job {job_id} completed with status: {status}")
|
||||||
|
return success, response
|
||||||
|
|
||||||
|
time.sleep(0.05) # Check every 50ms
|
||||||
|
|
||||||
|
# Timeout - no response received
|
||||||
|
print(f"MQTT: Timeout waiting for job {job_id}")
|
||||||
|
# Clean up in case response arrives late
|
||||||
|
with _response_lock:
|
||||||
|
_response_store.pop(job_id, None)
|
||||||
|
return False, {"status": "timeout", "message": "No response from printer"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MQTT: Error in publish_label_print_with_response: {str(e)}")
|
||||||
|
return False, {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
def publish_label_print(label_data: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Publish a label print request to MQTT broker (fire and forget)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label_data: Dictionary containing label print information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create MQTT client
|
||||||
|
client = mqtt.Client()
|
||||||
|
|
||||||
|
# Set username and password if provided
|
||||||
|
if MQTT_USERNAME and MQTT_PASSWORD:
|
||||||
|
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
|
||||||
|
|
||||||
|
# Connect to broker
|
||||||
|
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, 60)
|
||||||
|
|
||||||
|
# Start network loop to process connection
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
# Publish message with QoS 0 (fire and forget)
|
||||||
|
message = json.dumps(label_data)
|
||||||
|
result = client.publish(MQTT_LABEL_TOPIC, message, qos=0)
|
||||||
|
|
||||||
|
# Stop loop and disconnect
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
|
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
logger.info(f"Successfully published label print request to {MQTT_LABEL_TOPIC}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to publish label print request: {result.rc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error publishing MQTT message: {str(e)}")
|
||||||
|
return False
|
||||||
@@ -5,3 +5,4 @@ pydantic==2.5.0
|
|||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[argon2]==1.7.4
|
passlib[argon2]==1.7.4
|
||||||
|
paho-mqtt==1.6.1
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
|
||||||
- "${BACKEND_PORT}:8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
@@ -14,13 +12,45 @@ services:
|
|||||||
- DATABASE_URL=sqlite:///./data/drugs.db
|
- DATABASE_URL=sqlite:///./data/drugs.db
|
||||||
- PUID=1001
|
- PUID=1001
|
||||||
- PGID=1001
|
- PGID=1001
|
||||||
|
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
|
||||||
|
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
|
||||||
|
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
||||||
|
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
||||||
|
- MQTT_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print}
|
||||||
|
- MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status}
|
||||||
|
- LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label}
|
||||||
|
- LABEL_SIZE=${LABEL_SIZE:-29x90}
|
||||||
|
- LABEL_TEST=${LABEL_TEST:-false}
|
||||||
|
|
||||||
|
mosquitto:
|
||||||
|
image: eclipse-mosquitto:latest
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||||
|
- ./mosquitto/config/pwfile:/mosquitto/config/pwfile:ro
|
||||||
|
- mosquitto_data:/mosquitto/data
|
||||||
|
- mosquitto_logs:/mosquitto/log
|
||||||
|
environment:
|
||||||
|
- PUID=1001
|
||||||
|
- PGID=1001
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
ports:
|
container_name: drugsprod
|
||||||
- "${FRONTEND_PORT}:80"
|
|
||||||
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
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
- mosquitto
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- webapps
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mosquitto_data:
|
||||||
|
mosquitto_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
webapps:
|
||||||
|
external: true
|
||||||
|
|||||||
276
frontend/app.js
276
frontend/app.js
@@ -7,6 +7,37 @@ let expandedDrugs = new Set();
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let accessToken = null;
|
let accessToken = null;
|
||||||
|
|
||||||
|
// Toast notification system
|
||||||
|
function showToast(message, type = 'info', duration = 3000) {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-icon">${icons[type] || icons.info}</span>
|
||||||
|
<span class="toast-message">${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
container.removeChild(toast);
|
||||||
|
}, 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
@@ -139,18 +170,24 @@ function setupEventListeners() {
|
|||||||
const variantForm = document.getElementById('variantForm');
|
const variantForm = document.getElementById('variantForm');
|
||||||
const editVariantForm = document.getElementById('editVariantForm');
|
const editVariantForm = document.getElementById('editVariantForm');
|
||||||
const dispenseForm = document.getElementById('dispenseForm');
|
const dispenseForm = document.getElementById('dispenseForm');
|
||||||
|
const prescribeForm = document.getElementById('prescribeForm');
|
||||||
const editForm = document.getElementById('editForm');
|
const editForm = document.getElementById('editForm');
|
||||||
|
const printNotesForm = document.getElementById('printNotesForm');
|
||||||
const addModal = document.getElementById('addModal');
|
const addModal = document.getElementById('addModal');
|
||||||
const addVariantModal = document.getElementById('addVariantModal');
|
const addVariantModal = document.getElementById('addVariantModal');
|
||||||
const editVariantModal = document.getElementById('editVariantModal');
|
const editVariantModal = document.getElementById('editVariantModal');
|
||||||
const dispenseModal = document.getElementById('dispenseModal');
|
const dispenseModal = document.getElementById('dispenseModal');
|
||||||
|
const prescribeModal = document.getElementById('prescribeModal');
|
||||||
const editModal = document.getElementById('editModal');
|
const editModal = document.getElementById('editModal');
|
||||||
|
const printNotesModal = document.getElementById('printNotesModal');
|
||||||
const addDrugBtn = document.getElementById('addDrugBtn');
|
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||||
const dispenseBtn = document.getElementById('dispenseBtn');
|
const dispenseBtn = document.getElementById('dispenseBtn');
|
||||||
|
const printNotesBtn = document.getElementById('printNotesBtn');
|
||||||
const cancelAddBtn = document.getElementById('cancelAddBtn');
|
const cancelAddBtn = document.getElementById('cancelAddBtn');
|
||||||
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
|
const cancelVariantBtn = document.getElementById('cancelVariantBtn');
|
||||||
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
|
const cancelEditVariantBtn = document.getElementById('cancelEditVariantBtn');
|
||||||
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
|
const cancelDispenseBtn = document.getElementById('cancelDispenseBtn');
|
||||||
|
const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn');
|
||||||
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
const cancelEditBtn = document.getElementById('cancelEditBtn');
|
||||||
const showAllBtn = document.getElementById('showAllBtn');
|
const showAllBtn = document.getElementById('showAllBtn');
|
||||||
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
const showLowStockBtn = document.getElementById('showLowStockBtn');
|
||||||
@@ -166,9 +203,12 @@ function setupEventListeners() {
|
|||||||
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
|
if (variantForm) variantForm.addEventListener('submit', handleAddVariant);
|
||||||
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
|
if (editVariantForm) editVariantForm.addEventListener('submit', handleEditVariant);
|
||||||
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
|
if (dispenseForm) dispenseForm.addEventListener('submit', handleDispenseDrug);
|
||||||
|
if (prescribeForm) prescribeForm.addEventListener('submit', handlePrescribeDrug);
|
||||||
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
if (editForm) editForm.addEventListener('submit', handleEditDrug);
|
||||||
|
if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes);
|
||||||
|
|
||||||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||||
|
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||||||
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||||||
updateDispenseDrugSelect();
|
updateDispenseDrugSelect();
|
||||||
openModal(dispenseModal);
|
openModal(dispenseModal);
|
||||||
@@ -178,8 +218,12 @@ function setupEventListeners() {
|
|||||||
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
if (cancelVariantBtn) cancelVariantBtn.addEventListener('click', () => closeModal(addVariantModal));
|
||||||
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
if (cancelEditVariantBtn) cancelEditVariantBtn.addEventListener('click', () => closeModal(editVariantModal));
|
||||||
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
if (cancelDispenseBtn) cancelDispenseBtn.addEventListener('click', () => closeModal(dispenseModal));
|
||||||
|
if (cancelPrescribeBtn) cancelPrescribeBtn.addEventListener('click', () => closeModal(prescribeModal));
|
||||||
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
if (cancelEditBtn) cancelEditBtn.addEventListener('click', closeEditModal);
|
||||||
|
|
||||||
|
const cancelPrintNotesBtn = document.getElementById('cancelPrintNotesBtn');
|
||||||
|
if (cancelPrintNotesBtn) cancelPrintNotesBtn.addEventListener('click', () => closeModal(printNotesModal));
|
||||||
|
|
||||||
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||||||
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
if (closeHistoryBtn) closeHistoryBtn.addEventListener('click', () => closeModal(document.getElementById('historyModal')));
|
||||||
|
|
||||||
@@ -324,6 +368,11 @@ function renderDrugs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by drug name
|
||||||
|
drugsToShow = drugsToShow.sort((a, b) =>
|
||||||
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (drugsToShow.length === 0) {
|
if (drugsToShow.length === 0) {
|
||||||
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
|
drugsList.innerHTML = '<p class="empty">No drugs found matching your criteria</p>';
|
||||||
return;
|
return;
|
||||||
@@ -353,6 +402,7 @@ function renderDrugs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="variant-actions">
|
<div class="variant-actions">
|
||||||
|
<button class="btn btn-primary btn-small" onclick="prescribeVariant(${variant.id}, '${drug.name.replace(/'/g, "\\'")}', '${variant.strength.replace(/'/g, "\\'")}', '${variant.unit.replace(/'/g, "\\'")}')">🏷️ Prescribe & Print</button>
|
||||||
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
|
<button class="btn btn-success btn-small" onclick="dispenseVariant(${variant.id})">💊 Dispense</button>
|
||||||
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
|
<button class="btn btn-warning btn-small" onclick="openEditVariantModal(${variant.id})">Edit</button>
|
||||||
<button class="btn btn-danger btn-small" onclick="deleteVariant(${variant.id})">Delete</button>
|
<button class="btn btn-danger btn-small" onclick="deleteVariant(${variant.id})">Delete</button>
|
||||||
@@ -430,10 +480,10 @@ async function handleAddDrug(e) {
|
|||||||
document.getElementById('initialVariantThreshold').value = '10';
|
document.getElementById('initialVariantThreshold').value = '10';
|
||||||
closeModal(document.getElementById('addModal'));
|
closeModal(document.getElementById('addModal'));
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
alert('Drug added successfully!');
|
showToast('Drug added successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding drug:', error);
|
console.error('Error adding drug:', error);
|
||||||
alert('Failed to add drug. Check the console for details.');
|
showToast('Failed to add drug. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +498,7 @@ async function handleDispenseDrug(e) {
|
|||||||
const notes = document.getElementById('dispenseNotes').value;
|
const notes = document.getElementById('dispenseNotes').value;
|
||||||
|
|
||||||
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
|
if (!variantId || isNaN(quantity) || quantity <= 0 || !userName) {
|
||||||
alert('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)');
|
showToast('Please fill in all required fields (Drug Variant, Quantity > 0, Dispensed by)', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,10 +524,10 @@ async function handleDispenseDrug(e) {
|
|||||||
document.getElementById('dispenseForm').reset();
|
document.getElementById('dispenseForm').reset();
|
||||||
closeModal(document.getElementById('dispenseModal'));
|
closeModal(document.getElementById('dispenseModal'));
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
alert('Drug dispensed successfully!');
|
showToast('Drug dispensed successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error dispensing drug:', error);
|
console.error('Error dispensing drug:', error);
|
||||||
alert('Failed to dispense drug: ' + error.message);
|
showToast('Failed to dispense drug: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,10 +593,10 @@ async function handleAddVariant(e) {
|
|||||||
closeModal(document.getElementById('addVariantModal'));
|
closeModal(document.getElementById('addVariantModal'));
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
alert('Variant added successfully!');
|
showToast('Variant added successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding variant:', error);
|
console.error('Error adding variant:', error);
|
||||||
alert('Failed to add variant. Check the console for details.');
|
showToast('Failed to add variant. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,10 +643,10 @@ async function handleEditVariant(e) {
|
|||||||
closeModal(document.getElementById('editVariantModal'));
|
closeModal(document.getElementById('editVariantModal'));
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
alert('Variant updated successfully!');
|
showToast('Variant updated successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating variant:', error);
|
console.error('Error updating variant:', error);
|
||||||
alert('Failed to update variant. Check the console for details.');
|
showToast('Failed to update variant. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +663,178 @@ function dispenseVariant(variantId) {
|
|||||||
openModal(document.getElementById('dispenseModal'));
|
openModal(document.getElementById('dispenseModal'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prescribe variant and print label
|
||||||
|
function prescribeVariant(variantId, drugName, variantStrength, unit) {
|
||||||
|
// Set hidden fields
|
||||||
|
document.getElementById('prescribeVariantId').value = variantId;
|
||||||
|
document.getElementById('prescribeDrugName').value = drugName;
|
||||||
|
document.getElementById('prescribeVariantStrength').value = variantStrength;
|
||||||
|
document.getElementById('prescribeUnit').value = unit || 'units';
|
||||||
|
|
||||||
|
// Pre-fill user name if available
|
||||||
|
if (currentUser) {
|
||||||
|
document.getElementById('prescribeUser').value = currentUser.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default expiry date to 1 month from now
|
||||||
|
const defaultExpiry = new Date();
|
||||||
|
defaultExpiry.setMonth(defaultExpiry.getMonth() + 1);
|
||||||
|
document.getElementById('prescribeExpiry').value = defaultExpiry.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Open prescribe modal
|
||||||
|
openModal(document.getElementById('prescribeModal'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle prescribe drug form submission
|
||||||
|
async function handlePrescribeDrug(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const variantId = parseInt(document.getElementById('prescribeVariantId').value);
|
||||||
|
const drugName = document.getElementById('prescribeDrugName').value;
|
||||||
|
const variantStrength = document.getElementById('prescribeVariantStrength').value;
|
||||||
|
const unit = document.getElementById('prescribeUnit').value;
|
||||||
|
const quantity = parseFloat(document.getElementById('prescribeQuantity').value);
|
||||||
|
const animalName = document.getElementById('prescribeAnimal').value;
|
||||||
|
const dosage = document.getElementById('prescribeDosage').value;
|
||||||
|
const expiryDate = document.getElementById('prescribeExpiry').value;
|
||||||
|
const userName = document.getElementById('prescribeUser').value;
|
||||||
|
const notes = document.getElementById('prescribeNotes').value;
|
||||||
|
|
||||||
|
if (!variantId || isNaN(quantity) || quantity <= 0 || !animalName || !dosage || !expiryDate || !userName) {
|
||||||
|
showToast('Please fill in all required fields', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert expiry date to DD/MM/YYYY format
|
||||||
|
const expiryParts = expiryDate.split('-');
|
||||||
|
const formattedExpiry = `${expiryParts[2]}/${expiryParts[1]}/${expiryParts[0]}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, print the label
|
||||||
|
const labelData = {
|
||||||
|
variables: {
|
||||||
|
practice_name: "Many Tears Animal Rescue",
|
||||||
|
animal_name: animalName,
|
||||||
|
drug_name: `${drugName} ${variantStrength}`,
|
||||||
|
dosage: dosage,
|
||||||
|
quantity: `${quantity} ${unit}`,
|
||||||
|
expiry_date: formattedExpiry
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelResponse = await apiCall('/labels/print', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(labelData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!labelResponse.ok) {
|
||||||
|
const error = await labelResponse.json();
|
||||||
|
throw new Error(error.detail || 'Label printing request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelResult = await labelResponse.json();
|
||||||
|
console.log('Label print result:', labelResult);
|
||||||
|
|
||||||
|
if (!labelResult.success) {
|
||||||
|
// Label printing failed - don't dispense the drug
|
||||||
|
const isError = labelResult.message && (
|
||||||
|
labelResult.message.includes('not found') ||
|
||||||
|
labelResult.message.includes('error') ||
|
||||||
|
labelResult.message.includes('failed')
|
||||||
|
);
|
||||||
|
const toastType = isError ? 'error' : 'warning';
|
||||||
|
showToast('Cannot dispense: ' + labelResult.message, toastType, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label printed successfully, now dispense the drug
|
||||||
|
const dispensingData = {
|
||||||
|
drug_variant_id: variantId,
|
||||||
|
quantity: quantity,
|
||||||
|
animal_name: animalName,
|
||||||
|
user_name: userName,
|
||||||
|
notes: notes || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispenseResponse = await apiCall('/dispense', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(dispensingData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dispenseResponse.ok) {
|
||||||
|
const error = await dispenseResponse.json();
|
||||||
|
throw new Error(error.detail || 'Failed to dispense drug');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both operations succeeded
|
||||||
|
showToast('Drug prescribed and label printed successfully!', 'success');
|
||||||
|
|
||||||
|
document.getElementById('prescribeForm').reset();
|
||||||
|
closeModal(document.getElementById('prescribeModal'));
|
||||||
|
await loadDrugs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error prescribing drug:', error);
|
||||||
|
showToast('Failed to prescribe drug: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle print notes form submission
|
||||||
|
async function handlePrintNotes(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const animalName = document.getElementById('notesAnimalName').value.trim();
|
||||||
|
const notes = document.getElementById('notesContent').value.trim();
|
||||||
|
|
||||||
|
if (!animalName || !notes) {
|
||||||
|
showToast('Please fill in all required fields', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send notes to print endpoint
|
||||||
|
const notesData = {
|
||||||
|
variables: {
|
||||||
|
animal_name: animalName,
|
||||||
|
notes: notes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiCall('/notes/print', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(notesData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Notes printing request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Notes print result:', result);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Printing failed
|
||||||
|
const isError = result.message && (
|
||||||
|
result.message.includes('not found') ||
|
||||||
|
result.message.includes('error') ||
|
||||||
|
result.message.includes('failed')
|
||||||
|
);
|
||||||
|
const toastType = isError ? 'error' : 'warning';
|
||||||
|
showToast(result.message, toastType, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printing succeeded
|
||||||
|
showToast('Notes printed successfully!', 'success');
|
||||||
|
|
||||||
|
document.getElementById('printNotesForm').reset();
|
||||||
|
closeModal(document.getElementById('printNotesModal'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error printing notes:', error);
|
||||||
|
showToast('Failed to print notes: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete variant
|
// Delete variant
|
||||||
async function deleteVariant(variantId) {
|
async function deleteVariant(variantId) {
|
||||||
if (!confirm('Are you sure you want to delete this variant?')) return;
|
if (!confirm('Are you sure you want to delete this variant?')) return;
|
||||||
@@ -626,10 +848,10 @@ async function deleteVariant(variantId) {
|
|||||||
|
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
alert('Variant deleted successfully!');
|
showToast('Variant deleted successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting variant:', error);
|
console.error('Error deleting variant:', error);
|
||||||
alert('Failed to delete variant. Check the console for details.');
|
showToast('Failed to delete variant. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,10 +947,10 @@ async function handleEditDrug(e) {
|
|||||||
|
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
alert('Drug updated successfully!');
|
showToast('Drug updated successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating drug:', error);
|
console.error('Error updating drug:', error);
|
||||||
alert('Failed to update drug. Check the console for details.');
|
showToast('Failed to update drug. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,10 +966,10 @@ async function deleteDrug(drugId) {
|
|||||||
if (!response.ok) throw new Error('Failed to delete drug');
|
if (!response.ok) throw new Error('Failed to delete drug');
|
||||||
|
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
alert('Drug deleted successfully!');
|
showToast('Drug deleted successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting drug:', error);
|
console.error('Error deleting drug:', error);
|
||||||
alert('Failed to delete drug. Check the console for details.');
|
showToast('Failed to delete drug. Check the console for details.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,12 +993,12 @@ async function handleChangePassword(e) {
|
|||||||
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
const confirmPassword = document.getElementById('confirmNewPassword').value;
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
alert('New passwords do not match!');
|
showToast('New passwords do not match!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 1) {
|
if (newPassword.length < 1) {
|
||||||
alert('New password cannot be empty!');
|
showToast('New password cannot be empty!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,11 +1016,11 @@ async function handleChangePassword(e) {
|
|||||||
throw new Error(error.detail || 'Failed to change password');
|
throw new Error(error.detail || 'Failed to change password');
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Password changed successfully!');
|
showToast('Password changed successfully!', 'success');
|
||||||
closeModal(document.getElementById('changePasswordModal'));
|
closeModal(document.getElementById('changePasswordModal'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error changing password:', error);
|
console.error('Error changing password:', error);
|
||||||
alert('Failed to change password: ' + error.message);
|
showToast('Failed to change password: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,12 +1040,12 @@ async function handleAdminChangePassword(e) {
|
|||||||
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
|
const confirmPassword = document.getElementById('adminChangePasswordConfirm').value;
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
alert('Passwords do not match!');
|
showToast('Passwords do not match!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 1) {
|
if (newPassword.length < 1) {
|
||||||
alert('Password cannot be empty!');
|
showToast('Password cannot be empty!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,12 +1062,12 @@ async function handleAdminChangePassword(e) {
|
|||||||
throw new Error(error.detail || 'Failed to change password');
|
throw new Error(error.detail || 'Failed to change password');
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Password changed successfully!');
|
showToast('Password changed successfully!', 'success');
|
||||||
closeModal(document.getElementById('adminChangePasswordModal'));
|
closeModal(document.getElementById('adminChangePasswordModal'));
|
||||||
openUserManagement();
|
openUserManagement();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error changing password:', error);
|
console.error('Error changing password:', error);
|
||||||
alert('Failed to change password: ' + error.message);
|
showToast('Failed to change password: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,11 +1149,11 @@ async function createUser(e) {
|
|||||||
|
|
||||||
document.getElementById('newUsername').value = '';
|
document.getElementById('newUsername').value = '';
|
||||||
document.getElementById('newUserPassword').value = '';
|
document.getElementById('newUserPassword').value = '';
|
||||||
alert('User created successfully!');
|
showToast('User created successfully!', 'success');
|
||||||
openUserManagement();
|
openUserManagement();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating user:', error);
|
console.error('Error creating user:', error);
|
||||||
alert('Failed to create user: ' + error.message);
|
showToast('Failed to create user: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,10 +1166,10 @@ async function deleteUser(userId) {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete user');
|
if (!response.ok) throw new Error('Failed to delete user');
|
||||||
|
|
||||||
alert('User deleted successfully!');
|
showToast('User deleted successfully!', 'success');
|
||||||
openUserManagement();
|
openUserManagement();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
alert('Failed to delete user: ' + error.message);
|
showToast('Failed to delete user: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Toast Notification Container -->
|
||||||
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
<!-- Login Page -->
|
<!-- Login Page -->
|
||||||
<div id="loginPage" class="login-page">
|
<div id="loginPage" class="login-page">
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Current Inventory</h2>
|
<h2>Current Inventory</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</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="filters">
|
<div class="filters">
|
||||||
@@ -196,6 +200,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Prescribe Drug Modal -->
|
||||||
|
<div id="prescribeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Prescribe Drug & Print Label</h2>
|
||||||
|
<form id="prescribeForm" novalidate>
|
||||||
|
<input type="hidden" id="prescribeVariantId">
|
||||||
|
<input type="hidden" id="prescribeDrugName">
|
||||||
|
<input type="hidden" id="prescribeVariantStrength">
|
||||||
|
<input type="hidden" id="prescribeUnit">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeQuantity">Quantity *</label>
|
||||||
|
<input type="number" id="prescribeQuantity" step="0.1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeAnimal">Animal Name/ID *</label>
|
||||||
|
<input type="text" id="prescribeAnimal" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeDosage">Dosage Instructions *</label>
|
||||||
|
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeExpiry">Expiry Date *</label>
|
||||||
|
<input type="date" id="prescribeExpiry" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeUser">Prescribed by *</label>
|
||||||
|
<input type="text" id="prescribeUser" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="prescribeNotes">Notes</label>
|
||||||
|
<input type="text" id="prescribeNotes" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">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">
|
||||||
@@ -377,6 +430,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Print Notes Modal -->
|
||||||
|
<div id="printNotesModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Print Notes</h2>
|
||||||
|
<form id="printNotesForm" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notesAnimalName">Animal Name/ID *</label>
|
||||||
|
<input type="text" id="notesAnimalName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notesContent">Notes *</label>
|
||||||
|
<textarea id="notesContent" rows="6" placeholder="Enter notes to print..." required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Print Notes</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelPrintNotesBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@@ -252,7 +252,9 @@ label {
|
|||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
select {
|
input[type="date"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -262,10 +264,17 @@ select {
|
|||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"]:focus,
|
input[type="text"]:focus,
|
||||||
input[type="number"]:focus,
|
input[type="number"]:focus,
|
||||||
input[type="password"]:focus,
|
input[type="password"]:focus,
|
||||||
select:focus {
|
input[type="date"]:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--secondary-color);
|
border-color: var(--secondary-color);
|
||||||
box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
|
box-shadow: 0 0 5px rgba(52, 152, 219, 0.3);
|
||||||
@@ -291,7 +300,6 @@ select:focus {
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
@@ -947,3 +955,95 @@ footer {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Toast Notifications */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
border-left-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-left-color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning {
|
||||||
|
border-left-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-left-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
font-size: 1.5em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-dark);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toast-container {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
mosquitto/config/mosquitto.conf
Normal file
20
mosquitto/config/mosquitto.conf
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
listener 1883
|
||||||
|
listener 9001
|
||||||
|
protocol websockets
|
||||||
|
|
||||||
|
persistence true
|
||||||
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
allow_anonymous false
|
||||||
|
password_file /mosquitto/config/pwfile
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_dest stdout
|
||||||
|
log_timestamp true
|
||||||
|
#log_type all
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
max_connections -1
|
||||||
|
max_queued_messages 1000
|
||||||
|
|
||||||
1
mosquitto/config/pwfile
Normal file
1
mosquitto/config/pwfile
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mqtt:$7$1000$x5foCeSdbDKyb+S/CkGI37HWJEIAQil+PfMwREfPHHbRGsUwTWKcoHXcOC4l15mj1HMH8GovYvGGOVDHkOrcBA==$kXYD9LRqSkThHyWTivw8l8/NBdXvpZ9d8qwmIJcGabyrohRdkfXcEgRbEJP7sJ43r6nPX7+p1lb+nF0Actt4ww==
|
||||||
11
nginx.conf
11
nginx.conf
@@ -16,5 +16,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /mqtt {
|
||||||
|
proxy_pass http://mosquitto:9001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user