Compare commits

..

23 Commits

Author SHA1 Message Date
jamesp 0026ac2274 Backup script 2026-06-19 17:42:28 +01:00
jamesp b093e1a90c Disposal 2026-06-19 17:21:07 +01:00
jamesp 25c3f1fa64 Scroll wheel fix again 2026-06-18 20:20:03 +01:00
jamesp 3f230bb0d7 Stock Take PDF 2026-06-18 20:15:24 +01:00
jamesp 0fea301af1 Scroll wheel fix 2026-05-19 15:58:56 -04:00
jamesp 562494a967 Couple of prod fixes 2026-04-26 16:19:13 -04:00
jamesp 05a093afd3 Dispensing Vet 2026-04-26 16:02:42 -04:00
jamesp 9ec27e245a Scan shortcut on main screen 2026-04-20 14:28:21 -04:00
jamesp 36634dc2bf Refactor - API lazy loading 2026-04-20 14:12:11 -04:00
jamesp 6be571a48c GS1 scanning and workflow improvements 2026-04-20 12:43:29 -04:00
jamesp cfb08bd288 Barcode scanning and GTIN mapping 2026-04-16 15:32:36 -04:00
jamesp 2aeba2f563 Add new pack size modal 2026-04-15 06:39:54 -04:00
jamesp d4753c0754 Ship it 2026-04-12 06:31:03 -04:00
jamesp 225202aacb Minor UI tweaks 2026-04-12 05:37:57 -04:00
jamesp 4673de4ae5 Add drug 2026-04-06 11:26:51 -04:00
jamesp cdbf613e4b Remove old Print button 2026-04-06 11:18:35 -04:00
jamesp 36f0a5b07e Reporting and batch management 2026-04-06 11:04:06 -04:00
jamesp b958ca493b Batch disposal 2026-04-06 10:41:33 -04:00
jamesp 5b5e17ec3e Better dispensing 2026-04-06 09:15:38 -04:00
jamesp 664a3189bd WIP gettnig there 2026-03-29 11:13:56 -04:00
jamesp ad1bb59f98 WIP 2026-03-29 10:39:03 -04:00
jamesp e00669ae2c WIP 2026-03-28 15:10:11 -04:00
jamesp 0521b8dfd6 WIP compliance 2026-03-28 14:41:15 -04:00
15 changed files with 9090 additions and 406 deletions
+1
View File
@@ -20,3 +20,4 @@ node_modules/
.cache/ .cache/
.pytest_cache/ .pytest_cache/
drugs.db drugs.db
data/backups/
+32 -3
View File
@@ -77,9 +77,38 @@ docker-compose up --build
## Database ## Database
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can: SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
- Backup by copying the file
- Share with team members ### Backups
The Docker Compose stack includes a `backup` sidecar that creates a SQLite-safe compressed backup every hour and keeps backups for 7 days.
Backups are stored in:
```bash
./data/backups/
```
The latest backup is also copied to:
```bash
./data/backups/latest.db.gz
```
To restore a backup:
```bash
docker compose stop backend backup
gunzip -c data/backups/drugs-YYYY-MM-DDTHH-MM-SSZ.db.gz > data/drugs.db
docker compose up -d backend backup
```
Backup interval and retention can be changed with:
```bash
BACKUP_INTERVAL_SECONDS=3600
BACKUP_RETENTION_DAYS=7
```
## Configuration ## Configuration
+1 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
import os import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./drugs.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/drugs.db")
# For SQLite, ensure the directory exists # For SQLite, ensure the directory exists
if "sqlite" in DATABASE_URL: if "sqlite" in DATABASE_URL:
@@ -18,10 +18,6 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
# Drop and recreate all tables (for development only)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
try: try:
+2269 -45
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
"""
Compliance schema migration helpers.
This module applies additive migrations for SQLite databases used by this project.
It is intentionally lightweight and idempotent because the project does not yet
use Alembic-style versioned migrations.
"""
import os
import sqlite3
from pathlib import Path
DEFAULT_DB_URL = "sqlite:///./data/drugs.db"
def _resolve_sqlite_path(db_url: str) -> Path | None:
if not db_url.startswith("sqlite:///"):
print(f"Unsupported database URL for compliance migration: {db_url}")
return None
raw_path = db_url.replace("sqlite:///", "")
if raw_path.startswith("/"):
return Path(raw_path)
return Path(raw_path)
def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [row[1] for row in cursor.fetchall()]
return column_name in columns
def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
return cursor.fetchone() is not None
def migrate_compliance_schema() -> None:
"""Apply additive schema changes needed for compliance features."""
db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
db_path = _resolve_sqlite_path(db_url)
if db_path is None:
return
if not db_path.exists():
print(f"Database does not exist at {db_path}, skipping compliance migration")
return
print(f"Running compliance migration on {db_path}")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
try:
if _table_exists(cursor, "drug_variants") and not _table_exists(cursor, "variant_packs"):
cursor.execute(
"""
CREATE TABLE variant_packs (
id INTEGER PRIMARY KEY,
drug_variant_id INTEGER NOT NULL,
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(drug_variant_id) REFERENCES drug_variants(id)
)
"""
)
cursor.execute("CREATE INDEX IF NOT EXISTS ix_variant_packs_drug_variant_id ON variant_packs(drug_variant_id)")
print("Created variant_packs table")
if _table_exists(cursor, "drugs") and not _column_exists(cursor, "drugs", "is_controlled"):
cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0")
print("Added drugs.is_controlled")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "batch_id"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN batch_id INTEGER")
print("Added dispensings.batch_id")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "actor_user_id"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER")
print("Added dispensings.actor_user_id")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_id"):
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_id INTEGER")
print("Added batches.received_pack_id")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_count"):
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_count FLOAT")
print("Added batches.received_pack_count")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "received_pack_size_snapshot"):
cursor.execute("ALTER TABLE batches ADD COLUMN received_pack_size_snapshot FLOAT")
print("Added batches.received_pack_size_snapshot")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_full_pack_count"):
cursor.execute("ALTER TABLE batches ADD COLUMN current_full_pack_count FLOAT")
print("Added batches.current_full_pack_count")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "current_loose_base_units"):
cursor.execute("ALTER TABLE batches ADD COLUMN current_loose_base_units FLOAT")
print("Added batches.current_loose_base_units")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_at"):
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_at DATETIME")
print("Added batches.disposed_at")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_by_user_id"):
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_by_user_id INTEGER")
print("Added batches.disposed_by_user_id")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposed_quantity"):
cursor.execute("ALTER TABLE batches ADD COLUMN disposed_quantity FLOAT")
print("Added batches.disposed_quantity")
if _table_exists(cursor, "batches") and not _column_exists(cursor, "batches", "disposal_notes"):
cursor.execute("ALTER TABLE batches ADD COLUMN disposal_notes VARCHAR")
print("Added batches.disposal_notes")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_id"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_id INTEGER")
print("Added dispensings.requested_pack_id")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "requested_pack_count"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN requested_pack_count FLOAT")
print("Added dispensings.requested_pack_count")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "dispense_mode"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN dispense_mode VARCHAR NOT NULL DEFAULT 'subunit'")
print("Added dispensings.dispense_mode")
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
cursor.execute(
"""
INSERT INTO variant_packs (drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active)
SELECT v.id,
COALESCE(NULLIF(v.unit, ''), 'unit'),
1,
1
FROM drug_variants v
WHERE NOT EXISTS (
SELECT 1
FROM variant_packs p
WHERE p.drug_variant_id = v.id
)
"""
)
print("Ensured default pack rows for variants")
if _table_exists(cursor, "batches") and _table_exists(cursor, "variant_packs"):
cursor.execute(
"""
UPDATE batches
SET received_pack_id = (
SELECT p.id
FROM variant_packs p
WHERE p.drug_variant_id = batches.drug_variant_id
ORDER BY p.id ASC
LIMIT 1
),
received_pack_count = quantity
WHERE received_pack_id IS NULL
"""
)
print("Backfilled batches pack context where missing")
cursor.execute(
"""
UPDATE batches
SET received_pack_size_snapshot = (
SELECT p.pack_size_in_base_units
FROM variant_packs p
WHERE p.id = batches.received_pack_id
LIMIT 1
)
WHERE received_pack_id IS NOT NULL
AND (received_pack_size_snapshot IS NULL OR received_pack_size_snapshot <= 0)
"""
)
print("Backfilled batches pack size snapshot where missing")
cursor.execute(
"""
UPDATE batches
SET current_full_pack_count = CASE
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN CAST(quantity / received_pack_size_snapshot AS INTEGER)
ELSE NULL
END,
current_loose_base_units = CASE
WHEN COALESCE(received_pack_size_snapshot, 0) > 0 THEN quantity - (CAST(quantity / received_pack_size_snapshot AS INTEGER) * received_pack_size_snapshot)
ELSE NULL
END
"""
)
print("Backfilled batches live pack state")
if _table_exists(cursor, "dispensings") and _table_exists(cursor, "variant_packs"):
cursor.execute(
"""
UPDATE dispensings
SET requested_pack_id = (
SELECT p.id
FROM variant_packs p
WHERE p.drug_variant_id = dispensings.drug_variant_id
ORDER BY p.id ASC
LIMIT 1
),
requested_pack_count = quantity
WHERE requested_pack_id IS NULL
"""
)
print("Backfilled dispensing pack context where missing")
cursor.execute(
"""
UPDATE dispensings
SET dispense_mode = CASE
WHEN requested_pack_id IS NOT NULL AND requested_pack_count IS NOT NULL THEN 'pack'
ELSE 'subunit'
END
WHERE dispense_mode IS NULL OR TRIM(dispense_mode) = ''
"""
)
print("Backfilled dispensing mode where missing")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "prescribing_vet"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN prescribing_vet VARCHAR")
print("Added dispensings.prescribing_vet")
# Seed default locations once table exists (created via SQLAlchemy create_all).
if _table_exists(cursor, "locations"):
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)")
cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Fridge', 1)")
print("Ensured default locations exist")
conn.commit()
print("Compliance migration completed")
except sqlite3.Error as exc:
conn.rollback()
raise RuntimeError(f"Compliance migration failed: {exc}") from exc
finally:
conn.close()
+103
View File
@@ -0,0 +1,103 @@
"""
GTIN mapping table migration.
Creates the gtin_mappings table if it does not already exist.
Idempotent and safe to run on every startup.
"""
import os
import sqlite3
from pathlib import Path
DEFAULT_DB_URL = "sqlite:///./data/drugs.db"
def _resolve_sqlite_path(db_url: str) -> Path | None:
if not db_url.startswith("sqlite:///"):
print(f"Unsupported database URL for GTIN migration: {db_url}")
return None
raw_path = db_url.replace("sqlite:///", "")
if raw_path.startswith("/"):
return Path(raw_path)
return Path(raw_path)
def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
return cursor.fetchone() is not None
def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
return any(row[1] == column_name for row in cursor.fetchall())
def migrate_gtin_schema() -> None:
"""Create gtin_mappings table if it does not exist, and drop label from variant_packs."""
db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
db_path = _resolve_sqlite_path(db_url)
if db_path is None:
return
if not db_path.exists():
print(f"Database does not exist at {db_path}, skipping GTIN migration")
return
print(f"Running GTIN migration on {db_path}")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
try:
if not _table_exists(cursor, "gtin_mappings"):
cursor.execute("""
CREATE TABLE gtin_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gtin VARCHAR(14) NOT NULL,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
variant_pack_id INTEGER NOT NULL REFERENCES variant_packs(id),
created_by_user_id INTEGER REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE UNIQUE INDEX ix_gtin_mappings_gtin ON gtin_mappings (gtin)")
cursor.execute("CREATE INDEX ix_gtin_mappings_drug_variant_id ON gtin_mappings (drug_variant_id)")
cursor.execute("CREATE INDEX ix_gtin_mappings_variant_pack_id ON gtin_mappings (variant_pack_id)")
print("Created gtin_mappings table")
else:
print("gtin_mappings table already exists, skipping")
# Drop label column from variant_packs if it still exists
if _table_exists(cursor, "variant_packs") and _column_exists(cursor, "variant_packs", "label"):
print("Dropping label column from variant_packs")
cursor.execute("""
CREATE TABLE variant_packs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
INSERT INTO variant_packs_new (id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at)
SELECT id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at
FROM variant_packs
""")
# Re-create indexes
cursor.execute("DROP INDEX IF EXISTS ix_variant_packs_drug_variant_id")
cursor.execute("DROP TABLE variant_packs")
cursor.execute("ALTER TABLE variant_packs_new RENAME TO variant_packs")
cursor.execute("CREATE INDEX ix_variant_packs_drug_variant_id ON variant_packs (drug_variant_id)")
print("Dropped label column from variant_packs")
else:
print("variant_packs.label already absent, skipping")
conn.commit()
finally:
conn.close()
+87 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean from sqlalchemy import Column, Integer, String, Float, DateTime, Date, ForeignKey, Boolean, Text
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .database import Base from .database import Base
@@ -18,6 +18,7 @@ class Drug(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False) name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
is_controlled = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
@@ -35,13 +36,98 @@ class DrugVariant(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class VariantPack(Base):
__tablename__ = "variant_packs"
id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
pack_unit_name = Column(String, nullable=False, default="pack")
pack_size_in_base_units = Column(Float, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class Dispensing(Base): class Dispensing(Base):
__tablename__ = "dispensings" __tablename__ = "dispensings"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False)
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
quantity = Column(Float, nullable=False) quantity = Column(Float, nullable=False)
dispense_mode = Column(String, nullable=False, default="subunit")
requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
requested_pack_count = Column(Float, nullable=True)
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional) animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
prescribing_vet = Column(String, nullable=True) # Prescribing vet's name (required for controlled drugs)
user_name = Column(String, nullable=False) # User who dispensed user_name = Column(String, nullable=False) # User who dispensed
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
notes = Column(String, nullable=True) notes = Column(String, nullable=True)
class Location(Base):
__tablename__ = "locations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class Batch(Base):
__tablename__ = "batches"
id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
batch_number = Column(String, nullable=False, index=True)
quantity = Column(Float, nullable=False, default=0)
received_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
received_pack_count = Column(Float, nullable=True)
received_pack_size_snapshot = Column(Float, nullable=True)
current_full_pack_count = Column(Float, nullable=True)
current_loose_base_units = Column(Float, nullable=True)
expiry_date = Column(Date, nullable=False, index=True)
location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True)
received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
notes = Column(String, nullable=True)
disposed_at = Column(DateTime(timezone=True), nullable=True, index=True)
disposed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
disposed_quantity = Column(Float, nullable=True)
disposal_notes = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class DispensingAllocation(Base):
__tablename__ = "dispensing_allocations"
id = Column(Integer, primary_key=True, index=True)
dispensing_id = Column(Integer, ForeignKey("dispensings.id"), nullable=False, index=True)
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=False, index=True)
quantity = Column(Float, nullable=False)
class GtinMapping(Base):
__tablename__ = "gtin_mappings"
id = Column(Integer, primary_key=True, index=True)
gtin = Column(String(14), unique=True, index=True, nullable=False)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
variant_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=False, index=True)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
action = Column(String, nullable=False, index=True)
entity_type = Column(String, nullable=False, index=True)
entity_id = Column(Integer, nullable=True, index=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
actor_username = Column(String, nullable=False, index=True)
details = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+6 -6
View File
@@ -1,8 +1,8 @@
fastapi==0.104.1 fastapi==0.137.2
uvicorn==0.24.0 uvicorn==0.49.0
sqlalchemy==2.0.23 sqlalchemy==2.0.51
pydantic==2.5.0 pydantic==2.13.4
python-multipart==0.0.6 python-multipart==0.0.32
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4 passlib[argon2]==1.7.4
paho-mqtt==1.6.1 paho-mqtt==1.6.1
+19 -5
View File
@@ -10,8 +10,8 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- DATABASE_URL=sqlite:///./data/drugs.db - DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto} - MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883} - MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_USERNAME=${MQTT_USERNAME:-}
@@ -19,9 +19,24 @@ services:
- MQTT_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print} - MQTT_LABEL_TOPIC=${MQTT_LABEL_TOPIC:-vet/labels/print}
- MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status} - MQTT_STATUS_TOPIC=${MQTT_STATUS_TOPIC:-vet/labels/status}
- LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label} - LABEL_TEMPLATE_ID=${LABEL_TEMPLATE_ID:-vet_label}
- NOTES_TEMPLATE_ID=${NOTES_TEMPLATE_ID:-notes_1}
- LABEL_SIZE=${LABEL_SIZE:-29x90} - LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false} - LABEL_TEST=${LABEL_TEST:-false}
backup:
image: python:3.11-slim
user: "${PUID:-1000}:${PGID:-1000}"
volumes:
- ./data:/data
- ./scripts/backup_sqlite.py:/usr/local/bin/backup_sqlite.py:ro
environment:
- SQLITE_DB_PATH=/data/drugs.db
- BACKUP_DIR=/data/backups
- BACKUP_INTERVAL_SECONDS=${BACKUP_INTERVAL_SECONDS:-3600}
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-7}
command: ["python", "/usr/local/bin/backup_sqlite.py"]
restart: unless-stopped
mosquitto: mosquitto:
image: eclipse-mosquitto:latest image: eclipse-mosquitto:latest
volumes: volumes:
@@ -30,12 +45,11 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log - mosquitto_logs:/mosquitto/log
environment: environment:
- PUID=1001 - PUID=${PUID:-1000}
- PGID=1001 - PGID=${PGID:-1000}
frontend: frontend:
image: nginx:alpine image: nginx:alpine
container_name: drugsprod
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
+4085 -188
View File
File diff suppressed because it is too large Load Diff
+405 -89
View File
@@ -13,7 +13,7 @@
<!-- Login Page --> <!-- Login Page -->
<div id="loginPage" class="login-page"> <div id="loginPage" class="login-page">
<div class="login-container"> <div class="login-container">
<h1>🐶 MTAR Drug Inventory System 🐶</h1> <h1>MTAR Drug Inventory System</h1>
<form id="loginForm" class="login-form"> <form id="loginForm" class="login-form">
<h2>Login</h2> <h2>Login</h2>
<div class="form-group"> <div class="form-group">
@@ -36,13 +36,15 @@
<div class="container"> <div class="container">
<header> <header>
<div class="header-top"> <div class="header-top">
<h1>🐶 MTAR Drug Inventory System 🐶</h1> <h1>MTAR Drug Inventory System</h1>
<div class="user-menu"> <div class="user-menu">
<span id="currentUser">User</span> <span id="currentUser">User</span>
<button id="userMenuBtn" class="btn btn-small"></button> <button id="userMenuBtn" class="btn btn-small"></button>
<div id="userDropdown" class="user-dropdown" style="display: none;"> <div id="userDropdown" class="user-dropdown" style="display: none;">
<button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button> <button id="changePasswordBtn" class="dropdown-item">🔑 Change Password</button>
<button id="adminBtn" class="dropdown-item" style="display: none;">👤 Admin</button> <button id="adminBtn" class="dropdown-item" style="display: none;">👤 Admin</button>
<button id="locationsBtn" class="dropdown-item" style="display: none;">📍 Storage Locations</button>
<button id="reportsBtn" class="dropdown-item" style="display: none;">📊 Reports</button>
<button id="logoutBtn" class="dropdown-item">🚪 Logout</button> <button id="logoutBtn" class="dropdown-item">🚪 Logout</button>
</div> </div>
</div> </div>
@@ -54,19 +56,23 @@
<section id="listSection" class="list-section"> <section id="listSection" class="list-section">
<div class="section-header"> <div class="section-header">
<h2>Current Inventory</h2> <h2>Current Inventory</h2>
<div class="inventory-toolbar">
<div class="header-actions"> <div class="header-actions">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button> <button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
<button id="receiveDeliveryBtn" class="btn btn-success btn-small">📦 Receive Delivery</button>
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button> <button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button>
</div> </div>
<div class="toolbar-search">
<input type="text" id="drugSearch" placeholder="Search drugs by name..." class="search-input">
</div>
<div class="filters"> <div class="filters">
<button id="showAllBtn" class="filter-btn active">All</button> <button id="showAllBtn" class="filter-btn active">All</button>
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button> <button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
<select id="locationFilterSelect" class="filter-select" aria-label="Filter by location">
<option value="">All Locations</option>
</select>
</div> </div>
</div> </div>
<!-- Search Section -->
<div class="search-section">
<input type="text" id="drugSearch" placeholder="🔍 Search drugs by name..." class="search-input">
</div> </div>
<div id="drugsList" class="drugs-list"> <div id="drugsList" class="drugs-list">
@@ -97,7 +103,12 @@
<input type="text" id="editDrugDescription"> <input type="text" id="editDrugDescription">
</div> </div>
<div class="form-actions"> <div class="form-group">
<label>
<input type="checkbox" id="editDrugIsControlled">
This is a Controlled Substance
</label>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
</div> </div>
@@ -121,34 +132,11 @@
<input type="text" id="drugDescription"> <input type="text" id="drugDescription">
</div> </div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
<div class="form-group"> <div class="form-group">
<label for="initialVariantStrength">Strength</label> <label>
<input type="text" id="initialVariantStrength" placeholder="e.g., 10mg, 5.4mg"> <input type="checkbox" id="drugIsControlled">
</div> This is a Controlled Substance
</label>
<div class="form-group">
<label for="initialVariantQuantity">Quantity</label>
<input type="number" id="initialVariantQuantity" placeholder="0" min="0" step="0.1">
</div>
<div class="form-group">
<label for="initialVariantUnit">Unit</label>
<select id="initialVariantUnit">
<option value="tablets">Tablets</option>
<option value="bottles">Bottles</option>
<option value="boxes">Boxes</option>
<option value="vials">Vials</option>
<option value="units">Units</option>
<option value="packets">Packets</option>
</select>
</div>
<div class="form-group">
<label for="initialVariantThreshold">Low Stock Threshold</label>
<input type="number" id="initialVariantThreshold" placeholder="0" min="0" step="0.1" value="10">
</div> </div>
<div class="form-actions"> <div class="form-actions">
@@ -161,20 +149,78 @@
<!-- Dispense Drug Modal --> <!-- Dispense Drug Modal -->
<div id="dispenseModal" class="modal"> <div id="dispenseModal" class="modal">
<div class="modal-content"> <div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span> <span class="close">&times;</span>
<h2>Dispense Drug</h2> <h2>Dispense Drug</h2>
<form id="dispenseForm" novalidate> <form id="dispenseForm" novalidate>
<div class="form-group"> <div class="form-group">
<label for="dispenseDrugSelect">Drug Variant *</label> <label for="dispenseDrugSelect">Drug Variant *</label>
<select id="dispenseDrugSelect"> <select id="dispenseDrugSelect" onchange="updateBatchInfo()">
<option value="">-- Select a drug variant --</option> <option value="">-- Select a drug variant --</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Dispense Mode *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseMode" id="dispenseModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseMode" id="dispenseModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label> <label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1"> <input type="number" id="dispenseQuantity" step="1.0">
</div>
<div class="form-row" id="dispensePackRow" style="display: none;">
<div class="form-group">
<label for="dispensePackSelect">Pack Type *</label>
<select id="dispensePackSelect" onchange="updateDispenseQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="dispensePackCount">Pack Count *</label>
<input type="number" id="dispensePackCount" min="1" step="1" onchange="updateDispenseQuantityFromPack()">
<small id="dispensePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group" id="dispenseSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseSource" id="dispenseSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseSource" id="dispenseSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="dispenseSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested dispense amount.</p>
<details id="expiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="expiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="batchAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="batchAllocationSummaryContent"></div>
</div>
<div id="batchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -183,8 +229,26 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="dispenseUser">Dispensed by *</label> <label for="dispenseVet" id="dispenseVetLabel">Prescribing Vet</label>
<input type="text" id="dispenseUser"> <input type="text" id="dispenseVet" placeholder="Vet's name">
</div>
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
<input type="checkbox" id="dispensePrintEnabled">
Print label after dispensing
</label>
<div id="dispensePrintFields" style="display: none; margin-top: 12px;">
<p id="dispensePrintHelpText" style="margin: 0 0 12px; color: #666;">Uses the dispensed quantity, the animal name/ID entered above, the logged-in user, and the latest expiry date from the allocated batches.</p>
<div class="form-group">
<label for="dispenseDosage">Dosage Instructions *</label>
<input type="text" id="dispenseDosage" placeholder="e.g., 1 tablet twice daily with food">
</div>
<div class="form-group" id="dispenseLegacyExpiryGroup" style="display: none;">
<label for="dispenseLegacyExpiry">Expiry Date *</label>
<input type="date" id="dispenseLegacyExpiry">
</div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -200,50 +264,90 @@
</div> </div>
</div> </div>
<!-- Prescribe Drug Modal --> <!-- Dispose Inventory Modal -->
<div id="prescribeModal" class="modal"> <div id="disposeInventoryModal" class="modal">
<div class="modal-content"> <div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span> <span class="close">&times;</span>
<h2>Prescribe Drug & Print Label</h2> <h2>Dispose Inventory</h2>
<form id="prescribeForm" novalidate> <form id="disposeInventoryForm" 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"> <div class="form-group">
<label for="prescribeQuantity">Quantity *</label> <label for="disposeDrugSelect">Drug Variant *</label>
<input type="number" id="prescribeQuantity" step="0.1" required> <select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
<option value="">-- Select a drug variant --</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="prescribeAnimal">Animal Name/ID *</label> <label>Disposal Mode *</label>
<input type="text" id="prescribeAnimal" required> <div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group" id="disposeQuantityGroup">
<label for="disposeQuantity">Quantity *</label>
<input type="number" id="disposeQuantity" step="1.0">
</div>
<div class="form-row" id="disposePackRow" style="display: none;">
<div class="form-group">
<label for="disposePackSelect">Pack Type *</label>
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="disposePackCount">Pack Count *</label>
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group" id="disposeSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="disposeAllocationSummaryContent"></div>
</div>
<div id="disposeBatchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="prescribeDosage">Dosage Instructions *</label> <label for="disposeNotes">Disposal Note</label>
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required> <textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</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>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button> <button type="submit" class="btn btn-danger">Dispose</button>
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">Cancel</button> <button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
</div> </div>
</form> </form>
</div> </div>
@@ -258,26 +362,24 @@
<input type="hidden" id="variantDrugId"> <input type="hidden" id="variantDrugId">
<div class="form-group"> <div class="form-group">
<label for="variantStrength">Strength *</label> <label for="variantStrength">Strength *</label>
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required> <input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 0.5mg/ml" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="variantQuantity">Quantity *</label>
<input type="number" id="variantQuantity" step="0.1" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="variantUnit">Unit *</label> <label for="variantUnit">Base Unit *</label>
<select id="variantUnit"> <select id="variantUnit">
<option value="tablets">Tablets</option> <option value="ml">ml</option>
<option value="bottles">Bottles</option> <option value="tablets" selected>tablets</option>
<option value="boxes">boxes</option> <option value="capsules">capsules</option>
<option value="vials">Vials</option> <option value="units">units</option>
<option value="units">Units</option> <option value="vials">vials</option>
<option value="packets">Packets</option>
</select> </select>
</div> </div>
<div class="form-group">
<label>Pack Sizes *</label>
<div id="variantPackRows" class="delivery-lines"></div>
<button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -300,6 +402,9 @@
<h2>Edit Variant</h2> <h2>Edit Variant</h2>
<form id="editVariantForm"> <form id="editVariantForm">
<input type="hidden" id="editVariantId"> <input type="hidden" id="editVariantId">
<p id="editVariantLockNotice" style="display:none; margin: 0 0 12px; padding: 8px 10px; background: #fff8e1; border: 1px solid #f5c15d; border-radius: 6px; color: #7a4f01;">
Strength, quantity, and base unit are locked once this variant has stock/batch history.
</p>
<div class="form-group"> <div class="form-group">
<label for="editVariantStrength">Strength *</label> <label for="editVariantStrength">Strength *</label>
<input type="text" id="editVariantStrength" required> <input type="text" id="editVariantStrength" required>
@@ -312,7 +417,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="editVariantUnit">Unit *</label> <label for="editVariantUnit">Base Unit *</label>
<select id="editVariantUnit"> <select id="editVariantUnit">
<option value="tablets">Tablets</option> <option value="tablets">Tablets</option>
<option value="bottles">Bottles</option> <option value="bottles">Bottles</option>
@@ -324,6 +429,12 @@
</div> </div>
</div> </div>
<div class="form-group">
<label>Add Pack Sizes</label>
<div id="editVariantPackRows" class="delivery-lines"></div>
<button type="button" id="addEditVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
</div>
<div class="form-group"> <div class="form-group">
<label for="editVariantThreshold">Low Stock Threshold *</label> <label for="editVariantThreshold">Low Stock Threshold *</label>
<input type="number" id="editVariantThreshold" step="0.1" required> <input type="number" id="editVariantThreshold" step="0.1" required>
@@ -410,7 +521,7 @@
<span class="close">&times;</span> <span class="close">&times;</span>
<h2>User Management</h2> <h2>User Management</h2>
<div class="user-management-content"> <div class="user-management-content">
<div class="form-group"> <section class="user-create-panel">
<h3>Create New User</h3> <h3>Create New User</h3>
<form id="createUserForm"> <form id="createUserForm">
<div class="form-row"> <div class="form-row">
@@ -425,7 +536,7 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-small">Create User</button> <button type="submit" class="btn btn-primary btn-small">Create User</button>
</form> </form>
</div> </section>
<div id="usersList" class="users-list"> <div id="usersList" class="users-list">
<h3>Users</h3> <h3>Users</h3>
<p class="loading">Loading users...</p> <p class="loading">Loading users...</p>
@@ -437,6 +548,32 @@
</div> </div>
</div> </div>
<!-- Location Management Modal -->
<div id="locationManagementModal" class="modal">
<div class="modal-content modal-large">
<span class="close">&times;</span>
<h2>Storage Locations</h2>
<div class="location-management-content">
<div class="form-group">
<h3>Add New Location</h3>
<form id="createLocationForm">
<div class="form-row">
<input type="text" id="newLocationName" placeholder="Location name (e.g., Fridge, Cupboard)" required>
<button type="submit" class="btn btn-primary btn-small">Add Location</button>
</div>
</form>
</div>
<div id="locationsList" class="locations-list">
<h3>Active Locations</h3>
<p class="loading">Loading locations...</p>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="closeLocationManagementBtn">Close</button>
</div>
</div>
</div>
<!-- Print Notes Modal --> <!-- Print Notes Modal -->
<div id="printNotesModal" class="modal"> <div id="printNotesModal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -460,6 +597,185 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Dispose Batch Modal -->
<div id="disposeBatchModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Dispose Expired Batch</h2>
<form id="disposeBatchForm" novalidate>
<input type="hidden" id="disposeBatchId">
<div class="form-group">
<label for="disposeBatchName">Batch</label>
<input type="text" id="disposeBatchName" disabled>
</div>
<div class="form-group">
<label for="disposeBatchStockSummary">Stock to Dispose</label>
<input type="text" id="disposeBatchStockSummary" disabled>
</div>
<div class="form-group">
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
</div>
<div class="form-group">
<label for="disposeBatchNotes">Disposal Note</label>
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Batch Receive Modal -->
<div id="batchReceiveModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Receive New Batch</h2>
<form id="batchReceiveForm" novalidate>
<input type="hidden" id="batchVariantId">
<div class="form-group">
<label for="batchNumber">Batch Number *</label>
<input type="text" id="batchNumber" placeholder="e.g., LOT-2026-001" required>
</div>
<div class="form-group">
<label for="batchQuantity">Quantity *</label>
<input type="number" id="batchQuantity" step="0.1" required>
</div>
<div class="form-group">
<label for="batchExpiryDate">Expiry Date *</label>
<input type="date" id="batchExpiryDate" required>
</div>
<div class="form-group">
<label for="batchLocation">Storage Location *</label>
<select id="batchLocation" required>
<option value="">-- Select location --</option>
</select>
</div>
<div class="form-group">
<label for="batchNotes">Notes</label>
<input type="text" id="batchNotes" placeholder="Optional notes about this batch">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Batch</button>
<button type="button" class="btn btn-secondary" id="cancelBatchReceiveBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Add Pack Size Modal -->
<div id="addPackSizeModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Add Pack Size</h2>
<p id="addPackSizeDrugLabel" style="margin: 4px 0 14px; color: #666; font-weight: 600;"></p>
<form id="addPackSizeForm" novalidate>
<div class="form-group">
<label for="addPackSizeVariantSelect">Variant *</label>
<select id="addPackSizeVariantSelect" required>
<option value="">-- Select variant --</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeType">Pack Type *</label>
<select id="addPackSizeType" required>
<option value="box" selected>Box</option>
<option value="bottle">Bottle</option>
<option value="vial">Vial</option>
<option value="packet">Packet</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeCount">Pack Size (units) *</label>
<input type="number" id="addPackSizeCount" min="0.0001" step="0.0001" placeholder="e.g., 28" required>
<small id="addPackSizeHint" style="display: block; margin-top: 4px; color: #666;"></small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Pack Size</button>
<button type="button" class="btn btn-secondary" id="cancelAddPackSizeBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Receive Delivery Modal -->
<div id="receiveDeliveryModal" class="modal">
<div class="modal-content modal-large receive-delivery-modal-content">
<span class="close">&times;</span>
<h2>Receive Delivery</h2>
<p id="receiveDeliveryDrugLabel" style="margin: 6px 0 16px; color: #666; font-weight: 600;"></p>
<form id="receiveDeliveryForm" novalidate>
<div id="deliveryLinesContainer" class="delivery-lines"></div>
<div class="delivery-toolbar">
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
<button type="button" id="addPackSizeFromDeliveryBtn" class="btn btn-info">+ Add Pack Size</button>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Delivery</button>
<button type="button" class="btn btn-secondary" id="cancelReceiveDeliveryBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- GTIN Mapping Modal -->
<div id="gtinMappingModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Unknown Barcode — Map GTIN</h2>
<p style="color:#666; margin-bottom:16px;">This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.</p>
<div class="form-group">
<label>GTIN</label>
<input type="text" id="gtinMappingGtin" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>Drug</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingDrugSelect" onchange="onGtinMappingDrugChange()" style="flex:1">
<option value="">-- Select drug --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddDrug()">+ New Drug</button>
</div>
</div>
<div class="form-group">
<label>Variant</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingVariantSelect" onchange="onGtinMappingVariantChange()" style="flex:1">
<option value="">-- Select variant --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddVariant()">+ New Variant</button>
</div>
</div>
<div class="form-group">
<label>Pack</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingPackSelect" style="flex:1">
<option value="">-- Select pack --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddPack()">+ New Pack</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="handleSaveGtinMapping()">Save Mapping</button>
<button type="button" class="btn btn-secondary" id="cancelGtinMappingBtn">Cancel</button>
</div>
</div>
</div>
</div> </div>
<script src="app.js"></script> <script src="app.js"></script>
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reports - Drug Inventory System</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="toastContainer" class="toast-container"></div>
<div id="reportsApp" class="main-app" style="display: none;">
<div class="container">
<header>
<div class="header-top">
<h1>Audit Reports</h1>
<div class="user-menu reports-user-menu">
<span id="reportsCurrentUser">User</span>
<button id="backToInventoryBtn" class="btn btn-small">Back To Inventory</button>
<button id="reportsLogoutBtn" class="btn btn-small">Logout</button>
</div>
</div>
</header>
<main>
<section class="list-section reports-page-section">
<div class="section-header">
<h2 id="reportsHeading">Dispensing History</h2>
<div class="reports-controls reports-page-controls">
<div class="form-group report-control">
<label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option>
<option value="global_inventory">Stock Check</option>
<option value="batch_attention">Expired Batches</option>
<option value="audit">Audit Trail (Raw)</option>
</select>
</div>
<div class="form-group report-control">
<label for="reportFromDate">From Date</label>
<input type="date" id="reportFromDate">
</div>
<div class="form-group report-control">
<label for="reportToDate">To Date</label>
<input type="date" id="reportToDate">
</div>
<div class="form-group report-control">
<label for="reportUserFilter">User</label>
<select id="reportUserFilter">
<option value="">All Users</option>
</select>
</div>
<div class="form-group report-control">
<label for="reportDrugFilter">Drug</label>
<select id="reportDrugFilter">
<option value="">All Drugs</option>
</select>
</div>
<div class="form-group report-control report-text-search">
<label for="reportActionSearch">Search</label>
<input type="text" id="reportActionSearch" placeholder="Search user, action, notes, details...">
</div>
<div class="report-actions">
<button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button>
<button id="clearReportFiltersBtn" type="button" class="btn btn-secondary btn-small">Clear</button>
<button id="refreshReportsBtn" type="button" class="btn btn-secondary btn-small">Refresh</button>
<button id="stockCheckPdfBtn" type="button" class="btn btn-secondary btn-small" style="display: none;">Print Stock Check</button>
</div>
</div>
</div>
<div id="reportsSummary" class="reports-summary"></div>
<div id="reportsTableContainer" class="reports-table-container">
<p class="loading" style="padding: 14px;">Loading audit trail...</p>
</div>
</section>
</main>
<footer>
<p>Many Tears Confidential</p>
</footer>
</div>
</div>
<div id="reportsErrorState" class="login-page" style="display: none;">
<div class="login-container">
<h1>Audit Reports</h1>
<p id="reportsErrorMessage">Access denied.</p>
<button id="goToLoginBtn" class="btn btn-primary">Go To Login</button>
</div>
</div>
<div id="disposeBatchModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Dispose Expired Batch</h2>
<form id="disposeBatchForm" novalidate>
<input type="hidden" id="disposeBatchId">
<div class="form-group">
<label for="disposeBatchName">Batch</label>
<input type="text" id="disposeBatchName" disabled>
</div>
<div class="form-group">
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
</div>
<div class="form-group">
<label for="disposeBatchNotes">Disposal Note</label>
<textarea id="disposeBatchNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger">Confirm Disposal</button>
<button type="button" class="btn btn-secondary" id="cancelDisposeBatchBtn">Cancel</button>
</div>
</form>
</div>
</div>
<script src="reports.js"></script>
</body>
</html>
+1115
View File
File diff suppressed because it is too large Load Diff
+456 -27
View File
@@ -136,9 +136,9 @@ body {
header { header {
background: var(--primary-color); background: var(--primary-color);
color: var(--white); color: var(--white);
padding: 40px 20px; padding: 12px 20px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 30px; margin-bottom: 22px;
text-align: center; text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
@@ -152,7 +152,8 @@ header {
.header-top h1 { .header-top h1 {
flex: 1; flex: 1;
font-size: 2.5em; font-size: 3em;
line-height: 1;
} }
.user-menu { .user-menu {
@@ -214,8 +215,8 @@ header {
} }
header h1 { header h1 {
font-size: 2.5em; font-size: 3em;
margin-bottom: 10px; margin-bottom: 0;
} }
} }
@@ -368,16 +369,34 @@ textarea:focus {
margin-bottom: 15px; margin-bottom: 15px;
} }
.inventory-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: nowrap;
}
.header-actions { .header-actions {
margin-bottom: 15px;
display: flex; display: flex;
gap: 10px; gap: 10px;
width: fit-content; width: fit-content;
flex-shrink: 0;
}
.toolbar-search {
flex: 1;
min-width: 260px;
max-width: 520px;
} }
.filters { .filters {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center;
margin-left: auto;
flex-wrap: nowrap;
flex-shrink: 0;
} }
.filter-btn { .filter-btn {
@@ -388,6 +407,17 @@ textarea:focus {
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
color: var(--text-dark); color: var(--text-dark);
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
min-height: 38px;
}
#showLowStockBtn {
padding-left: 14px;
padding-right: 14px;
} }
.filter-btn:hover { .filter-btn:hover {
@@ -401,6 +431,22 @@ textarea:focus {
border-color: var(--secondary-color); border-color: var(--secondary-color);
} }
.filter-select {
min-width: 180px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--white);
color: var(--text-dark);
font-size: 0.95em;
}
.filter-select:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.15);
}
.drugs-list { .drugs-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -571,10 +617,10 @@ footer {
.search-input { .search-input {
width: 100%; width: 100%;
max-width: none; max-width: none;
padding: 12px 16px; padding: 10px 14px;
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
font-size: 1em; font-size: 0.95em;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
@@ -627,6 +673,53 @@ footer {
overflow-y: auto; overflow-y: auto;
} }
#receiveDeliveryModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
}
#receiveDeliveryModal .modal-content {
width: min(1280px, 96vw) !important;
max-width: min(1280px, 96vw) !important;
max-height: calc(100vh - 48px) !important;
}
#dispenseModal.show,
#disposeInventoryModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
}
.dispense-modal-content {
max-width: 780px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
#dispenseForm,
#disposeInventoryForm {
display: block;
padding-right: 6px;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 220px;
overflow-y: auto;
min-height: fit-content;
}
#dispenseModal .form-actions,
#disposeInventoryModal .form-actions {
margin-top: 16px;
padding-top: 14px;
background: var(--white);
border-top: 1px solid var(--border-color);
}
.close { .close {
color: var(--text-light); color: var(--text-light);
float: right; float: right;
@@ -706,6 +799,197 @@ footer {
color: var(--text-dark); color: var(--text-dark);
} }
.reports-modal-content {
max-width: 1000px;
}
.reports-controls {
display: flex;
align-items: end;
gap: 12px;
margin: 16px 0 10px;
flex-wrap: wrap;
}
.report-control {
flex: 1;
min-width: 220px;
}
.report-control label {
display: block;
font-weight: 600;
margin-bottom: 6px;
}
.report-control select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.report-control input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.report-control input:focus,
.report-control select:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.12);
}
.report-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.reports-summary {
font-size: 0.95em;
color: var(--text-light);
margin-bottom: 10px;
}
.reports-table-container {
border: 1px solid var(--border-color);
border-radius: 8px;
max-height: 52vh;
overflow: auto;
background: var(--white);
}
.reports-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92em;
}
.reports-table th,
.reports-table td {
border-bottom: 1px solid #e9ecef;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.reports-table th {
position: sticky;
top: 0;
background: #f8fafc;
z-index: 1;
font-weight: 700;
}
.reports-table code {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85em;
color: #1f2937;
}
.delivery-lines {
display: flex;
flex-direction: column;
gap: 12px;
margin: 8px 0 16px;
}
.delivery-line {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 14px;
background: #f9fbfd;
overflow-x: hidden;
}
.receive-delivery-modal-content {
width: min(1320px, 98vw);
max-width: 1320px;
max-height: 88vh;
overflow-y: auto;
}
.delivery-line-grid {
display: grid;
grid-template-columns: 1.9fr 1.8fr 1.5fr 0.8fr 1.2fr 1.3fr auto;
gap: 12px;
align-items: end;
}
.delivery-line-grid > * {
min-width: 0;
}
.delivery-line-grid .form-group {
margin-bottom: 0;
}
.delivery-line-grid label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 0.88em;
}
.delivery-line-grid input,
.delivery-line-grid select {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
}
.delivery-toolbar {
display: flex;
gap: 10px;
margin: 6px 0 10px;
flex-wrap: wrap;
}
.delivery-remove-btn {
align-self: end;
white-space: nowrap;
}
@media (max-width: 1200px) {
.receive-delivery-modal-content {
width: min(1120px, 97vw);
max-width: 1120px;
}
.delivery-line-grid {
grid-template-columns: 1.7fr 1.4fr 0.95fr 1.2fr 1.1fr 1.15fr;
}
.delivery-remove-btn {
grid-column: 1 / -1;
justify-self: end;
}
}
@media (max-width: 980px) {
#receiveDeliveryModal .modal-content {
width: 94vw !important;
max-width: 94vw !important;
}
.delivery-line-grid {
grid-template-columns: 1fr 1fr;
}
.delivery-remove-btn {
grid-column: 1 / -1;
justify-self: end;
}
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
main { main {
@@ -722,6 +1006,26 @@ footer {
gap: 15px; gap: 15px;
} }
.inventory-toolbar {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.toolbar-search {
min-width: 0;
max-width: none;
width: 100%;
}
.header-actions,
.filters {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.filters { .filters {
width: 100%; width: 100%;
} }
@@ -730,6 +1034,28 @@ footer {
flex: 1; flex: 1;
} }
.filter-select {
min-width: 160px;
flex: 1;
}
.reports-controls {
flex-direction: column;
align-items: stretch;
}
.report-control {
min-width: 0;
}
.delivery-line-grid {
grid-template-columns: 1fr;
}
.delivery-toolbar {
flex-direction: column;
}
.drug-details { .drug-details {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -741,6 +1067,22 @@ footer {
.modal-content { .modal-content {
width: 95%; width: 95%;
} }
.dispense-modal-content {
max-width: 95%;
max-height: calc(100vh - 24px);
}
#dispenseModal.show,
#disposeInventoryModal.show {
padding: 12px 0;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 160px;
}
} }
/* Variants Section */ /* Variants Section */
@@ -859,22 +1201,29 @@ footer {
} }
.user-management-content h3 { .user-management-content h3 {
margin-top: 20px; margin-top: 0;
margin-bottom: 15px; margin-bottom: 15px;
color: var(--primary-color); color: var(--primary-color);
} }
.user-create-panel {
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #f8fafc;
}
.user-management-content .form-row { .user-management-content .form-row {
display: flex; display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
gap: 10px; gap: 10px;
margin-bottom: 15px; margin-bottom: 15px;
flex-wrap: wrap;
} }
.user-management-content input, .user-management-content input,
.user-management-content select { .user-management-content select {
flex: 1; width: 100%;
min-width: 150px; min-width: 0;
padding: 10px; padding: 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
@@ -886,39 +1235,109 @@ footer {
} }
.users-list { .users-list {
margin-top: 20px; margin-top: 24px;
} }
.users-table { .users-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
} }
.user-item { .user-item {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
align-items: center; align-items: center;
padding: 12px; padding: 12px 14px;
background: #f8f9fa; background: var(--white);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 8px;
gap: 15px; gap: 12px;
}
.user-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-identity strong {
overflow-wrap: anywhere;
}
.current-user-label,
.self-note {
color: #64748b;
font-size: 0.85em;
}
.current-user-label {
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
} }
.admin-badge { .admin-badge {
padding: 4px 12px; justify-self: start;
background: var(--warning-color); padding: 5px 10px;
color: var(--white); color: var(--white);
border-radius: 20px; border-radius: 999px;
font-size: 0.9em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
white-space: nowrap;
}
.admin-badge.role-admin {
background: var(--warning-color);
}
.admin-badge.role-user {
background: var(--secondary-color);
}
.admin-badge.role-readonly {
background: #64748b;
}
.role-control {
display: flex;
align-items: center;
gap: 8px;
}
.role-control span {
color: #64748b;
font-size: 0.85em;
}
.role-control select {
margin: 0;
}
.role-control select:disabled {
color: #64748b;
background: #f1f5f9;
cursor: not-allowed;
}
.user-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-actions .btn {
margin-bottom: 0;
} }
/* Responsive Styles */ /* Responsive Styles */
@media (max-width: 768px) { @media (max-width: 768px) {
header { header {
padding: 20px 10px; padding: 10px 10px;
} }
.header-top { .header-top {
@@ -929,7 +1348,8 @@ footer {
.header-top h1 { .header-top h1 {
flex: none; flex: none;
font-size: 1.8em; font-size: 2.4em;
line-height: 1;
} }
.user-menu { .user-menu {
@@ -941,6 +1361,15 @@ footer {
max-width: none; max-width: none;
} }
.user-management-content .form-row,
.user-item {
grid-template-columns: 1fr;
}
.user-actions {
justify-content: flex-start;
}
.drug-item { .drug-item {
flex-direction: column; flex-direction: column;
} }
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import gzip
import os
import shutil
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/drugs.db"))
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
INTERVAL_SECONDS = int(os.getenv("BACKUP_INTERVAL_SECONDS", "3600"))
RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "7"))
LATEST_BACKUP = BACKUP_DIR / "latest.db.gz"
def utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def log(message: str) -> None:
print(f"[{utc_stamp()}] {message}", flush=True)
def run_integrity_check(db_path: Path) -> None:
with sqlite3.connect(str(db_path)) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()
if not result or result[0] != "ok":
detail = result[0] if result else "no result"
raise RuntimeError(f"SQLite integrity check failed: {detail}")
def create_backup() -> None:
if not DB_PATH.exists():
log(f"database not found at {DB_PATH}; skipping backup")
return
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = utc_stamp()
temp_db = BACKUP_DIR / f".{stamp}.db.tmp"
temp_gz = BACKUP_DIR / f".{stamp}.db.gz.tmp"
final_gz = BACKUP_DIR / f"drugs-{stamp}.db.gz"
for path in (temp_db, temp_gz):
if path.exists():
path.unlink()
source_uri = f"file:{DB_PATH}?mode=ro"
with sqlite3.connect(source_uri, uri=True) as source:
with sqlite3.connect(str(temp_db)) as target:
source.backup(target)
run_integrity_check(temp_db)
with temp_db.open("rb") as raw, gzip.open(temp_gz, "wb", compresslevel=6) as compressed:
shutil.copyfileobj(raw, compressed)
temp_gz.replace(final_gz)
shutil.copy2(final_gz, LATEST_BACKUP)
temp_db.unlink(missing_ok=True)
size_kb = final_gz.stat().st_size / 1024
log(f"created {final_gz.name} ({size_kb:.1f} KiB)")
def prune_old_backups() -> None:
if RETENTION_DAYS <= 0 or not BACKUP_DIR.exists():
return
cutoff = time.time() - (RETENTION_DAYS * 24 * 60 * 60)
deleted = 0
for backup in BACKUP_DIR.glob("drugs-*.db.gz"):
if backup.stat().st_mtime < cutoff:
backup.unlink()
deleted += 1
if deleted:
log(f"deleted {deleted} backup(s) older than {RETENTION_DAYS} day(s)")
def main() -> None:
log(
"starting SQLite backup loop: "
f"db={DB_PATH}, dir={BACKUP_DIR}, interval={INTERVAL_SECONDS}s, "
f"retention={RETENTION_DAYS}d"
)
while True:
try:
create_backup()
prune_old_backups()
except Exception as exc:
log(f"backup failed: {exc}")
time.sleep(INTERVAL_SECONDS)
if __name__ == "__main__":
main()