Compare commits

...

9 Commits

Author SHA1 Message Date
511e8ebde4 Notes printing function 2026-02-18 11:57:37 -05:00
1a82776657 Add notes API only 2026-02-18 06:15:48 -05:00
fa192247fe Private network fixes 2026-02-11 15:35:27 -05:00
65f951dfcc MQTT broker add 2026-02-09 15:28:48 -05:00
62bb5cb60d Update frontend print logic 2026-02-07 09:54:52 -05:00
7fd2e0050b Printer realtime status 2026-02-07 09:38:36 -05:00
20d8cefe22 Tidy up 2026-02-07 08:46:08 -05:00
d277d5c07b Notifications and printing 2026-02-07 07:01:31 -05:00
3d1c007609 Label print API 2026-02-07 06:41:39 -05:00
11 changed files with 906 additions and 35 deletions

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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');
} }
} }

View File

@@ -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">&times;</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">&times;</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>

View File

@@ -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%;
}
}

View 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
View File

@@ -0,0 +1 @@
mqtt:$7$1000$x5foCeSdbDKyb+S/CkGI37HWJEIAQil+PfMwREfPHHbRGsUwTWKcoHXcOC4l15mj1HMH8GovYvGGOVDHkOrcBA==$kXYD9LRqSkThHyWTivw8l8/NBdXvpZ9d8qwmIJcGabyrohRdkfXcEgRbEJP7sJ43r6nPX7+p1lb+nF0Actt4ww==

View File

@@ -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;
}
} }