Compare commits

...

21 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
14 changed files with 6674 additions and 632 deletions
+1
View File
@@ -20,3 +20,4 @@ node_modules/
.cache/
.pytest_cache/
drugs.db
data/backups/
+32 -3
View File
@@ -77,9 +77,38 @@ docker-compose up --build
## Database
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can:
- Backup by copying the file
- Share with team members
SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
### 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
+1344 -89
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -56,6 +56,24 @@ def migrate_compliance_schema() -> None:
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")
@@ -68,6 +86,152 @@ def migrate_compliance_schema() -> None:
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)")
+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()
+36
View File
@@ -36,6 +36,18 @@ class DrugVariant(Base):
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):
__tablename__ = "dispensings"
@@ -44,7 +56,11 @@ class Dispensing(Base):
batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True)
actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
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)
prescribing_vet = Column(String, nullable=True) # Prescribing vet's name (required for controlled drugs)
user_name = Column(String, nullable=False) # User who dispensed
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
notes = Column(String, nullable=True)
@@ -67,10 +83,19 @@ class Batch(Base):
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())
@@ -84,6 +109,17 @@ class DispensingAllocation(Base):
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"
+6 -6
View File
@@ -1,8 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
fastapi==0.137.2
uvicorn==0.49.0
sqlalchemy==2.0.51
pydantic==2.13.4
python-multipart==0.0.32
python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4
paho-mqtt==1.6.1
+18 -5
View File
@@ -10,8 +10,8 @@ services:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001
- PGID=1001
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
@@ -23,6 +23,20 @@ services:
- LABEL_SIZE=${LABEL_SIZE:-29x90}
- 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:
image: eclipse-mosquitto:latest
volumes:
@@ -31,12 +45,11 @@ services:
- mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log
environment:
- PUID=1001
- PGID=1001
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
frontend:
image: nginx:alpine
container_name: drugsdev
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+3727 -368
View File
File diff suppressed because it is too large Load Diff
+309 -108
View File
@@ -59,6 +59,7 @@
<div class="inventory-toolbar">
<div class="header-actions">
<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">
@@ -138,36 +139,6 @@
</label>
</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">
<label for="initialVariantStrength">Strength</label>
<input type="text" id="initialVariantStrength" placeholder="e.g., 10mg, 5.4mg">
</div>
<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 class="form-actions">
<button type="submit" class="btn btn-primary">Add Drug</button>
<button type="button" class="btn btn-secondary" id="cancelAddBtn">Cancel</button>
@@ -189,37 +160,66 @@
</select>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0;">Available Batches (FEFO Order)</h4>
<div id="batchInfoContent">
<p class="loading">Loading batches...</p>
<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">
<label for="dispenseBatchSelect">Preferred Batch Override</label>
<select id="dispenseBatchSelect" onchange="updateAllocationPreview()">
<option value="">Automatic FEFO Selection</option>
</select>
<small style="display: block; margin-top: 6px; color: #666;">Leave on automatic to use the earliest-expiry batch first. Choose a batch here to consume that batch first instead.</small>
</div>
<div class="form-group">
<div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1" onchange="updateAllocationPreview()">
<input type="number" id="dispenseQuantity" step="1.0">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="dispenseAllowSplit" onchange="updateAllocationPreview()">
Allow Split Across Multiple Batches
</label>
<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 id="allocationPreviewSection" style="display: none; margin: 15px 0; padding: 10px; background: #f0f8ff; border-radius: 4px; border-left: 3px solid #2196F3;">
<h4 style="margin-top: 0;">Allocation Preview</h4>
<div id="allocationPreviewContent">
<p class="loading">Loading allocation...</p>
<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>
@@ -229,8 +229,26 @@
</div>
<div class="form-group">
<label for="dispenseUser">Dispensed by *</label>
<input type="text" id="dispenseUser">
<label for="dispenseVet" id="dispenseVetLabel">Prescribing Vet</label>
<input type="text" id="dispenseVet" placeholder="Vet's name">
</div>
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
<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 class="form-group">
@@ -246,50 +264,90 @@
</div>
</div>
<!-- Prescribe Drug Modal -->
<div id="prescribeModal" class="modal">
<div class="modal-content">
<!-- Dispose Inventory Modal -->
<div id="disposeInventoryModal" class="modal">
<div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span>
<h2>Prescribe Drug & Print Label</h2>
<form id="prescribeForm" novalidate>
<input type="hidden" id="prescribeVariantId">
<input type="hidden" id="prescribeDrugName">
<input type="hidden" id="prescribeVariantStrength">
<input type="hidden" id="prescribeUnit">
<h2>Dispose Inventory</h2>
<form id="disposeInventoryForm" novalidate>
<div class="form-group">
<label for="prescribeQuantity">Quantity *</label>
<input type="number" id="prescribeQuantity" step="0.1" required>
<label for="disposeDrugSelect">Drug Variant *</label>
<select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
<option value="">-- Select a drug variant --</option>
</select>
</div>
<div class="form-group">
<label for="prescribeAnimal">Animal Name/ID *</label>
<input type="text" id="prescribeAnimal" required>
<label>Disposal Mode *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group" id="disposeQuantityGroup">
<label for="disposeQuantity">Quantity *</label>
<input type="number" id="disposeQuantity" step="1.0">
</div>
<div class="form-row" id="disposePackRow" style="display: none;">
<div class="form-group">
<label for="disposePackSelect">Pack Type *</label>
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="disposePackCount">Pack Count *</label>
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group" id="disposeSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="disposeAllocationSummaryContent"></div>
</div>
<div id="disposeBatchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div>
<div class="form-group">
<label for="prescribeDosage">Dosage Instructions *</label>
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required>
</div>
<div class="form-group">
<label for="prescribeExpiry">Expiry Date *</label>
<input type="date" id="prescribeExpiry" required>
</div>
<div class="form-group">
<label for="prescribeUser">Prescribed by *</label>
<input type="text" id="prescribeUser" required>
</div>
<div class="form-group">
<label for="prescribeNotes">Notes</label>
<input type="text" id="prescribeNotes" placeholder="Optional">
<label for="disposeNotes">Disposal Note</label>
<textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button>
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">Cancel</button>
<button type="submit" class="btn btn-danger">Dispose</button>
<button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
</div>
</form>
</div>
@@ -304,26 +362,24 @@
<input type="hidden" id="variantDrugId">
<div class="form-group">
<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 class="form-group">
<label for="variantUnit">Base Unit *</label>
<select id="variantUnit">
<option value="ml">ml</option>
<option value="tablets" selected>tablets</option>
<option value="capsules">capsules</option>
<option value="units">units</option>
<option value="vials">vials</option>
</select>
</div>
<div class="form-group">
<label for="variantUnit">Unit *</label>
<select id="variantUnit">
<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>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 class="form-group">
@@ -346,6 +402,9 @@
<h2>Edit Variant</h2>
<form id="editVariantForm">
<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">
<label for="editVariantStrength">Strength *</label>
<input type="text" id="editVariantStrength" required>
@@ -358,7 +417,7 @@
</div>
<div class="form-group">
<label for="editVariantUnit">Unit *</label>
<label for="editVariantUnit">Base Unit *</label>
<select id="editVariantUnit">
<option value="tablets">Tablets</option>
<option value="bottles">Bottles</option>
@@ -370,6 +429,12 @@
</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">
<label for="editVariantThreshold">Low Stock Threshold *</label>
<input type="number" id="editVariantThreshold" step="0.1" required>
@@ -456,7 +521,7 @@
<span class="close">&times;</span>
<h2>User Management</h2>
<div class="user-management-content">
<div class="form-group">
<section class="user-create-panel">
<h3>Create New User</h3>
<form id="createUserForm">
<div class="form-row">
@@ -471,7 +536,7 @@
</div>
<button type="submit" class="btn btn-primary btn-small">Create User</button>
</form>
</div>
</section>
<div id="usersList" class="users-list">
<h3>Users</h3>
<p class="loading">Loading users...</p>
@@ -533,6 +598,41 @@
</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">
@@ -575,6 +675,107 @@
</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>
<script src="app.js"></script>
+32
View File
@@ -31,6 +31,8 @@
<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>
@@ -62,6 +64,7 @@
<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>
@@ -87,6 +90,35 @@
</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>
+546 -9
View File
@@ -5,10 +5,40 @@ let currentUser = null;
let allDrugs = [];
let auditTrailRows = [];
let dispensingRows = [];
let globalInventoryRows = [];
let batchAttentionRows = [];
let activeReportType = 'dispensing';
const batchLookupById = new Map();
const loadedBatchVariants = new Set();
function openModal(modal) {
if (!modal) return;
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove('show');
document.body.style.overflow = 'auto';
}
function resetDisposeBatchModal() {
const form = document.getElementById('disposeBatchForm');
if (form) {
form.reset();
}
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
if (batchIdInput) batchIdInput.value = '';
if (batchNameInput) batchNameInput.value = '';
}
function closeDisposeBatchModal() {
resetDisposeBatchModal();
closeModal(document.getElementById('disposeBatchModal'));
}
function showToast(message, type = 'info', duration = 3000) {
const container = document.getElementById('toastContainer');
if (!container) return;
@@ -170,19 +200,31 @@ function detailsContainsText(details, searchText) {
}
function getActiveRows() {
return activeReportType === 'dispensing' ? dispensingRows : auditTrailRows;
if (activeReportType === 'dispensing') return dispensingRows;
if (activeReportType === 'global_inventory') return globalInventoryRows;
if (activeReportType === 'batch_attention') return batchAttentionRows;
return auditTrailRows;
}
function getRowUser(row) {
return activeReportType === 'dispensing' ? (row.user_name || 'unknown') : (row.actor_username || 'system');
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
if (activeReportType === 'global_inventory') return '';
if (activeReportType === 'batch_attention') return '';
return row.actor_username || 'system';
}
function getRowDrug(row) {
return activeReportType === 'dispensing' ? extractDrugLabelFromDispenseRow(row) : extractDrugLabelFromAuditRow(row);
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
if (activeReportType === 'global_inventory') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
return extractDrugLabelFromAuditRow(row);
}
function getRowDate(row) {
return new Date(activeReportType === 'dispensing' ? row.dispensed_at : row.created_at);
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
if (activeReportType === 'global_inventory') return row.expiry_date ? new Date(row.expiry_date) : null;
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
return new Date(row.created_at);
}
function populateCommonFilters(rows) {
@@ -193,7 +235,7 @@ function populateCommonFilters(rows) {
const previousUser = userFilter.value;
const previousDrug = drugFilter.value;
const users = Array.from(new Set(rows.map(getRowUser))).sort((a, b) => a.localeCompare(b));
const users = Array.from(new Set(rows.map(getRowUser).filter(Boolean))).sort((a, b) => a.localeCompare(b));
const drugs = Array.from(new Set(rows.map(getRowDrug).filter(label => label && label !== 'N/A'))).sort((a, b) => a.localeCompare(b));
userFilter.innerHTML = '<option value="">All Users</option>';
@@ -275,6 +317,7 @@ function renderDispensingTable(rows) {
const info = getVariantInfoById(row.drug_variant_id);
const quantityText = `${row.quantity} ${info.unit || 'units'}`;
const animal = row.animal_name || '-';
const vet = row.prescribing_vet || '-';
const notes = row.notes || '-';
const allocations = formatDispenseAllocation(row);
@@ -286,6 +329,7 @@ function renderDispensingTable(rows) {
<td>${escapeHtml(info.strength || '-')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(animal)}</td>
<td>${escapeHtml(vet)}</td>
<td>${escapeHtml(allocations)}</td>
<td>${escapeHtml(notes)}</td>
</tr>
@@ -302,6 +346,7 @@ function renderDispensingTable(rows) {
<th>Strength</th>
<th>Quantity</th>
<th>Animal</th>
<th>Prescribing Vet</th>
<th>Batch Allocation</th>
<th>Notes</th>
</tr>
@@ -311,6 +356,152 @@ function renderDispensingTable(rows) {
`;
}
function renderGlobalInventoryTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No inventory lines match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '-';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '');
const locationText = row.location_name || '-';
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(locationText)}</td>
<td>${escapeHtml(expiryText)}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Variant</th>
<th>Batch</th>
<th>Quantity</th>
<th>Location</th>
<th>Expiry</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderBatchAttentionTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No expired batches match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : 'Unknown';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const statusText = 'Expired';
const isExpired = true;
const packState = row.current_loose_base_units > 0
? `${row.current_full_pack_count || 0} full packs + ${row.current_loose_base_units} loose ${row.unit || 'units'}`
: `${row.current_full_pack_count || 0} full packs`;
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(row.batch_number || '')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(packState)}</td>
<td>${escapeHtml(row.location || '-')}</td>
<td>${escapeHtml(expiryText)}</td>
<td>${escapeHtml(statusText)}</td>
<td>${isExpired ? `<button type="button" class="btn btn-danger btn-small" onclick="disposeBatchFromReport(${row.batch_id}, '${String(row.batch_number || '').replace(/'/g, "\\'")}')">Dispose Expired Batch</button>` : '-'}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Strength</th>
<th>Batch</th>
<th>Quantity</th>
<th>Pack State</th>
<th>Location</th>
<th>Expiry</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function disposeBatchFromReport(batchId, batchNumber) {
const modal = document.getElementById('disposeBatchModal');
const batchIdInput = document.getElementById('disposeBatchId');
const batchNameInput = document.getElementById('disposeBatchName');
const notesInput = document.getElementById('disposeBatchNotes');
if (!modal || !batchIdInput || !batchNameInput || !notesInput) {
showToast('Dispose batch modal is unavailable.', 'error');
return;
}
batchIdInput.value = String(batchId);
batchNameInput.value = batchNumber;
notesInput.value = '';
openModal(modal);
}
async function handleDisposeBatch(e) {
e.preventDefault();
const batchId = parseInt(document.getElementById('disposeBatchId')?.value || '', 10);
const notes = document.getElementById('disposeBatchNotes')?.value.trim() || '';
if (!batchId) {
showToast('Batch disposal context is unavailable.', 'error');
return;
}
try {
const response = await apiCall(`/batches/${batchId}/dispose`, {
method: 'POST',
body: JSON.stringify({ notes: notes || null })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to dispose batch');
}
closeDisposeBatchModal();
await loadActiveReport();
showToast('Expired batch marked as disposed.', 'success');
} catch (error) {
console.error('Error disposing batch from report:', error);
showToast('Failed to dispose batch: ' + error.message, 'error');
}
}
function applyCurrentFilters() {
const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter');
@@ -330,8 +521,8 @@ function applyCurrentFilters() {
const userMatch = !selectedUser || getRowUser(row) === selectedUser;
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
const rowDate = getRowDate(row);
const fromMatch = !fromDate || rowDate >= fromDate;
const toMatch = !toDate || rowDate <= toDate;
const fromMatch = !fromDate || !rowDate || rowDate >= fromDate;
const toMatch = !toDate || !rowDate || rowDate <= toDate;
let textMatch = true;
if (searchText) {
@@ -342,10 +533,31 @@ function applyCurrentFilters() {
info.drugName || '',
info.strength || '',
row.animal_name || '',
row.prescribing_vet || '',
row.notes || '',
formatDispenseAllocation(row)
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'global_inventory') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.inventory_source || '',
row.location_name || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'batch_attention') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.location || '',
row.status || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else {
const actionText = (row.action || '').toLowerCase();
const entityText = (row.entity_type || '').toLowerCase();
@@ -361,31 +573,307 @@ function applyCurrentFilters() {
});
if (reportsSummary) {
const reportName = activeReportType === 'dispensing' ? 'dispensing records' : 'audit events';
const reportName = activeReportType === 'dispensing'
? 'dispensing records'
: activeReportType === 'global_inventory'
? 'inventory lines'
: activeReportType === 'batch_attention'
? 'expired batches'
: 'audit events';
reportsSummary.textContent = `Showing ${filteredRows.length} of ${sourceRows.length} ${reportName}`;
}
if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows);
} else if (activeReportType === 'global_inventory') {
renderGlobalInventoryTable(filteredRows);
} else if (activeReportType === 'batch_attention') {
renderBatchAttentionTable(filteredRows);
} else {
renderAuditTable(filteredRows);
}
return filteredRows;
}
function updateReportHeading() {
const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch');
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else if (activeReportType === 'global_inventory') {
heading.textContent = 'Global Inventory';
searchInput.placeholder = 'Search drug, variant, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = '';
} else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else {
heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
}
}
function formatDisplayDate(value) {
if (!value) return '-';
const date = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T00:00:00`)
: new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString();
}
function getReportFilterSummary() {
const drugFilter = document.getElementById('reportDrugFilter')?.value || '';
const fromDate = document.getElementById('reportFromDate')?.value || '';
const toDate = document.getElementById('reportToDate')?.value || '';
const searchText = document.getElementById('reportActionSearch')?.value.trim() || '';
const parts = [];
if (drugFilter) parts.push(`Drug: ${drugFilter}`);
if (fromDate) parts.push(`From: ${formatDisplayDate(fromDate)}`);
if (toDate) parts.push(`To: ${formatDisplayDate(toDate)}`);
if (searchText) parts.push(`Search: ${searchText}`);
return parts.length ? parts.join(' | ') : 'All inventory lines';
}
function getStockCheckRows() {
const previousReportType = activeReportType;
activeReportType = 'global_inventory';
const rows = applyCurrentFilters() || [];
activeReportType = previousReportType;
return rows;
}
function buildStockCheckPdfHtml(rows) {
const groupedRows = new Map();
const sortedRows = [...rows].sort((a, b) => {
const drugCompare = String(a.drug_name || '').localeCompare(String(b.drug_name || ''), undefined, { sensitivity: 'base' });
if (drugCompare !== 0) return drugCompare;
const strengthCompare = String(a.strength || '').localeCompare(String(b.strength || ''), undefined, { numeric: true, sensitivity: 'base' });
if (strengthCompare !== 0) return strengthCompare;
const expiryCompare = String(a.expiry_date || '9999-12-31').localeCompare(String(b.expiry_date || '9999-12-31'));
if (expiryCompare !== 0) return expiryCompare;
return String(a.batch_number || '').localeCompare(String(b.batch_number || ''), undefined, { numeric: true, sensitivity: 'base' });
});
sortedRows.forEach(row => {
const drugName = row.drug_name || 'Unknown Drug';
if (!groupedRows.has(drugName)) groupedRows.set(drugName, []);
groupedRows.get(drugName).push(row);
});
const generatedAt = new Date().toLocaleString();
const filterSummary = getReportFilterSummary();
const totalLines = rows.length;
const sectionsHtml = Array.from(groupedRows.entries()).map(([drugName, drugRows]) => {
const bodyHtml = drugRows.map(row => {
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '-');
const quantityText = `${row.quantity ?? 0} ${row.unit || 'units'}`;
const controlledText = row.is_controlled ? 'Yes' : 'No';
return `
<tr>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(row.location_name || '-')}</td>
<td>${escapeHtml(formatDisplayDate(row.expiry_date))}</td>
<td>${escapeHtml(controlledText)}</td>
<td class="manual-entry"></td>
<td class="manual-entry notes-cell"></td>
</tr>
`;
}).join('');
return `
<section class="drug-section">
<h2>${escapeHtml(drugName)}</h2>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Batch</th>
<th>System Qty</th>
<th>Location</th>
<th>Expiry</th>
<th>CD</th>
<th>Actual</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${bodyHtml}</tbody>
</table>
</section>
`;
}).join('');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manual Stock Check</title>
<style>
@page { size: A4 portrait; margin: 8mm; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #111827;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.5pt;
}
header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
margin-bottom: 5mm;
border-bottom: 1.5px solid #111827;
padding-bottom: 3mm;
}
h1 {
margin: 0 0 1.5mm;
font-size: 14pt;
line-height: 1.1;
}
.meta {
color: #374151;
font-size: 7.5pt;
line-height: 1.35;
}
.signoff {
min-width: 52mm;
display: grid;
gap: 2mm;
font-size: 7.5pt;
}
.signoff-line {
border-bottom: 1px solid #6b7280;
height: 5mm;
}
.empty {
border: 1px solid #d1d5db;
padding: 8mm;
color: #4b5563;
}
.drug-section {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 4mm;
}
h2 {
margin: 0;
padding: 1mm 1.5mm;
background: #e5e7eb;
border: 1px solid #9ca3af;
border-bottom: 0;
font-size: 9.5pt;
line-height: 1.15;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #9ca3af;
padding: 1.2mm 1.4mm;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
line-height: 1.2;
}
th {
background: #f3f4f6;
font-size: 6.8pt;
text-transform: uppercase;
letter-spacing: 0;
padding-top: 1mm;
padding-bottom: 1mm;
}
th:nth-child(1) { width: 13%; }
th:nth-child(2) { width: 15%; }
th:nth-child(3) { width: 12%; }
th:nth-child(4) { width: 13%; }
th:nth-child(5) { width: 10%; }
th:nth-child(6) { width: 5%; }
th:nth-child(7) { width: 12%; }
th:nth-child(8) { width: 20%; }
td.manual-entry {
height: 7mm;
background: #ffffff;
}
.notes-cell {
min-height: 7mm;
}
@media print {
body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Manual Stock Check</h1>
<div class="meta">Generated: ${escapeHtml(generatedAt)}</div>
<div class="meta">Filters: ${escapeHtml(filterSummary)}</div>
<div class="meta">Inventory lines: ${escapeHtml(String(totalLines))}</div>
</div>
<div class="signoff">
<div>Checked by<div class="signoff-line"></div></div>
<div>Date<div class="signoff-line"></div></div>
</div>
</header>
${sectionsHtml || '<div class="empty">No inventory lines match the selected filters.</div>'}
<script>
window.addEventListener('load', () => {
window.focus();
window.print();
});
</script>
</body>
</html>
`;
}
function generateStockCheckPdf() {
if (activeReportType !== 'global_inventory') {
showToast('Select Global Inventory to generate a stock check PDF.', 'warning');
return;
}
const rows = getStockCheckRows();
if (!rows.length) {
showToast('No inventory lines match the selected filters.', 'warning');
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) {
showToast('Allow pop-ups to generate the stock check PDF.', 'warning');
return;
}
printWindow.document.open();
printWindow.document.write(buildStockCheckPdfHtml(rows));
printWindow.document.close();
}
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
@@ -423,7 +911,13 @@ async function loadActiveReport() {
const container = document.getElementById('reportsTableContainer');
const reportsSummary = document.getElementById('reportsSummary');
if (container) {
const loadingText = activeReportType === 'dispensing' ? 'Loading dispensing history...' : 'Loading audit trail...';
const loadingText = activeReportType === 'dispensing'
? 'Loading dispensing history...'
: activeReportType === 'global_inventory'
? 'Loading global inventory...'
: activeReportType === 'batch_attention'
? 'Loading expired batches...'
: 'Loading audit trail...';
container.innerHTML = `<p class="loading" style="padding: 14px;">${loadingText}</p>`;
}
if (reportsSummary) reportsSummary.textContent = '';
@@ -438,6 +932,22 @@ async function loadActiveReport() {
dispensingRows = await response.json();
await ensureBatchLookupForDispensing(dispensingRows);
populateCommonFilters(dispensingRows);
} else if (activeReportType === 'global_inventory') {
const response = await apiCall('/reports/global-inventory');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load global inventory');
}
globalInventoryRows = await response.json();
populateCommonFilters(globalInventoryRows);
} else if (activeReportType === 'batch_attention') {
const response = await apiCall('/reports/batch-attention');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load batch attention report');
}
batchAttentionRows = await response.json();
populateCommonFilters(batchAttentionRows);
} else {
const response = await apiCall('/reports/audit-trail');
if (!response.ok) {
@@ -481,9 +991,13 @@ function setupEventListeners() {
const applyBtn = document.getElementById('applyReportFiltersBtn');
const clearBtn = document.getElementById('clearReportFiltersBtn');
const refreshBtn = document.getElementById('refreshReportsBtn');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn');
const disposeBatchForm = document.getElementById('disposeBatchForm');
const cancelDisposeBatchBtn = document.getElementById('cancelDisposeBatchBtn');
const closeButtons = document.querySelectorAll('.close');
const userFilter = document.getElementById('reportUserFilter');
const drugFilter = document.getElementById('reportDrugFilter');
@@ -501,6 +1015,7 @@ function setupEventListeners() {
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
if (stockCheckPdfBtn) stockCheckPdfBtn.addEventListener('click', generateStockCheckPdf);
if (clearBtn) {
clearBtn.addEventListener('click', () => {
@@ -538,6 +1053,28 @@ function setupEventListeners() {
if (goToLoginBtn) goToLoginBtn.addEventListener('click', () => {
window.location.href = 'index.html';
});
if (disposeBatchForm) disposeBatchForm.addEventListener('submit', handleDisposeBatch);
if (cancelDisposeBatchBtn) cancelDisposeBatchBtn.addEventListener('click', closeDisposeBatchModal);
closeButtons.forEach(btn => btn.addEventListener('click', (e) => {
const modal = e.target.closest('.modal');
if (modal?.id === 'disposeBatchModal') {
closeDisposeBatchModal();
return;
}
closeModal(modal);
}));
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
if (e.target.id === 'disposeBatchModal') {
closeDisposeBatchModal();
return;
}
closeModal(e.target);
}
});
}
async function initializeReportsPage() {
+232 -23
View File
@@ -673,7 +673,20 @@ footer {
overflow-y: auto;
}
#dispenseModal.show {
#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;
@@ -685,21 +698,22 @@ footer {
overflow-y: auto;
}
#dispenseForm {
#dispenseForm,
#disposeInventoryForm {
display: block;
padding-right: 6px;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 220px;
overflow-y: auto;
min-height: fit-content;
}
#dispenseModal .form-actions {
position: sticky;
bottom: 0;
#dispenseModal .form-actions,
#disposeInventoryModal .form-actions {
margin-top: 16px;
padding-top: 14px;
background: var(--white);
@@ -832,6 +846,8 @@ footer {
.report-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.reports-summary {
@@ -877,6 +893,103 @@ footer {
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 */
@media (max-width: 768px) {
main {
@@ -935,6 +1048,14 @@ footer {
min-width: 0;
}
.delivery-line-grid {
grid-template-columns: 1fr;
}
.delivery-toolbar {
flex-direction: column;
}
.drug-details {
grid-template-columns: 1fr;
}
@@ -952,11 +1073,13 @@ footer {
max-height: calc(100vh - 24px);
}
#dispenseModal.show {
#dispenseModal.show,
#disposeInventoryModal.show {
padding: 12px 0;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 160px;
}
@@ -1078,22 +1201,29 @@ footer {
}
.user-management-content h3 {
margin-top: 20px;
margin-top: 0;
margin-bottom: 15px;
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 {
display: flex;
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.user-management-content input,
.user-management-content select {
flex: 1;
min-width: 150px;
width: 100%;
min-width: 0;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -1105,33 +1235,103 @@ footer {
}
.users-list {
margin-top: 20px;
margin-top: 24px;
}
.users-table {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.user-item {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
align-items: center;
padding: 12px;
background: #f8f9fa;
padding: 12px 14px;
background: var(--white);
border: 1px solid var(--border-color);
border-radius: 6px;
gap: 15px;
border-radius: 8px;
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 {
padding: 4px 12px;
background: var(--warning-color);
justify-self: start;
padding: 5px 10px;
color: var(--white);
border-radius: 20px;
font-size: 0.9em;
border-radius: 999px;
font-size: 0.85em;
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 */
@@ -1161,6 +1361,15 @@ footer {
max-width: none;
}
.user-management-content .form-row,
.user-item {
grid-template-columns: 1fr;
}
.user-actions {
justify-content: flex-start;
}
.drug-item {
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()