7 Commits

Author SHA1 Message Date
Javier
2d333c16a3 v0.14.0 - Major Logic Overhaul & Real-Time Dashboard
Logic: Implemented "One User, One Bin" locking to prevent duplicate counting.

    Integrity: Standardized is_deleted = 0 and tightened "Matched" criteria to require zero weight variance.

    Refresh: Added silent 30-second dashboard polling for all 6 status categories and active counter list.

    Tracking: Built user-specific activity tracking to identify who is counting where in real-time.

    Stability: Resolved persistent 500 errors by finalizing the active-counters-fragment structure.
2026-01-31 19:17:36 -06:00
Javier
288b390618 Merge branch 'refactor/counts-dashboard' 2026-01-30 10:43:51 -06:00
Javier
fcdef6875e Stop tracking compiled python files 2026-01-30 10:41:34 -06:00
Javier
a6d8767c28 Refactor Admin Dashboard and organize Counts module 2026-01-30 10:30:17 -06:00
Javier
d6e9f20757 Update: Fixed some vscode "Problems" that weren't really problems but it was complaining about jinja code. 2026-01-30 00:39:26 -06:00
Javier
674a8f8a0c updated ai prompt 2026-01-30 00:19:54 -06:00
Javier
5097ceb82f v0.13.0 - Add Consumption Sheets module and database migration system 2026-01-29 22:12:55 -06:00
23 changed files with 911 additions and 273 deletions

View File

@@ -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.
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.
9) **Database changes:** The app auto-initializes the database if it doesn't exist. Schema is in /database/init_db.py.
10) **Docker deployment:** Production runs in Docker on Linux (jisoo). Volume mounts only /app/database to preserve data between updates.
9) **Docker deployment:** Production runs in Docker on Linux (PortainerVM). 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
- Start by confirming which mode were 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 is a web app for warehouse counting workflows built with Flask + SQLite.
**Current Version:** 0.12.0
**Current Version:** 0.13.0
**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)
- Frontend: Jinja2 templates, vanilla JS, custom CSS
- 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)
- db.py (database helper functions: query_db, execute_db)
- utils.py (decorators: login_required, role_required)
- migrations.py (database migration system)
**Key Features (implemented):**
- 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)
- Role-based access: owner, admin, staff
- 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:**
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.
**Module System (v0.12.0):**
**Module System (v0.13.0):**
- Modules table defines available modules (module_key used for routing)
- UserModules table tracks per-user 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)
- routes.py (all routes)
- templates/ (module-specific templates)
- Current modules:
- Inventory Counts (counting)
- Consumption Sheets (cons_sheets)
## Quick Reference

22
app.py
View File

@@ -38,7 +38,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version
APP_VERSION = '0.13.0'
APP_VERSION = '0.14.0'
# 2. Inject it into all templates automatically
@app.context_processor
@@ -54,6 +54,10 @@ if not os.path.exists(db_path):
create_default_users()
print("Database initialized!")
# Run migrations to apply any pending database changes
from migrations import run_migrations
run_migrations()
# ==================== 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 ====================
@app.route('/manifest.json')

View File

@@ -29,35 +29,6 @@ def reopen_location(location_count_id):
return jsonify({'success': True, 'message': 'Bin reopened for counting'})
@admin_locations_bp.route('/location/<int:location_count_id>/delete', methods=['POST'])
@login_required
def delete_location_count(location_count_id):
"""Delete all counts for a location (soft delete)"""
# Verify ownership
loc = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', [location_count_id], one=True)
if not loc:
return jsonify({'success': False, 'message': 'Location not found'})
if loc['counted_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Soft delete all scan entries for this location
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# Delete the location count record
execute_db('''
DELETE FROM LocationCounts
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({'success': True, 'message': 'Bin count deleted'})
@admin_locations_bp.route('/location/<int:location_count_id>/scans')
@login_required
def get_location_scans(location_count_id):
@@ -87,3 +58,39 @@ def get_location_scans(location_count_id):
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@admin_locations_bp.route('/location/<int:location_count_id>/delete', methods=['POST'])
@login_required
def soft_delete_location(location_count_id):
"""Admin-only: Soft delete a bin count and its associated data"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Admin role required'}), 403
# 1. Verify location exists
loc = query_db('SELECT session_id, location_name FROM LocationCounts WHERE location_count_id = ?',
[location_count_id], one=True)
if not loc:
return jsonify({'success': False, 'message': 'Location not found'})
# 2. Soft delete the bin count itself
execute_db('''
UPDATE LocationCounts
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# 3. Soft delete all scans in that bin
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# 4. Remove any MissingLots records generated for this bin
execute_db('''
DELETE FROM MissingLots
WHERE session_id = ? AND master_expected_location = ?
''', [loc['session_id'], loc['location_name']])
return jsonify({'success': True, 'message': 'Bin count and associated data soft-deleted'})

View File

@@ -10,6 +10,50 @@ def get_active_session(session_id):
return None
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')
@login_required
def index():
@@ -33,7 +77,7 @@ def index():
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>')
@@ -72,11 +116,18 @@ def my_counts(session_id):
FROM LocationCounts lc
LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0
WHERE lc.session_id = ?
AND lc.counted_by = ?
AND lc.status = 'in_progress'
AND lc.is_deleted = 0
AND (
lc.counted_by = ?
OR lc.location_count_id IN (
SELECT location_count_id FROM ScanEntries
WHERE scanned_by = ? AND is_deleted = 0
)
)
GROUP BY lc.location_count_id
ORDER BY lc.start_timestamp DESC
''', [session_id, session['user_id']])
''', [session_id, session['user_id'], session['user_id']])
# Get this user's completed bins
completed_bins = query_db('''
@@ -85,13 +136,19 @@ def my_counts(session_id):
FROM LocationCounts lc
LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0
WHERE lc.session_id = ?
AND lc.counted_by = ?
AND lc.status = 'completed'
AND (
lc.counted_by = ?
OR lc.location_count_id IN (
SELECT location_count_id FROM ScanEntries
WHERE scanned_by = ? AND is_deleted = 0
)
)
GROUP BY lc.location_count_id
ORDER BY lc.end_timestamp DESC
''', [session_id, session['user_id']])
ORDER BY lc.start_timestamp DESC
''', [session_id, session['user_id'], session['user_id']])
return render_template('my_counts.html',
return render_template('counts/my_counts.html',
count_session=sess,
active_bins=active_bins,
completed_bins=completed_bins)
@@ -100,7 +157,7 @@ def my_counts(session_id):
@counting_bp.route('/session/<int:session_id>/start-bin', methods=['POST'])
@login_required
def start_bin_count(session_id):
"""Start counting a new bin"""
"""Start counting a new bin or resume an existing in-progress one"""
sess = get_active_session(session_id)
if not sess:
flash('Session not found or archived', 'warning')
@@ -115,6 +172,20 @@ def start_bin_count(session_id):
flash('Bin number is required', 'danger')
return redirect(url_for('counting.my_counts', session_id=session_id))
# --- NEW LOGIC: Check for existing in-progress bin ---
existing_bin = query_db('''
SELECT location_count_id
FROM LocationCounts
WHERE session_id = ? AND location_name = ? AND status = 'in_progress'
''', [session_id, location_name], one=True)
if existing_bin:
flash(f'Resuming bin: {location_name}', 'info')
return redirect(url_for('counting.count_location',
session_id=session_id,
location_count_id=existing_bin['location_count_id']))
# --- END NEW LOGIC ---
# Count expected lots from MASTER baseline for this location
expected_lots = query_db('''
SELECT COUNT(DISTINCT lot_number) as count
@@ -124,7 +195,7 @@ def start_bin_count(session_id):
expected_count = expected_lots['count'] if expected_lots else 0
# Create new location count
# Create new location count if none existed
conn = get_db()
cursor = conn.cursor()
@@ -140,7 +211,6 @@ def start_bin_count(session_id):
flash(f'Started counting bin: {location_name}', 'success')
return redirect(url_for('counting.count_location', session_id=session_id, location_count_id=location_count_id))
@counting_bp.route('/location/<int:location_count_id>/complete', methods=['POST'])
@login_required
def complete_location(location_count_id):
@@ -219,7 +289,7 @@ def count_location(session_id, location_count_id):
ORDER BY lot_number
''', [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,
location=location,
scans=scans,
@@ -468,7 +538,7 @@ def scan_lot(session_id, location_count_id):
def delete_scan(entry_id):
"""Soft delete a scan and recalculate duplicate statuses"""
# Get the scan being deleted
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ?', [entry_id], one=True)
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True)
if not scan:
return jsonify({'success': False, 'message': 'Scan not found'})
@@ -528,7 +598,7 @@ def update_scan(entry_id):
comment = data.get('comment', '')
# Get the scan
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ?', [entry_id], one=True)
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True)
if not scan:
return jsonify({'success': False, 'message': 'Scan not found'})
@@ -549,7 +619,7 @@ def update_scan(entry_id):
actual_weight = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [item, weight, comment, entry_id])
return jsonify({'success': True, 'message': 'Scan updated'})
@@ -581,7 +651,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = NULL,
comment = NULL,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [scan['entry_id']])
updated_entries.append({
'entry_id': scan['entry_id'],
@@ -626,7 +696,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, scan['entry_id']])
# Update our tracking list
@@ -645,7 +715,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, prev_scan['entry_id']])
# Update tracking for previous scans
@@ -711,3 +781,56 @@ def finish_location(session_id, location_count_id):
'success': True,
'redirect': url_for('counting.count_session', session_id=session_id)
})
@counting_bp.route('/session/<int:session_id>/finalize-all', methods=['POST'])
@login_required
def finalize_all_locations(session_id):
"""Finalize all 'in_progress' locations in a session"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'}), 403
# 1. Get all in_progress locations for this session
locations = query_db('''
SELECT location_count_id, location_name
FROM LocationCounts
WHERE session_id = ?
AND status = 'in_progress'
AND is_deleted = 0
''', [session_id])
if not locations:
return jsonify({'success': True, 'message': 'No open bins to finalize.'})
# 2. Loop through and run the finalize logic for each
for loc in locations:
# We reuse the logic from your existing finish_location route
execute_db('''
UPDATE LocationCounts
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
WHERE location_count_id = ?
''', [loc['location_count_id']])
# Identify missing lots from MASTER baseline
expected_lots = query_db('''
SELECT lot_number, item, description, system_quantity
FROM BaselineInventory_Master
WHERE session_id = ? AND system_bin = ?
''', [session_id, loc['location_name']])
scanned_lots = query_db('''
SELECT DISTINCT lot_number
FROM ScanEntries
WHERE location_count_id = ? AND is_deleted = 0
''', [loc['location_count_id']])
scanned_lot_numbers = {s['lot_number'] for s in scanned_lots}
for expected in expected_lots:
if expected['lot_number'] not in scanned_lot_numbers:
execute_db('''
INSERT INTO MissingLots (session_id, lot_number, master_expected_location, item, master_expected_quantity, marked_by)
VALUES (?, ?, ?, ?, ?, ?)
''', [session_id, expected['lot_number'], loc['location_name'],
expected['item'], expected['system_quantity'], session['user_id']])
return jsonify({'success': True, 'message': f'Successfully finalized {len(locations)} bins.'})

View File

@@ -24,7 +24,7 @@ def create_session():
flash(f'Session "{session_name}" created successfully!', 'success')
return redirect(url_for('sessions.session_detail', session_id=session_id))
return render_template('create_session.html')
return render_template('/counts/create_session.html')
@sessions_bp.route('/session/<int:session_id>')
@@ -54,24 +54,38 @@ def session_detail(session_id):
''', [session_id], one=True)
# Get location progress
# We add a subquery to count the actual missing lots for each bin
locations = query_db('''
SELECT lc.*, u.full_name as counter_name
SELECT
lc.*,
u.full_name as counter_name,
(SELECT COUNT(*) FROM MissingLots ml
WHERE ml.session_id = lc.session_id
AND ml.master_expected_location = lc.location_name) as lots_missing_calc
FROM LocationCounts lc
LEFT JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ?
AND lc.is_deleted = 0
ORDER BY lc.status DESC, lc.location_name
''', [session_id])
# Get active counters
active_counters = query_db('''
SELECT DISTINCT u.full_name, lc.location_name, lc.start_timestamp
SELECT
u.full_name,
u.user_id,
MAX(lc.start_timestamp) AS start_timestamp, -- Add the alias here!
lc.location_name
FROM LocationCounts lc
JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ? AND lc.status = 'in_progress'
ORDER BY lc.start_timestamp DESC
WHERE lc.session_id = ?
AND lc.status = 'in_progress'
AND lc.is_deleted = 0
GROUP BY u.user_id
ORDER BY start_timestamp DESC
''', [session_id])
return render_template('session_detail.html',
return render_template('/counts/session_detail.html',
count_session=sess,
stats=stats,
locations=locations,
@@ -98,6 +112,7 @@ def get_status_details(session_id, status):
WHERE se.session_id = ?
AND se.master_status = 'match'
AND se.duplicate_status = '00'
AND se.master_variance_lbs = 0
AND se.is_deleted = 0
ORDER BY se.scan_timestamp DESC
''', [session_id])
@@ -184,20 +199,21 @@ def get_status_details(session_id, status):
# Missing lots (in master but not scanned)
items = query_db('''
SELECT
bim.lot_number,
bim.item,
ml.lot_number,
ml.item,
bim.description,
bim.system_bin,
bim.system_quantity
FROM BaselineInventory_Master bim
WHERE bim.session_id = ?
AND bim.lot_number NOT IN (
SELECT lot_number
FROM ScanEntries
WHERE session_id = ? AND is_deleted = 0
)
ORDER BY bim.system_bin, bim.lot_number
''', [session_id, session_id])
ml.master_expected_location as system_bin,
ml.master_expected_quantity as system_quantity
FROM MissingLots ml
LEFT JOIN BaselineInventory_Master bim ON
ml.lot_number = bim.lot_number AND
ml.item = bim.item AND
ml.master_expected_location = bim.system_bin AND
ml.session_id = bim.session_id
WHERE ml.session_id = ?
GROUP BY ml.lot_number, ml.item, ml.master_expected_location
ORDER BY ml.master_expected_location, ml.lot_number
''', [session_id])
else:
return jsonify({'success': False, 'message': 'Invalid status'})
@@ -242,3 +258,39 @@ def activate_session(session_id):
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['active', session_id])
return jsonify({'success': True, 'message': 'Session activated successfully'})
@sessions_bp.route('/session/<int:session_id>/get_stats')
@role_required('owner', 'admin')
def get_session_stats(session_id):
stats = query_db('''
SELECT
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.master_variance_lbs = 0 AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) < 0.01) as matched,
COUNT(DISTINCT se.lot_number) FILTER (WHERE se.duplicate_status IN ('01', '03', '04') AND se.is_deleted = 0) as duplicates,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01) as discrepancy,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'wrong_location' AND se.is_deleted = 0) as wrong_location,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'ghost_lot' AND se.is_deleted = 0) as ghost_lots,
COUNT(DISTINCT ml.missing_id) as missing
FROM CountSessions cs
LEFT JOIN ScanEntries se ON cs.session_id = se.session_id
LEFT JOIN MissingLots ml ON cs.session_id = ml.session_id
WHERE cs.session_id = ?
''', [session_id], one=True)
return jsonify(success=True, stats=dict(stats))
@sessions_bp.route('/session/<int:session_id>/active-counters-fragment')
@role_required('owner', 'admin')
def active_counters_fragment(session_id):
# Use that unique-user query we just built together
active_counters = query_db('''
SELECT
u.full_name,
MAX(lc.start_timestamp) AS start_timestamp,
lc.location_name
FROM LocationCounts lc
JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ? AND lc.status = 'in_progress' AND lc.is_deleted = 0
GROUP BY u.user_id
''', [session_id])
# This renders JUST the list part, not the whole page
return render_template('counts/partials/_active_counters.html', active_counters=active_counters)

View File

@@ -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
# ============================================
@@ -295,6 +327,52 @@ def create_default_users():
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__':
init_database()
create_default_users()
create_default_modules()

257
migrations.py Normal file
View File

@@ -0,0 +1,257 @@
"""
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()
def migration_006_add_is_deleted_to_locationcounts():
"""Add is_deleted column to LocationCounts table"""
conn = get_db()
if table_exists('LocationCounts'):
if not column_exists('LocationCounts', 'is_deleted'):
conn.execute('ALTER TABLE LocationCounts ADD COLUMN is_deleted INTEGER DEFAULT 0')
print(" Added is_deleted column to LocationCounts")
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),
(6, 'add_is_deleted_to_locationcounts', migration_006_add_is_deleted_to_locationcounts),
]
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()

View File

@@ -4,121 +4,28 @@
{% block content %}
<div class="dashboard-container">
<!-- Mode Selector -->
<div class="mode-selector">
<div class="dashboard-header" style="margin-top: var(--space-lg);">
<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">
<i class="fa-solid fa-arrow-left"></i> Back to Home
</a>
<button class="mode-btn mode-btn-active" data-href="{{ url_for('admin_dashboard') }}">
👔 Admin Console
</button>
<button class="mode-btn" data-href="{{ url_for('staff_mode') }}">
📦 Scanning Mode
</button>
<h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1>
</div>
</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>
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session
</a>
</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">
<h2 class="section-title">Modules</h2>
<div class="modules-grid">
<div class="module-card module-card-active">
<div class="module-icon">📋</div>
<h3 class="module-name">Counts</h3>
<a href="{{ url_for('counting.admin_dashboard') }}" class="module-card">
<div class="module-icon">📊</div> <h3 class="module-name">Counts</h3>
<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">
<div class="module-icon">📝</div>
<h3 class="module-name">Consumption Sheets</h3>
<div class="module-icon">📝</div> <h3 class="module-name">Consumption Sheets</h3>
<p class="module-desc">Production consumption tracking</p>
</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>
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
Create First Session
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -4,18 +4,18 @@
{% block content %}
<div class="dashboard-container">
<!-- Back to Admin Dashboard -->
<div class="mode-selector">
<div class="dashboard-header" style="margin-top: var(--space-lg);">
<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">
<i class="fa-solid fa-arrow-left"></i> Back to Admin
</a>
<div>
<h1 class="page-title" style="margin-bottom: 0;">Consumption Sheets</h1>
<p class="page-subtitle" style="margin-bottom: 0;">Manage process types and templates</p>
</div>
</div>
<div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">Consumption Sheets</h1>
<p class="page-subtitle">Manage process types and templates</p>
</div>
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Process
</a>

View File

@@ -49,7 +49,13 @@
<td>{{ field.excel_cell or '—' }}</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>
<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>
</tr>
{% endfor %}
@@ -95,7 +101,13 @@
<td>{{ field.excel_cell or '—' }}</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>
<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>
</tr>
{% endfor %}
@@ -138,7 +150,11 @@
</style>
<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).')) {
fetch('{{ url_for("cons_sheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), {
method: 'POST'

View File

@@ -101,9 +101,11 @@
<div class="scans-header">
<h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3>
</div>
<div id="scansList" class="scans-grid">
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% 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 %}
<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 %}
@@ -139,7 +141,7 @@
.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 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-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; }
@@ -151,11 +153,22 @@
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
</style>
<script>
const detailFields = {{ detail_fields|tojson|safe }};
const dupKeyFieldName = {{ (dup_key_field.field_name if dup_key_field else '')|tojson|safe }};
const sessionId = {{ session.id }};
<script id="session-data" type="application/json">
{
"detailFields": {{ detail_fields|tojson|safe }},
"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 currentDuplicateStatus = '';
let isDuplicateConfirmed = false;

View File

@@ -47,7 +47,7 @@
<span class="arrow-icon"></span>
</div>
</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>
</div>

View 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 %}

View File

@@ -163,9 +163,7 @@
<a href="{{ url_for('counting.my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
← Back to My Counts
</a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
✓ Finish Location
</button>
{# Finish button moved to Admin Dashboard #}
</div>
</div>
<button class="scroll-to-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">

View File

@@ -49,14 +49,6 @@
<a href="{{ url_for('counting.count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
Resume Counting
</a>
<div class="bin-actions-row">
<button class="btn btn-secondary" onclick="markComplete('{{ bin.location_count_id }}')">
✓ Mark Complete
</button>
<button class="btn btn-danger" onclick="deleteBinCount('{{ bin.location_count_id }}', '{{ bin.location_name }}')">
🗑️ Delete
</button>
</div>
</div>
</div>
{% endfor %}

View File

@@ -0,0 +1,12 @@
<div class="counter-list">
{% for counter in active_counters %}
<div class="counter-item">
<div class="counter-avatar">{{ counter.full_name[0] }}</div>
<div class="counter-info">
<div class="counter-name">{{ counter.full_name }}</div>
<div class="counter-location">📍 {{ counter.location_name }}</div>
</div>
<div class="counter-time">{{ counter.start_timestamp[11:16] }}</div>
</div>
{% endfor %}
</div>

View File

@@ -77,27 +77,27 @@
<div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match')">
<div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-number" id="count-matched">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div>
</div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates')">
<div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-number" id="count-duplicates">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div>
</div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy')">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-number" id="count-discrepancy">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div>
</div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location')">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-number" id="count-wrong">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div>
</div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot')">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-number" id="count-ghost">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div>
</div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing')">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-number" id="count-missing">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div>
</div>
</div>
@@ -107,7 +107,7 @@
{% if active_counters %}
<div class="section-card">
<h2 class="section-title">Active Counters</h2>
<div class="counter-list">
<div id="active-counters-container"> <div class="counter-list">
{% for counter in active_counters %}
<div class="counter-item">
<div class="counter-avatar">{{ counter.full_name[0] }}</div>
@@ -125,7 +125,12 @@
<!-- Location Progress Section -->
{% if locations %}
<div class="section-card">
<h2 class="section-title">Location Progress</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h2 class="section-title" style="margin-bottom: 0;">Location Progress</h2>
<button class="btn btn-danger btn-sm" onclick="showFinalizeAllConfirm()">
⚠️ Finalize All Bins
</button>
</div>
<div class="location-table">
<table>
<thead>
@@ -157,7 +162,7 @@
<td>{{ loc.counter_name or '-' }}</td>
<td>{{ loc.expected_lots_master }}</td>
<td>{{ loc.lots_found }}</td>
<td>{{ loc.lots_missing }}</td>
<td>{{ loc.lots_missing_calc }}</td>
</tr>
{% endfor %}
</tbody>
@@ -198,6 +203,9 @@
<button id="reopenLocationBtn" class="btn btn-warning btn-sm" style="display: none;" onclick="showReopenConfirm()">
🔓 Reopen Location
</button>
<button id="deleteLocationBtn" class="btn btn-danger btn-sm" style="display: none;" onclick="showDeleteBinConfirm()">
🗑️ Delete Bin
</button>
<button class="btn btn-secondary btn-sm" onclick="exportLocationToCSV()">
📥 Export CSV
</button>
@@ -460,6 +468,9 @@ function showLocationDetails(locationCountId, locationName, status) {
// Show finalize or reopen button based on status
const finalizeBtn = document.getElementById('finalizeLocationBtn');
const reopenBtn = document.getElementById('reopenLocationBtn');
const deleteBtn = document.getElementById('deleteLocationBtn'); // ADD THIS LINE
deleteBtn.style.display = 'block';
if (status === 'in_progress') {
finalizeBtn.style.display = 'block';
@@ -621,8 +632,8 @@ function closeFinalizeConfirm() {
}
function confirmFinalize() {
// Note: The /complete endpoint is handled by blueprints/counting.py
fetch(`/location/${currentLocationId}/complete`, {
// Correctly points to the /finish route to trigger Missing Lot calculations
fetch(`/count/${CURRENT_SESSION_ID}/location/${currentLocationId}/finish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -633,16 +644,18 @@ function confirmFinalize() {
if (data.success) {
closeFinalizeConfirm();
closeLocationModal();
location.reload(); // Reload to show updated status
location.reload(); // Reload to show updated status and Missing counts
} else {
alert(data.message || 'Error finalizing location');
}
})
.catch(error => {
console.error('Finalize Error:', error);
alert('Error: ' + error.message);
});
}
function showReopenConfirm() {
document.getElementById('reopenBinName').textContent = currentLocationName;
document.getElementById('reopenConfirmModal').style.display = 'flex';
@@ -717,5 +730,78 @@ function activateSession() {
}
});
}
function showFinalizeAllConfirm() {
if (confirm("⚠️ WARNING: This will finalize ALL open bins in this session and calculate missing items. This cannot be undone. Are you sure?")) {
fetch(`/session/${CURRENT_SESSION_ID}/finalize-all`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert("Error: " + data.message);
}
});
}
}
function showDeleteBinConfirm() {
if (confirm(`⚠️ DANGER: Are you sure you want to delete ALL data for ${currentLocationName}? This will hide the bin from staff and wipe any missing lot flags.`)) {
fetch(`/location/${currentLocationId}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeLocationModal();
location.reload();
} else {
alert(data.message || 'Error deleting bin');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
}
function refreshDashboardStats() {
const sessionId = CURRENT_SESSION_ID;
fetch(`/session/${sessionId}/get_stats`)
.then(response => response.json())
.then(data => {
if (data.success) {
const s = data.stats;
// These IDs must match your HTML and the keys must match sessions.py
if (document.getElementById('count-matched')) document.getElementById('count-matched').innerText = s.matched;
if (document.getElementById('count-duplicates')) document.getElementById('count-duplicates').innerText = s.duplicates;
if (document.getElementById('count-discrepancy')) document.getElementById('count-discrepancy').innerText = s.discrepancy;
if (document.getElementById('count-wrong')) document.getElementById('count-wrong').innerText = s.wrong_location; // Fixed
if (document.getElementById('count-ghost')) document.getElementById('count-ghost').innerText = s.ghost_lots; // Fixed
if (document.getElementById('count-missing')) document.getElementById('count-missing').innerText = s.missing;
}
})
.catch(err => console.error('Error refreshing stats:', err));
fetch(`/session/${sessionId}/active-counters-fragment`)
.then(response => response.text())
.then(html => {
const container = document.getElementById('active-counters-container');
if (container) container.innerHTML = html;
})
.catch(err => console.error('Error refreshing counters:', err));
}
// This tells the browser: "Run the refresh function every 30 seconds"
setInterval(refreshDashboardStats, 30000);
// This runs it IMMEDIATELY once so you don't wait 30 seconds for the first update
refreshDashboardStats();
</script>
{% endblock %}