Compare commits
6 Commits
3ec00613ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288b390618 | ||
|
|
fcdef6875e | ||
|
|
a6d8767c28 | ||
|
|
d6e9f20757 | ||
|
|
674a8f8a0c | ||
|
|
5097ceb82f |
@@ -30,8 +30,9 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r
|
|||||||
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
|
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
|
||||||
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
|
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
|
||||||
8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
|
8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
|
||||||
9) **Database changes:** The app auto-initializes the database if it doesn't exist. Schema is in /database/init_db.py.
|
9) **Docker deployment:** Production runs in Docker on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
|
||||||
10) **Docker deployment:** Production runs in Docker on Linux (jisoo). Volume mounts only /app/database to preserve data between updates.
|
10) Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
|
||||||
|
|
||||||
|
|
||||||
## How you should respond
|
## How you should respond
|
||||||
- Start by confirming which mode we’re working on: Cycle Count or Physical Inventory.
|
- Start by confirming which mode we’re working on: Cycle Count or Physical Inventory.
|
||||||
@@ -43,10 +44,10 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r
|
|||||||
## Scanlook (current product summary)
|
## Scanlook (current product summary)
|
||||||
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
|
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
|
||||||
|
|
||||||
**Current Version:** 0.12.0
|
**Current Version:** 0.13.0
|
||||||
|
|
||||||
**Tech Stack:**
|
**Tech Stack:**
|
||||||
- Backend: Python/Flask, raw SQL (no ORM)
|
- Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation)
|
||||||
- Database: SQLite (located in /database/scanlook.db)
|
- Database: SQLite (located in /database/scanlook.db)
|
||||||
- Frontend: Jinja2 templates, vanilla JS, custom CSS
|
- Frontend: Jinja2 templates, vanilla JS, custom CSS
|
||||||
- CSS Architecture: Desktop-first with device-specific overrides
|
- CSS Architecture: Desktop-first with device-specific overrides
|
||||||
@@ -63,6 +64,7 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
|
|||||||
- /database/ (scanlook.db, init_db.py)
|
- /database/ (scanlook.db, init_db.py)
|
||||||
- db.py (database helper functions: query_db, execute_db)
|
- db.py (database helper functions: query_db, execute_db)
|
||||||
- utils.py (decorators: login_required, role_required)
|
- utils.py (decorators: login_required, role_required)
|
||||||
|
- migrations.py (database migration system)
|
||||||
|
|
||||||
**Key Features (implemented):**
|
**Key Features (implemented):**
|
||||||
- Count Sessions with archive/activate functionality
|
- Count Sessions with archive/activate functionality
|
||||||
@@ -74,6 +76,8 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
|
|||||||
- Session isolation (archived sessions blocked from access)
|
- Session isolation (archived sessions blocked from access)
|
||||||
- Role-based access: owner, admin, staff
|
- Role-based access: owner, admin, staff
|
||||||
- Auto-initialize database on first run
|
- Auto-initialize database on first run
|
||||||
|
- Consumption Sheets module (production lot tracking with Excel export)
|
||||||
|
- Database migration system (auto-applies schema changes on startup)
|
||||||
|
|
||||||
**Two count types:**
|
**Two count types:**
|
||||||
1. Cycle Count: shows Expected list for the BIN
|
1. Cycle Count: shows Expected list for the BIN
|
||||||
@@ -81,7 +85,7 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
|
|||||||
|
|
||||||
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
|
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
|
||||||
|
|
||||||
**Module System (v0.12.0):**
|
**Module System (v0.13.0):**
|
||||||
- Modules table defines available modules (module_key used for routing)
|
- Modules table defines available modules (module_key used for routing)
|
||||||
- UserModules table tracks per-user access
|
- UserModules table tracks per-user access
|
||||||
- Home page (/home) shows module cards based on user's access
|
- Home page (/home) shows module cards based on user's access
|
||||||
@@ -90,6 +94,9 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
|
|||||||
- __init__.py (blueprint registration)
|
- __init__.py (blueprint registration)
|
||||||
- routes.py (all routes)
|
- routes.py (all routes)
|
||||||
- templates/ (module-specific templates)
|
- templates/ (module-specific templates)
|
||||||
|
- Current modules:
|
||||||
|
- Inventory Counts (counting)
|
||||||
|
- Consumption Sheets (cons_sheets)
|
||||||
|
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|||||||
22
app.py
22
app.py
@@ -38,7 +38,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
|
|||||||
|
|
||||||
|
|
||||||
# 1. Define the version
|
# 1. Define the version
|
||||||
APP_VERSION = '0.13.0'
|
APP_VERSION = '0.13.2'
|
||||||
|
|
||||||
# 2. Inject it into all templates automatically
|
# 2. Inject it into all templates automatically
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -54,6 +54,10 @@ if not os.path.exists(db_path):
|
|||||||
create_default_users()
|
create_default_users()
|
||||||
print("Database initialized!")
|
print("Database initialized!")
|
||||||
|
|
||||||
|
# Run migrations to apply any pending database changes
|
||||||
|
from migrations import run_migrations
|
||||||
|
run_migrations()
|
||||||
|
|
||||||
|
|
||||||
# ==================== ROUTES: AUTHENTICATION ====================
|
# ==================== ROUTES: AUTHENTICATION ====================
|
||||||
|
|
||||||
@@ -160,22 +164,6 @@ def admin_dashboard():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/staff-mode')
|
|
||||||
@login_required
|
|
||||||
def staff_mode():
|
|
||||||
"""Allow admin/owner to switch to staff view for scanning"""
|
|
||||||
# Show staff dashboard view regardless of role
|
|
||||||
active_sessions = query_db('''
|
|
||||||
SELECT session_id, session_name, session_type, created_timestamp
|
|
||||||
FROM CountSessions
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY created_timestamp DESC
|
|
||||||
''')
|
|
||||||
|
|
||||||
return render_template('staff_dashboard.html', sessions=active_sessions, is_admin_mode=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== PWA SUPPORT ROUTES ====================
|
# ==================== PWA SUPPORT ROUTES ====================
|
||||||
|
|
||||||
@app.route('/manifest.json')
|
@app.route('/manifest.json')
|
||||||
|
|||||||
Binary file not shown.
@@ -10,6 +10,50 @@ def get_active_session(session_id):
|
|||||||
return None
|
return None
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@counting_bp.route('/counts/admin')
|
||||||
|
@login_required
|
||||||
|
def admin_dashboard():
|
||||||
|
"""Admin dashboard for Counts module"""
|
||||||
|
# Security check: Ensure user is admin/owner
|
||||||
|
if session.get('role') not in ['owner', 'admin']:
|
||||||
|
flash('Access denied. Admin role required.', 'danger')
|
||||||
|
return redirect(url_for('counting.index'))
|
||||||
|
|
||||||
|
show_archived = request.args.get('show_archived', '0') == '1'
|
||||||
|
|
||||||
|
# This SQL was moved from app.py
|
||||||
|
if show_archived:
|
||||||
|
sessions_list = query_db('''
|
||||||
|
SELECT s.*, u.full_name as created_by_name,
|
||||||
|
COUNT(DISTINCT lc.location_count_id) as total_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
|
||||||
|
FROM CountSessions s
|
||||||
|
LEFT JOIN Users u ON s.created_by = u.user_id
|
||||||
|
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
|
||||||
|
WHERE s.status IN ('active', 'archived')
|
||||||
|
GROUP BY s.session_id
|
||||||
|
ORDER BY s.status ASC, s.created_timestamp DESC
|
||||||
|
''')
|
||||||
|
else:
|
||||||
|
sessions_list = query_db('''
|
||||||
|
SELECT s.*, u.full_name as created_by_name,
|
||||||
|
COUNT(DISTINCT lc.location_count_id) as total_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
|
||||||
|
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
|
||||||
|
FROM CountSessions s
|
||||||
|
LEFT JOIN Users u ON s.created_by = u.user_id
|
||||||
|
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
|
||||||
|
WHERE s.status = 'active'
|
||||||
|
GROUP BY s.session_id
|
||||||
|
ORDER BY s.created_timestamp DESC
|
||||||
|
''')
|
||||||
|
|
||||||
|
return render_template('counts/admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
|
||||||
|
|
||||||
|
|
||||||
@counting_bp.route('/counts')
|
@counting_bp.route('/counts')
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -33,7 +77,7 @@ def index():
|
|||||||
ORDER BY created_timestamp DESC
|
ORDER BY created_timestamp DESC
|
||||||
''')
|
''')
|
||||||
|
|
||||||
return render_template('staff_dashboard.html', sessions=active_sessions)
|
return render_template('counts/staff_dashboard.html', sessions=active_sessions)
|
||||||
|
|
||||||
|
|
||||||
@counting_bp.route('/count/<int:session_id>')
|
@counting_bp.route('/count/<int:session_id>')
|
||||||
@@ -91,7 +135,7 @@ def my_counts(session_id):
|
|||||||
ORDER BY lc.end_timestamp DESC
|
ORDER BY lc.end_timestamp DESC
|
||||||
''', [session_id, session['user_id']])
|
''', [session_id, session['user_id']])
|
||||||
|
|
||||||
return render_template('my_counts.html',
|
return render_template('counts/my_counts.html',
|
||||||
count_session=sess,
|
count_session=sess,
|
||||||
active_bins=active_bins,
|
active_bins=active_bins,
|
||||||
completed_bins=completed_bins)
|
completed_bins=completed_bins)
|
||||||
@@ -219,7 +263,7 @@ def count_location(session_id, location_count_id):
|
|||||||
ORDER BY lot_number
|
ORDER BY lot_number
|
||||||
''', [session_id, location['location_name'], location_count_id])
|
''', [session_id, location['location_name'], location_count_id])
|
||||||
|
|
||||||
return render_template('count_location.html',
|
return render_template('counts/count_location.html',
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
location=location,
|
location=location,
|
||||||
scans=scans,
|
scans=scans,
|
||||||
|
|||||||
@@ -166,6 +166,38 @@ def init_database():
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MODULE SYSTEM TABLES
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Modules Table - Available feature modules
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Modules (
|
||||||
|
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
module_key TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
display_order INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# UserModules Table - Module access per user
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS UserModules (
|
||||||
|
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
module_id INTEGER NOT NULL,
|
||||||
|
granted_by INTEGER,
|
||||||
|
granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES Users(user_id),
|
||||||
|
FOREIGN KEY (module_id) REFERENCES Modules(module_id),
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES Users(user_id),
|
||||||
|
UNIQUE(user_id, module_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# CONSUMPTION SHEETS MODULE TABLES
|
# CONSUMPTION SHEETS MODULE TABLES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -295,6 +327,52 @@ def create_default_users():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_modules():
|
||||||
|
"""Create default modules and assign to admin users"""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Define default modules
|
||||||
|
default_modules = [
|
||||||
|
('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1),
|
||||||
|
('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Insert modules (ignore if already exist)
|
||||||
|
for module in default_modules:
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', module)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass # Module already exists
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Auto-assign all modules to owner and admin users
|
||||||
|
cursor.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")')
|
||||||
|
admin_users = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute('SELECT module_id FROM Modules')
|
||||||
|
all_modules = cursor.fetchall()
|
||||||
|
|
||||||
|
for user in admin_users:
|
||||||
|
for module in all_modules:
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO UserModules (user_id, module_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
''', (user[0], module[0]))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass # Assignment already exists
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("✅ Default modules created and assigned to admin users")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
init_database()
|
init_database()
|
||||||
create_default_users()
|
create_default_users()
|
||||||
|
create_default_modules()
|
||||||
245
migrations.py
Normal file
245
migrations.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
ScanLook Database Migration System
|
||||||
|
|
||||||
|
Simple migration system that tracks and applies database changes.
|
||||||
|
Each migration has a version number and an up() function.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from migrations import run_migrations
|
||||||
|
run_migrations() # Call on app startup
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), 'database', 'scanlook.db')
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Get database connection"""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_migrations_table():
|
||||||
|
"""Create the migrations tracking table if it doesn't exist"""
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_applied_migrations():
|
||||||
|
"""Get list of already-applied migration versions"""
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
rows = conn.execute('SELECT version FROM schema_migrations ORDER BY version').fetchall()
|
||||||
|
return [row['version'] for row in rows]
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def record_migration(version, name):
|
||||||
|
"""Record that a migration was applied"""
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [version, name])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table, column):
|
||||||
|
"""Check if a column exists in a table"""
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.execute(f'PRAGMA table_info({table})')
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return column in columns
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table):
|
||||||
|
"""Check if a table exists"""
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [table])
|
||||||
|
exists = cursor.fetchone() is not None
|
||||||
|
conn.close()
|
||||||
|
return exists
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MIGRATIONS
|
||||||
|
# ============================================
|
||||||
|
# Add new migrations to this list.
|
||||||
|
# Each migration is a tuple: (version, name, up_function)
|
||||||
|
#
|
||||||
|
# RULES:
|
||||||
|
# - Never modify an existing migration
|
||||||
|
# - Always add new migrations at the end with the next version number
|
||||||
|
# - Check if changes are needed before applying (idempotent)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def migration_001_add_modules_tables():
|
||||||
|
"""Add Modules and UserModules tables"""
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
if not table_exists('Modules'):
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE Modules (
|
||||||
|
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
module_key TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
display_order INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
print(" Created Modules table")
|
||||||
|
|
||||||
|
if not table_exists('UserModules'):
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE UserModules (
|
||||||
|
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
module_id INTEGER NOT NULL,
|
||||||
|
granted_by INTEGER,
|
||||||
|
granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES Users(user_id),
|
||||||
|
FOREIGN KEY (module_id) REFERENCES Modules(module_id),
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES Users(user_id),
|
||||||
|
UNIQUE(user_id, module_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
print(" Created UserModules table")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migration_002_add_usermodules_granted_columns():
|
||||||
|
"""Add granted_by and granted_timestamp to UserModules if missing"""
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
if table_exists('UserModules'):
|
||||||
|
if not column_exists('UserModules', 'granted_by'):
|
||||||
|
conn.execute('ALTER TABLE UserModules ADD COLUMN granted_by INTEGER')
|
||||||
|
print(" Added granted_by column to UserModules")
|
||||||
|
|
||||||
|
if not column_exists('UserModules', 'granted_timestamp'):
|
||||||
|
conn.execute('ALTER TABLE UserModules ADD COLUMN granted_timestamp DATETIME')
|
||||||
|
print(" Added granted_timestamp column to UserModules")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migration_003_add_default_modules():
|
||||||
|
"""Add default modules if they don't exist"""
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
# Check if modules exist
|
||||||
|
existing = conn.execute('SELECT COUNT(*) as cnt FROM Modules').fetchone()
|
||||||
|
|
||||||
|
if existing['cnt'] == 0:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
|
||||||
|
VALUES ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
|
||||||
|
VALUES ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2)
|
||||||
|
''')
|
||||||
|
print(" Added default modules")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migration_004_assign_modules_to_admins():
|
||||||
|
"""Auto-assign all modules to owner and admin users"""
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
# Get admin users
|
||||||
|
admins = conn.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")').fetchall()
|
||||||
|
modules = conn.execute('SELECT module_id FROM Modules').fetchall()
|
||||||
|
|
||||||
|
for user in admins:
|
||||||
|
for module in modules:
|
||||||
|
try:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO UserModules (user_id, module_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
''', [user['user_id'], module['module_id']])
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass # Already assigned
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(" Assigned modules to admin users")
|
||||||
|
|
||||||
|
|
||||||
|
def migration_005_add_cons_process_fields_duplicate_key():
|
||||||
|
"""Add is_duplicate_key column to cons_process_fields if missing"""
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
if table_exists('cons_process_fields'):
|
||||||
|
if not column_exists('cons_process_fields', 'is_duplicate_key'):
|
||||||
|
conn.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0')
|
||||||
|
print(" Added is_duplicate_key column to cons_process_fields")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# List of all migrations in order
|
||||||
|
MIGRATIONS = [
|
||||||
|
(1, 'add_modules_tables', migration_001_add_modules_tables),
|
||||||
|
(2, 'add_usermodules_granted_columns', migration_002_add_usermodules_granted_columns),
|
||||||
|
(3, 'add_default_modules', migration_003_add_default_modules),
|
||||||
|
(4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins),
|
||||||
|
(5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
"""Run all pending migrations"""
|
||||||
|
print("🔄 Checking database migrations...")
|
||||||
|
|
||||||
|
# Make sure migrations table exists
|
||||||
|
init_migrations_table()
|
||||||
|
|
||||||
|
# Get already-applied migrations
|
||||||
|
applied = get_applied_migrations()
|
||||||
|
|
||||||
|
# Run pending migrations
|
||||||
|
pending = [(v, n, f) for v, n, f in MIGRATIONS if v not in applied]
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
print("✅ Database is up to date")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"📦 Running {len(pending)} migration(s)...")
|
||||||
|
|
||||||
|
for version, name, func in pending:
|
||||||
|
print(f"\n Migration {version}: {name}")
|
||||||
|
try:
|
||||||
|
func()
|
||||||
|
record_migration(version, name)
|
||||||
|
print(f" ✅ Migration {version} complete")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Migration {version} failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
print("\n✅ All migrations complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_migrations()
|
||||||
@@ -4,121 +4,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<!-- Mode Selector -->
|
<div class="dashboard-header" style="margin-top: var(--space-lg);">
|
||||||
<div class="mode-selector">
|
<div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);">
|
||||||
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm">
|
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm">
|
||||||
<i class="fa-solid fa-arrow-left"></i> Back to Home
|
<i class="fa-solid fa-arrow-left"></i> Back to Home
|
||||||
</a>
|
</a>
|
||||||
<button class="mode-btn mode-btn-active" data-href="{{ url_for('admin_dashboard') }}">
|
<h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1>
|
||||||
👔 Admin Console
|
|
||||||
</button>
|
|
||||||
<button class="mode-btn" data-href="{{ url_for('staff_mode') }}">
|
|
||||||
📦 Scanning Mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.mode-selector button').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
window.location.href = this.getAttribute('data-href');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="dashboard-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="page-title">Admin Dashboard</h1>
|
|
||||||
<label class="filter-toggle">
|
|
||||||
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
|
|
||||||
<span class="filter-label">Show Archived</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
|
||||||
<span class="btn-icon">+</span> New Session
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleArchived() {
|
|
||||||
const checked = document.getElementById('showArchived').checked;
|
|
||||||
window.location.href = '{{ url_for("admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Modules Section -->
|
|
||||||
<div class="modules-section">
|
<div class="modules-section">
|
||||||
<h2 class="section-title">Modules</h2>
|
<h2 class="section-title">Modules</h2>
|
||||||
<div class="modules-grid">
|
<div class="modules-grid">
|
||||||
<div class="module-card module-card-active">
|
<a href="{{ url_for('counting.admin_dashboard') }}" class="module-card">
|
||||||
<div class="module-icon">📋</div>
|
<div class="module-icon">📊</div> <h3 class="module-name">Counts</h3>
|
||||||
<h3 class="module-name">Counts</h3>
|
|
||||||
<p class="module-desc">Cycle counts & physical inventory</p>
|
<p class="module-desc">Cycle counts & physical inventory</p>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="module-card module-card-link">
|
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="module-card module-card-link">
|
||||||
<div class="module-icon">📝</div>
|
<div class="module-icon">📝</div> <h3 class="module-name">Consumption Sheets</h3>
|
||||||
<h3 class="module-name">Consumption Sheets</h3>
|
|
||||||
<p class="module-desc">Production consumption tracking</p>
|
<p class="module-desc">Production consumption tracking</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if sessions %}
|
|
||||||
<div class="sessions-grid">
|
|
||||||
{% for session in sessions %}
|
|
||||||
<div class="session-card {% if session.status == 'archived' %}session-archived{% endif %}">
|
|
||||||
<div class="session-card-header">
|
|
||||||
<h3 class="session-name">
|
|
||||||
{{ session.session_name }}
|
|
||||||
{% if session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
|
||||||
</h3>
|
|
||||||
<span class="session-type-badge session-type-{{ session.session_type }}">
|
|
||||||
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ session.total_locations or 0 }}</div>
|
|
||||||
<div class="stat-label">Total Locations</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ session.completed_locations or 0 }}</div>
|
|
||||||
<div class="stat-label">Completed</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-value">{{ session.in_progress_locations or 0 }}</div>
|
|
||||||
<div class="stat-label">In Progress</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-meta">
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="meta-label">Created:</span>
|
|
||||||
<span class="meta-value">{{ session.created_timestamp[:16] }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="meta-label">By:</span>
|
|
||||||
<span class="meta-value">{{ session.created_by_name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-actions">
|
|
||||||
<a href="{{ url_for('sessions.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
|
|
||||||
View Details
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-icon">📋</div>
|
|
||||||
<h2 class="empty-title">No Active Sessions</h2>
|
|
||||||
<p class="empty-text">Create a new count session to get started</p>
|
|
||||||
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
|
||||||
Create First Session
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,18 +4,18 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<!-- Back to Admin Dashboard -->
|
<div class="dashboard-header" style="margin-top: var(--space-lg);">
|
||||||
<div class="mode-selector">
|
<div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);">
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary btn-sm">
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary btn-sm">
|
||||||
<i class="fa-solid fa-arrow-left"></i> Back to Admin
|
<i class="fa-solid fa-arrow-left"></i> Back to Admin
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dashboard-header">
|
<div>
|
||||||
<div class="header-left">
|
<h1 class="page-title" style="margin-bottom: 0;">Consumption Sheets</h1>
|
||||||
<h1 class="page-title">Consumption Sheets</h1>
|
<p class="page-subtitle" style="margin-bottom: 0;">Manage process types and templates</p>
|
||||||
<p class="page-subtitle">Manage process types and templates</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary">
|
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary">
|
||||||
<span class="btn-icon">+</span> New Process
|
<span class="btn-icon">+</span> New Process
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -49,7 +49,13 @@
|
|||||||
<td>{{ field.excel_cell or '—' }}</td>
|
<td>{{ field.excel_cell or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
|
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
|
||||||
<button onclick="confirmDelete({{ field.id }}, '{{ field.field_label }}')" class="btn btn-sm" style="background: var(--color-danger); color: white;">Delete</button>
|
<button onclick="confirmDelete(this)"
|
||||||
|
data-id="{{ field.id }}"
|
||||||
|
data-label="{{ field.field_label }}"
|
||||||
|
class="btn btn-sm"
|
||||||
|
style="background: var(--color-danger); color: white;">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -95,7 +101,13 @@
|
|||||||
<td>{{ field.excel_cell or '—' }}</td>
|
<td>{{ field.excel_cell or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
|
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
|
||||||
<button onclick="confirmDelete({{ field.id }}, '{{ field.field_label }}')" class="btn btn-sm" style="background: var(--color-danger); color: white;">Delete</button>
|
<button onclick="confirmDelete(this)"
|
||||||
|
data-id="{{ field.id }}"
|
||||||
|
data-label="{{ field.field_label }}"
|
||||||
|
class="btn btn-sm"
|
||||||
|
style="background: var(--color-danger); color: white;">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -138,7 +150,11 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function confirmDelete(fieldId, fieldLabel) {
|
function confirmDelete(btn) {
|
||||||
|
// Read values from data attributes
|
||||||
|
const fieldId = btn.dataset.id;
|
||||||
|
const fieldLabel = btn.dataset.label;
|
||||||
|
|
||||||
if (confirm('Delete field "' + fieldLabel + '"?\n\nThis will soft-delete the field (data preserved but hidden).')) {
|
if (confirm('Delete field "' + fieldLabel + '"?\n\nThis will soft-delete the field (data preserved but hidden).')) {
|
||||||
fetch('{{ url_for("cons_sheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), {
|
fetch('{{ url_for("cons_sheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
|
|||||||
@@ -101,9 +101,11 @@
|
|||||||
<div class="scans-header">
|
<div class="scans-header">
|
||||||
<h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3>
|
<h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div id="scansList" class="scans-grid">
|
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
|
||||||
{% for scan in scans %}
|
{% for scan in scans %}
|
||||||
<div class="scan-row scan-row-{{ scan.duplicate_status }}" data-detail-id="{{ scan.id }}" onclick="openScanDetail({{ scan.id }})">
|
<div class="scan-row scan-row-{{ scan.duplicate_status }}"
|
||||||
|
data-detail-id="{{ scan.id }}"
|
||||||
|
onclick="openScanDetail(this.dataset.detailId)">
|
||||||
{% for field in detail_fields %}
|
{% for field in detail_fields %}
|
||||||
<div class="scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
|
<div class="scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -139,7 +141,7 @@
|
|||||||
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
|
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
|
||||||
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
|
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
.header-pill strong { color: var(--color-text); }
|
.header-pill strong { color: var(--color-text); }
|
||||||
.scan-row { display: grid; grid-template-columns: repeat({{ detail_fields|length }}, 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
|
.scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
|
||||||
.scan-row:hover { border-color: var(--color-primary); }
|
.scan-row:hover { border-color: var(--color-primary); }
|
||||||
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
|
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
|
||||||
@@ -151,11 +153,22 @@
|
|||||||
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
|
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script id="session-data" type="application/json">
|
||||||
const detailFields = {{ detail_fields|tojson|safe }};
|
{
|
||||||
const dupKeyFieldName = {{ (dup_key_field.field_name if dup_key_field else '')|tojson|safe }};
|
"detailFields": {{ detail_fields|tojson|safe }},
|
||||||
const sessionId = {{ session.id }};
|
"dupKeyFieldName": {{ (dup_key_field.field_name if dup_key_field else '')|tojson|safe }},
|
||||||
|
"sessionId": {{ session.id }}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Read data from the JSON block above
|
||||||
|
const sessionData = JSON.parse(document.getElementById('session-data').textContent);
|
||||||
|
const detailFields = sessionData.detailFields;
|
||||||
|
const dupKeyFieldName = sessionData.dupKeyFieldName;
|
||||||
|
const sessionId = sessionData.sessionId;
|
||||||
|
|
||||||
|
// Standard variables
|
||||||
let currentDupKeyValue = '';
|
let currentDupKeyValue = '';
|
||||||
let currentDuplicateStatus = '';
|
let currentDuplicateStatus = '';
|
||||||
let isDuplicateConfirmed = false;
|
let isDuplicateConfirmed = false;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<span class="arrow-icon">→</span>
|
<span class="arrow-icon">→</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
|
<button class="btn-archive" onclick="archiveSession(this)" data-id="{{ s.id }}" data-name="{{ s.process_name }}" title="Archive this session">
|
||||||
🗑️
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
102
templates/counts/admin_dashboard.html
Normal file
102
templates/counts/admin_dashboard.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Inventory Counts - ScanLook{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary btn-sm" style="margin-right: var(--space-md);">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Back to Admin
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Inventory Counts</h1>
|
||||||
|
<p class="page-subtitle">Manage cycle counts and physical inventory</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<label class="filter-toggle" style="margin-right: var(--space-lg);">
|
||||||
|
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
|
||||||
|
<span class="filter-label">Show Archived</span>
|
||||||
|
</label>
|
||||||
|
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
||||||
|
<span class="btn-icon">+</span> New Session
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sessions %}
|
||||||
|
<div class="sessions-grid">
|
||||||
|
{% for session in sessions %}
|
||||||
|
<div class="session-card {% if session.status == 'archived' %}session-archived{% endif %}">
|
||||||
|
<div class="session-card-header">
|
||||||
|
<h3 class="session-name">
|
||||||
|
{{ session.session_name }}
|
||||||
|
{% if session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
||||||
|
</h3>
|
||||||
|
<span class="session-type-badge session-type-{{ session.session_type }}">
|
||||||
|
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ session.total_locations or 0 }}</div>
|
||||||
|
<div class="stat-label">Total Locations</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ session.completed_locations or 0 }}</div>
|
||||||
|
<div class="stat-label">Completed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ session.in_progress_locations or 0 }}</div>
|
||||||
|
<div class="stat-label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Created:</span>
|
||||||
|
<span class="meta-value">{{ session.created_timestamp[:16] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">By:</span>
|
||||||
|
<span class="meta-value">{{ session.created_by_name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-actions">
|
||||||
|
<a href="{{ url_for('sessions.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<h2 class="empty-title">No Active Sessions</h2>
|
||||||
|
<p class="empty-text">Create a new count session to get started</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleArchived() {
|
||||||
|
const checked = document.getElementById('showArchived').checked;
|
||||||
|
window.location.href = '{{ url_for("counting.admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; }
|
||||||
|
.header-right { display: flex; align-items: center; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user