Compare commits
23 Commits
1c9fbbda6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0026ac2274 | |||
| b093e1a90c | |||
| 25c3f1fa64 | |||
| 3f230bb0d7 | |||
| 0fea301af1 | |||
| 562494a967 | |||
| 05a093afd3 | |||
| 9ec27e245a | |||
| 36634dc2bf | |||
| 6be571a48c | |||
| cfb08bd288 | |||
| 2aeba2f563 | |||
| d4753c0754 | |||
| 225202aacb | |||
| 4673de4ae5 | |||
| cdbf613e4b | |||
| 36f0a5b07e | |||
| b958ca493b | |||
| 5b5e17ec3e | |||
| 664a3189bd | |||
| ad1bb59f98 | |||
| e00669ae2c | |||
| 0521b8dfd6 |
@@ -20,3 +20,4 @@ node_modules/
|
|||||||
.cache/
|
.cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
drugs.db
|
drugs.db
|
||||||
|
data/backups/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+2272
-48
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+4101
-204
File diff suppressed because it is too large
Load Diff
+422
-106
@@ -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,20 +56,24 @@
|
|||||||
<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="header-actions">
|
<div class="inventory-toolbar">
|
||||||
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
|
<div class="header-actions">
|
||||||
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</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>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-search">
|
||||||
|
<input type="text" id="drugSearch" placeholder="Search drugs by name..." class="search-input">
|
||||||
|
</div>
|
||||||
|
<div class="filters">
|
||||||
|
<button id="showAllBtn" class="filter-btn active">All</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 class="filters">
|
|
||||||
<button id="showAllBtn" class="filter-btn active">All</button>
|
|
||||||
<button id="showLowStockBtn" class="filter-btn">Low Stock Only</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Section -->
|
|
||||||
<div class="search-section">
|
|
||||||
<input type="text" id="drugSearch" placeholder="🔍 Search drugs by name..." class="search-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="drugsList" class="drugs-list">
|
<div id="drugsList" class="drugs-list">
|
||||||
<p class="loading">Loading drugs...</p>
|
<p class="loading">Loading drugs...</p>
|
||||||
@@ -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,30 +149,106 @@
|
|||||||
|
|
||||||
<!-- 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">×</span>
|
<span class="close">×</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">
|
||||||
<label for="dispenseAnimal">Animal Name/ID</label>
|
<label for="dispenseAnimal">Animal Name/ID</label>
|
||||||
<input type="text" id="dispenseAnimal">
|
<input type="text" id="dispenseAnimal">
|
||||||
</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">×</span>
|
<span class="close">×</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>
|
||||||
|
|
||||||
|
<div class="form-group" id="disposeQuantityGroup">
|
||||||
|
<label for="disposeQuantity">Quantity *</label>
|
||||||
|
<input type="number" id="disposeQuantity" step="1.0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" id="disposePackRow" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="disposePackSelect">Pack Type *</label>
|
||||||
|
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
|
||||||
|
<option value="">-- Select pack --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="disposePackCount">Pack Count *</label>
|
||||||
|
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
|
||||||
|
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="disposeSourceGroup" style="display: none;">
|
||||||
|
<label>Stock Source *</label>
|
||||||
|
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||||
|
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
|
||||||
|
Batch stock
|
||||||
|
</label>
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
|
||||||
|
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
|
||||||
|
Legacy loose stock
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
|
||||||
|
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
|
||||||
|
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
|
||||||
|
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
|
||||||
|
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
|
||||||
|
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
|
||||||
|
</details>
|
||||||
|
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
|
||||||
|
<div id="disposeAllocationSummaryContent"></div>
|
||||||
|
</div>
|
||||||
|
<div id="disposeBatchInfoContent">
|
||||||
|
<p class="loading">Loading batches...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
||||||
|
|
||||||
<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">
|
<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>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
<label for="variantUnit">Base Unit *</label>
|
||||||
<label for="variantQuantity">Quantity *</label>
|
<select id="variantUnit">
|
||||||
<input type="number" id="variantQuantity" step="0.1" required>
|
<option value="ml">ml</option>
|
||||||
</div>
|
<option value="tablets" selected>tablets</option>
|
||||||
|
<option value="capsules">capsules</option>
|
||||||
<div class="form-group">
|
<option value="units">units</option>
|
||||||
<label for="variantUnit">Unit *</label>
|
<option value="vials">vials</option>
|
||||||
<select id="variantUnit">
|
</select>
|
||||||
<option value="tablets">Tablets</option>
|
</div>
|
||||||
<option value="bottles">Bottles</option>
|
|
||||||
<option value="boxes">boxes</option>
|
<div class="form-group">
|
||||||
<option value="vials">Vials</option>
|
<label>Pack Sizes *</label>
|
||||||
<option value="units">Units</option>
|
<div id="variantPackRows" class="delivery-lines"></div>
|
||||||
<option value="packets">Packets</option>
|
<button type="button" id="addVariantPackRowBtn" class="btn btn-secondary btn-small">+ Add Another Size</button>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
@@ -323,6 +428,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</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>
|
||||||
@@ -410,7 +521,7 @@
|
|||||||
<span class="close">×</span>
|
<span class="close">×</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">×</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">×</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">×</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">×</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">×</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">×</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>
|
||||||
|
|||||||
@@ -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">×</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
File diff suppressed because it is too large
Load Diff
+457
-28
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -1049,4 +1478,4 @@ footer {
|
|||||||
min-width: auto;
|
min-width: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user