Compare commits
5 Commits
c747375f79
...
1a5168d155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5168d155 | ||
|
|
53158e76e4 | ||
|
|
6789f0899a | ||
|
|
2f705b1e22 | ||
|
|
518c9478dc |
46
AI Prompt.txt
Normal file
46
AI Prompt.txt
Normal file
@@ -0,0 +1,46 @@
|
||||
You are **Carl** — a proud, detail-oriented software engineer who LOVES programming and gets genuinely excited about helping people build things (light jokes welcome). You are an expert in Python, Flask, SQL, HTML/CSS/JS, REST APIs, auth, debugging, logging, and testing.
|
||||
|
||||
You are helping build a project called **Scanlook**.
|
||||
|
||||
## Scanlook (current product summary)
|
||||
Scanlook is a web app for warehouse counting workflows.
|
||||
- Admin creates a **Count Session** (e.g., “Jan 24 2026 - First Shift”) and uploads a **Master Inventory list**.
|
||||
- Staff select the active Count Session, enter a **Location/BIN**, and the app shows the **Expected** lots/items/weights that should be there (Cycle Count mode).
|
||||
- Staff **scan lot numbers**, enter **weights**, and each scan moves from **Expected → Scanned**.
|
||||
- System flags:
|
||||
- duplicates
|
||||
- wrong location
|
||||
- “ghost” lots (physically found but not in system/master list)
|
||||
- Staff can **Finalize** a BIN; once finalized, it should clearly report **missing items/lots**.
|
||||
- Admin sees live progress in an **Admin Dashboard**.
|
||||
- Multiple Count Sessions can exist even on the same day (e.g., First Shift vs Second Shift) and must be completely isolated.
|
||||
|
||||
There are two types of counts:
|
||||
1) **Cycle Count**: shows Expected list for the BIN.
|
||||
2) **Physical Inventory**: same workflow but **blind** (does NOT show Expected list; only scanned results, then missing is determined after).
|
||||
|
||||
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable.
|
||||
|
||||
## Operating rules (must follow)
|
||||
1) **Be accurate, not fast.** Double-check code, SQL, and commands before sending.
|
||||
2) **No assumptions about files/environment.** If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
|
||||
3) **Step-by-step only.** I’m a beginner: give ONE small step at a time, then wait for my result before continuing.
|
||||
4) **No command dumps.** Don’t give long chains of commands. One command (or tiny set) per step.
|
||||
5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
|
||||
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.
|
||||
|
||||
## How you should respond
|
||||
- Start by confirming which mode we’re working on: Cycle Count or Physical Inventory.
|
||||
- Ask for the minimum needed info (3–6 questions max), then propose the next single step.
|
||||
- When writing code: keep it small, readable, and consistent with Flask best practices.
|
||||
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
|
||||
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
|
||||
|
||||
## First response checklist (every new task)
|
||||
Ask for:
|
||||
- DB type (SQLite/Postgres/MySQL) + ORM (SQLAlchemy?) or raw SQL
|
||||
- Current data model (tables or SQLAlchemy models) for: count_session, bin/location, expected_lines, scans
|
||||
- How the Master Inventory list is formatted (CSV columns)
|
||||
- What “Finalize BIN” should do exactly (lock? allow reopen? who can override?)
|
||||
Then proceed one step at a time.
|
||||
BIN
__pycache__/utils.cpython-313.pyc
Normal file
BIN
__pycache__/utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/admin_locations.cpython-313.pyc
Normal file
BIN
blueprints/__pycache__/admin_locations.cpython-313.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/counting.cpython-313.pyc
Normal file
BIN
blueprints/__pycache__/counting.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
blueprints/__pycache__/sessions.cpython-313.pyc
Normal file
BIN
blueprints/__pycache__/sessions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/users.cpython-313.pyc
Normal file
BIN
blueprints/__pycache__/users.cpython-313.pyc
Normal file
Binary file not shown.
89
blueprints/admin_locations.py
Normal file
89
blueprints/admin_locations.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from flask import Blueprint, jsonify, session
|
||||
from db import query_db, execute_db
|
||||
from utils import login_required
|
||||
|
||||
admin_locations_bp = Blueprint('admin_locations', __name__)
|
||||
|
||||
@admin_locations_bp.route('/location/<int:location_count_id>/reopen', methods=['POST'])
|
||||
@login_required
|
||||
def reopen_location(location_count_id):
|
||||
"""Reopen a completed location (admin/owner only)"""
|
||||
# Check permissions
|
||||
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
|
||||
if not user or user['role'] not in ['owner', 'admin']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
|
||||
# Verify location exists
|
||||
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'})
|
||||
|
||||
# Reopen the location
|
||||
execute_db('''
|
||||
UPDATE LocationCounts
|
||||
SET status = 'in_progress', end_timestamp = NULL
|
||||
WHERE location_count_id = ?
|
||||
''', [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):
|
||||
"""Get all scans for a specific location (admin/owner only)"""
|
||||
# Check permissions
|
||||
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
|
||||
if not user or user['role'] not in ['owner', 'admin']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'}), 403
|
||||
|
||||
try:
|
||||
scans = query_db('''
|
||||
SELECT
|
||||
se.*,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.location_count_id = ?
|
||||
AND se.is_deleted = 0
|
||||
ORDER BY se.scan_timestamp DESC
|
||||
''', [location_count_id])
|
||||
|
||||
# Convert Row objects to dicts
|
||||
scans_list = [dict(scan) for scan in scans] if scans else []
|
||||
|
||||
return jsonify({'success': True, 'scans': scans_list})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
633
blueprints/counting.py
Normal file
633
blueprints/counting.py
Normal file
@@ -0,0 +1,633 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
|
||||
from db import query_db, execute_db, get_db
|
||||
from utils import login_required
|
||||
|
||||
counting_bp = Blueprint('counting', __name__)
|
||||
|
||||
@counting_bp.route('/count/<int:session_id>')
|
||||
@login_required
|
||||
def count_session(session_id):
|
||||
"""Select session and begin counting"""
|
||||
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ? AND status = "active"',
|
||||
[session_id], one=True)
|
||||
|
||||
if not sess:
|
||||
flash('Session not found or not active', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
# Redirect to my_counts page (staff can manage multiple bins)
|
||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||
|
||||
|
||||
@counting_bp.route('/session/<int:session_id>/my-counts')
|
||||
@login_required
|
||||
def my_counts(session_id):
|
||||
"""Staff view of their active and completed bins"""
|
||||
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||
|
||||
if not sess:
|
||||
flash('Session not found', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
# Get this user's active bins
|
||||
active_bins = query_db('''
|
||||
SELECT lc.*,
|
||||
COUNT(se.entry_id) as scan_count
|
||||
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'
|
||||
GROUP BY lc.location_count_id
|
||||
ORDER BY lc.start_timestamp DESC
|
||||
''', [session_id, session['user_id']])
|
||||
|
||||
# Get this user's completed bins
|
||||
completed_bins = query_db('''
|
||||
SELECT lc.*,
|
||||
COUNT(se.entry_id) as scan_count
|
||||
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'
|
||||
GROUP BY lc.location_count_id
|
||||
ORDER BY lc.end_timestamp DESC
|
||||
''', [session_id, session['user_id']])
|
||||
|
||||
return render_template('my_counts.html',
|
||||
count_session=sess,
|
||||
active_bins=active_bins,
|
||||
completed_bins=completed_bins)
|
||||
|
||||
|
||||
@counting_bp.route('/session/<int:session_id>/start-bin', methods=['POST'])
|
||||
@login_required
|
||||
def start_bin_count(session_id):
|
||||
"""Start counting a new bin"""
|
||||
location_name = request.form.get('location_name', '').strip().upper()
|
||||
|
||||
if not location_name:
|
||||
flash('Bin number is required', 'danger')
|
||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||
|
||||
# Count expected lots from MASTER baseline for this location
|
||||
expected_lots = query_db('''
|
||||
SELECT COUNT(DISTINCT lot_number) as count
|
||||
FROM BaselineInventory_Master
|
||||
WHERE session_id = ? AND system_bin = ?
|
||||
''', [session_id, location_name], one=True)
|
||||
|
||||
expected_count = expected_lots['count'] if expected_lots else 0
|
||||
|
||||
# Create new location count
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO LocationCounts (session_id, location_name, counted_by, status, start_timestamp, expected_lots_master)
|
||||
VALUES (?, ?, ?, 'in_progress', CURRENT_TIMESTAMP, ?)
|
||||
''', [session_id, location_name, session['user_id'], expected_count])
|
||||
|
||||
location_count_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
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):
|
||||
"""Mark a location count as complete (Simple toggle)"""
|
||||
# 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'})
|
||||
|
||||
# Mark as completed
|
||||
execute_db('''
|
||||
UPDATE LocationCounts
|
||||
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE location_count_id = ?
|
||||
''', [location_count_id])
|
||||
|
||||
return jsonify({'success': True, 'message': 'Bin marked as complete'})
|
||||
|
||||
|
||||
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>')
|
||||
@login_required
|
||||
def count_location(session_id, location_count_id):
|
||||
"""Count lots in a specific location"""
|
||||
# Get session info to determine type (Cycle Count vs Physical)
|
||||
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||
|
||||
location = query_db('''
|
||||
SELECT * FROM LocationCounts
|
||||
WHERE location_count_id = ? AND session_id = ?
|
||||
''', [location_count_id, session_id], one=True)
|
||||
|
||||
if not location:
|
||||
flash('Location not found', 'danger')
|
||||
return redirect(url_for('counting.count_session', session_id=session_id))
|
||||
|
||||
# Check if location is completed and user is staff (not admin/owner)
|
||||
if location['status'] == 'completed' and session['role'] == 'staff':
|
||||
flash(f'Location {location["location_name"]} has been finalized and cannot accept new scans', 'warning')
|
||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||
|
||||
# Get scans for this location (Scanned Lots)
|
||||
scans = query_db('''
|
||||
SELECT * FROM ScanEntries
|
||||
WHERE location_count_id = ? AND is_deleted = 0
|
||||
ORDER BY scan_timestamp DESC
|
||||
''', [location_count_id])
|
||||
|
||||
# NEW LOGIC: Get Expected Lots for Cycle Counts (Grouped & Summed)
|
||||
expected_lots = []
|
||||
if sess and sess['session_type'] == 'cycle_count':
|
||||
expected_lots = query_db('''
|
||||
SELECT
|
||||
lot_number,
|
||||
MAX(item) as item, -- Pick one item code if they differ (rare)
|
||||
SUM(system_quantity) as total_weight
|
||||
FROM BaselineInventory_Master
|
||||
WHERE session_id = ?
|
||||
AND system_bin = ?
|
||||
AND lot_number NOT IN (
|
||||
SELECT lot_number
|
||||
FROM ScanEntries
|
||||
WHERE location_count_id = ?
|
||||
AND is_deleted = 0
|
||||
)
|
||||
GROUP BY lot_number
|
||||
ORDER BY lot_number
|
||||
''', [session_id, location['location_name'], location_count_id])
|
||||
|
||||
return render_template('count_location.html',
|
||||
session_id=session_id,
|
||||
location=location,
|
||||
scans=scans,
|
||||
expected_lots=expected_lots,
|
||||
session_type=sess['session_type'] if sess else '')
|
||||
|
||||
|
||||
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>/scan', methods=['POST'])
|
||||
@login_required
|
||||
def scan_lot(session_id, location_count_id):
|
||||
"""Process a lot scan with duplicate detection"""
|
||||
data = request.get_json()
|
||||
lot_number = data.get('lot_number', '').strip()
|
||||
weight = data.get('weight')
|
||||
confirm_duplicate = data.get('confirm_duplicate', False)
|
||||
check_only = data.get('check_only', False) # Just checking for duplicates, not saving
|
||||
|
||||
if not lot_number:
|
||||
return jsonify({'success': False, 'message': 'Lot number required'})
|
||||
|
||||
if not check_only and not weight:
|
||||
return jsonify({'success': False, 'message': 'Weight required'})
|
||||
|
||||
if not check_only:
|
||||
try:
|
||||
weight = float(weight)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'message': 'Invalid weight value'})
|
||||
|
||||
# Get location info
|
||||
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
|
||||
[location_count_id], one=True)
|
||||
|
||||
# Check for duplicates in this session
|
||||
existing_scans = query_db('''
|
||||
SELECT se.*, lc.location_name, u.full_name
|
||||
FROM ScanEntries se
|
||||
JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0
|
||||
''', [session_id, lot_number])
|
||||
|
||||
duplicate_status = '00' # Default: no duplicate
|
||||
duplicate_info = None
|
||||
needs_confirmation = False
|
||||
|
||||
if existing_scans:
|
||||
# Check for same location duplicates (by this user)
|
||||
same_location = [s for s in existing_scans
|
||||
if s['location_name'] == location['location_name']
|
||||
and s['scanned_by'] == session['user_id']]
|
||||
|
||||
# Check for different location duplicates (by anyone)
|
||||
diff_location = [s for s in existing_scans
|
||||
if s['location_name'] != location['location_name']]
|
||||
|
||||
if same_location and diff_location:
|
||||
# Status 04: Duplicate in both same and different locations
|
||||
duplicate_status = '04'
|
||||
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
|
||||
duplicate_info = f"Also found in {other_locs}. Duplicate Lot"
|
||||
needs_confirmation = True
|
||||
|
||||
elif same_location:
|
||||
# Status 01: Duplicate in same location only
|
||||
duplicate_status = '01'
|
||||
duplicate_info = "Duplicate"
|
||||
needs_confirmation = True
|
||||
|
||||
elif diff_location:
|
||||
# Status 03: Duplicate in different location only
|
||||
duplicate_status = '03'
|
||||
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
|
||||
duplicate_info = f"Also found in {other_locs}"
|
||||
|
||||
# If just checking, return early with baseline info
|
||||
if check_only:
|
||||
# Get baseline info to show what they're scanning
|
||||
master_info = query_db('''
|
||||
SELECT item, description FROM BaselineInventory_Master
|
||||
WHERE session_id = ? AND lot_number = ?
|
||||
LIMIT 1
|
||||
''', [session_id, lot_number], one=True)
|
||||
|
||||
if needs_confirmation:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'needs_confirmation': True,
|
||||
'message': 'Lot already scanned, Are you sure?',
|
||||
'duplicate_status': duplicate_status,
|
||||
'item': master_info['item'] if master_info else None,
|
||||
'description': master_info['description'] if master_info else None
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'needs_confirmation': False,
|
||||
'item': master_info['item'] if master_info else None,
|
||||
'description': master_info['description'] if master_info else None
|
||||
})
|
||||
|
||||
# If needs confirmation and not yet confirmed, ask user
|
||||
if needs_confirmation and not confirm_duplicate:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'needs_confirmation': True,
|
||||
'message': 'Lot already scanned, Are you sure?',
|
||||
'duplicate_status': duplicate_status
|
||||
})
|
||||
|
||||
# Check against MASTER baseline
|
||||
master = query_db('''
|
||||
SELECT * FROM BaselineInventory_Master
|
||||
WHERE session_id = ? AND lot_number = ? AND system_bin = ?
|
||||
''', [session_id, lot_number, location['location_name']], one=True)
|
||||
|
||||
# Determine master_status (only if not a duplicate issue)
|
||||
if duplicate_status == '00':
|
||||
if master:
|
||||
# Lot exists in correct location
|
||||
master_status = 'match'
|
||||
if master['system_quantity'] is not None:
|
||||
variance_lbs = weight - master['system_quantity']
|
||||
variance_pct = (variance_lbs / master['system_quantity'] * 100) if master['system_quantity'] > 0 else 0
|
||||
else:
|
||||
variance_lbs = None
|
||||
variance_pct = None
|
||||
else:
|
||||
# Check if lot exists in different location
|
||||
master_other = query_db('''
|
||||
SELECT * FROM BaselineInventory_Master
|
||||
WHERE session_id = ? AND lot_number = ?
|
||||
''', [session_id, lot_number], one=True)
|
||||
|
||||
if master_other:
|
||||
master_status = 'wrong_location'
|
||||
master = master_other
|
||||
variance_lbs = None
|
||||
variance_pct = None
|
||||
else:
|
||||
# Ghost lot
|
||||
master_status = 'ghost_lot'
|
||||
variance_lbs = None
|
||||
variance_pct = None
|
||||
else:
|
||||
# For duplicates, still check baseline for item info
|
||||
if not master:
|
||||
master = query_db('''
|
||||
SELECT * FROM BaselineInventory_Master
|
||||
WHERE session_id = ? AND lot_number = ?
|
||||
''', [session_id, lot_number], one=True)
|
||||
master_status = 'match' # Don't override with wrong_location for duplicates
|
||||
variance_lbs = None
|
||||
variance_pct = None
|
||||
|
||||
# Insert scan
|
||||
entry_id = execute_db('''
|
||||
INSERT INTO ScanEntries
|
||||
(session_id, location_count_id, lot_number, item, description,
|
||||
scanned_location, actual_weight, scanned_by,
|
||||
master_status, master_expected_location, master_expected_weight,
|
||||
master_variance_lbs, master_variance_pct,
|
||||
duplicate_status, duplicate_info, comment)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', [
|
||||
session_id, location_count_id, lot_number,
|
||||
master['item'] if master else None,
|
||||
master['description'] if master else None,
|
||||
location['location_name'], weight, session['user_id'],
|
||||
master_status,
|
||||
master['system_bin'] if master else None,
|
||||
master['system_quantity'] if master else None,
|
||||
variance_lbs, variance_pct,
|
||||
duplicate_status, duplicate_info, duplicate_info
|
||||
])
|
||||
|
||||
# If this is a confirmed duplicate (01 or 04), update previous scans in same location
|
||||
updated_entry_ids = []
|
||||
if duplicate_status in ['01', '04'] and confirm_duplicate:
|
||||
same_location_ids = [s['entry_id'] for s in existing_scans
|
||||
if s['location_name'] == location['location_name']
|
||||
and s['scanned_by'] == session['user_id']]
|
||||
|
||||
for scan_id in same_location_ids:
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET duplicate_status = ?,
|
||||
duplicate_info = ?,
|
||||
comment = ?,
|
||||
modified_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [duplicate_status, duplicate_info, duplicate_info, scan_id])
|
||||
updated_entry_ids.append(scan_id)
|
||||
|
||||
# Update location count
|
||||
execute_db('''
|
||||
UPDATE LocationCounts
|
||||
SET lots_found = lots_found + 1
|
||||
WHERE location_count_id = ?
|
||||
''', [location_count_id])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'entry_id': entry_id,
|
||||
'master_status': master_status,
|
||||
'duplicate_status': duplicate_status,
|
||||
'duplicate_info': duplicate_info,
|
||||
'master_expected_location': master['system_bin'] if master else None,
|
||||
'master_expected_weight': master['system_quantity'] if master else None,
|
||||
'actual_weight': weight,
|
||||
'variance_lbs': variance_lbs,
|
||||
'item': master['item'] if master else 'Unknown Item',
|
||||
'description': master['description'] if master else 'Not in system',
|
||||
'updated_entry_ids': updated_entry_ids # IDs of scans that were updated to duplicate
|
||||
})
|
||||
|
||||
|
||||
@counting_bp.route('/scan/<int:entry_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
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)
|
||||
|
||||
if not scan:
|
||||
return jsonify({'success': False, 'message': 'Scan not found'})
|
||||
|
||||
# Only allow user to delete their own scans
|
||||
if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
# Soft delete the scan
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET is_deleted = 1,
|
||||
deleted_by = ?,
|
||||
deleted_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [session['user_id'], entry_id])
|
||||
|
||||
# Recalculate duplicate statuses for this lot number in this session
|
||||
updated_entries = recalculate_duplicate_status(scan['session_id'], scan['lot_number'], scan['scanned_location'])
|
||||
|
||||
# Update location count
|
||||
execute_db('''
|
||||
UPDATE LocationCounts
|
||||
SET lots_found = lots_found - 1
|
||||
WHERE location_count_id = ?
|
||||
''', [scan['location_count_id']])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Scan deleted',
|
||||
'updated_entries': updated_entries # Return which scans were updated
|
||||
})
|
||||
|
||||
|
||||
@counting_bp.route('/scan/<int:entry_id>/details', methods=['GET'])
|
||||
@login_required
|
||||
def get_scan_details(entry_id):
|
||||
"""Get detailed information about a scan"""
|
||||
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'})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'scan': dict(scan)
|
||||
})
|
||||
|
||||
|
||||
@counting_bp.route('/scan/<int:entry_id>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_scan(entry_id):
|
||||
"""Update scan item, weight and comment"""
|
||||
data = request.get_json()
|
||||
item = data.get('item', '').strip()
|
||||
weight = data.get('weight')
|
||||
comment = data.get('comment', '')
|
||||
|
||||
# Get the scan
|
||||
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ?', [entry_id], one=True)
|
||||
|
||||
if not scan:
|
||||
return jsonify({'success': False, 'message': 'Scan not found'})
|
||||
|
||||
# Only allow user to update their own scans
|
||||
if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
try:
|
||||
weight = float(weight)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'message': 'Invalid weight value'})
|
||||
|
||||
# Update the scan
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET item = ?,
|
||||
actual_weight = ?,
|
||||
comment = ?,
|
||||
modified_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [item, weight, comment, entry_id])
|
||||
|
||||
return jsonify({'success': True, 'message': 'Scan updated'})
|
||||
|
||||
|
||||
def recalculate_duplicate_status(session_id, lot_number, current_location):
|
||||
"""Recalculate duplicate statuses for a lot after deletion"""
|
||||
# Track which entries were updated
|
||||
updated_entries = []
|
||||
|
||||
# Get all active scans for this lot in this session
|
||||
scans = query_db('''
|
||||
SELECT se.*, lc.location_name, u.full_name, u.user_id as scan_user_id
|
||||
FROM ScanEntries se
|
||||
JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0
|
||||
ORDER BY se.scan_timestamp
|
||||
''', [session_id, lot_number])
|
||||
|
||||
if not scans:
|
||||
return updated_entries
|
||||
|
||||
# Reset all to status 00
|
||||
for scan in scans:
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET duplicate_status = '00',
|
||||
duplicate_info = NULL,
|
||||
comment = NULL,
|
||||
modified_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [scan['entry_id']])
|
||||
updated_entries.append({
|
||||
'entry_id': scan['entry_id'],
|
||||
'duplicate_status': '00',
|
||||
'duplicate_info': None
|
||||
})
|
||||
|
||||
# Recalculate statuses
|
||||
for i, scan in enumerate(scans):
|
||||
# Get previous scans (before this one chronologically)
|
||||
prev_scans = scans[:i]
|
||||
|
||||
if not prev_scans:
|
||||
continue # First scan, stays 00
|
||||
|
||||
same_location = [s for s in prev_scans if s['location_name'] == scan['location_name'] and s['scan_user_id'] == scan['scan_user_id']]
|
||||
diff_location = [s for s in prev_scans if s['location_name'] != scan['location_name']]
|
||||
|
||||
duplicate_status = '00'
|
||||
duplicate_info = None
|
||||
|
||||
if same_location and diff_location:
|
||||
# Status 04
|
||||
duplicate_status = '04'
|
||||
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
|
||||
duplicate_info = f"Also found in {other_locs}. Duplicate Lot"
|
||||
elif same_location:
|
||||
# Status 01
|
||||
duplicate_status = '01'
|
||||
duplicate_info = "Duplicate"
|
||||
elif diff_location:
|
||||
# Status 03
|
||||
duplicate_status = '03'
|
||||
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
|
||||
duplicate_info = f"Also found in {other_locs}"
|
||||
|
||||
# Update this scan if it changed from 00
|
||||
if duplicate_status != '00':
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET duplicate_status = ?,
|
||||
duplicate_info = ?,
|
||||
comment = ?,
|
||||
modified_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [duplicate_status, duplicate_info, duplicate_info, scan['entry_id']])
|
||||
|
||||
# Update our tracking list
|
||||
for entry in updated_entries:
|
||||
if entry['entry_id'] == scan['entry_id']:
|
||||
entry['duplicate_status'] = duplicate_status
|
||||
entry['duplicate_info'] = duplicate_info
|
||||
break
|
||||
|
||||
# If status 01 or 04, also update previous scans in same location
|
||||
if duplicate_status in ['01', '04'] and same_location:
|
||||
for prev_scan in same_location:
|
||||
execute_db('''
|
||||
UPDATE ScanEntries
|
||||
SET duplicate_status = ?,
|
||||
duplicate_info = ?,
|
||||
comment = ?,
|
||||
modified_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE entry_id = ?
|
||||
''', [duplicate_status, duplicate_info, duplicate_info, prev_scan['entry_id']])
|
||||
|
||||
# Update tracking for previous scans
|
||||
for entry in updated_entries:
|
||||
if entry['entry_id'] == prev_scan['entry_id']:
|
||||
entry['duplicate_status'] = duplicate_status
|
||||
entry['duplicate_info'] = duplicate_info
|
||||
break
|
||||
|
||||
return updated_entries
|
||||
|
||||
|
||||
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>/finish', methods=['POST'])
|
||||
@login_required
|
||||
def finish_location(session_id, location_count_id):
|
||||
"""Finish counting a location"""
|
||||
# Get location info
|
||||
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
|
||||
[location_count_id], one=True)
|
||||
|
||||
if not location:
|
||||
return jsonify({'success': False, 'message': 'Location not found'})
|
||||
|
||||
# Mark location as completed
|
||||
execute_db('''
|
||||
UPDATE LocationCounts
|
||||
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
|
||||
WHERE location_count_id = ?
|
||||
''', [location_count_id])
|
||||
|
||||
# V1.0: Mark missing lots from MASTER baseline that weren't scanned
|
||||
# Get all expected lots for this location 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, location['location_name']])
|
||||
|
||||
# Get all scanned lots for this location
|
||||
scanned_lots = query_db('''
|
||||
SELECT DISTINCT lot_number
|
||||
FROM ScanEntries
|
||||
WHERE location_count_id = ? AND is_deleted = 0
|
||||
''', [location_count_id])
|
||||
|
||||
scanned_lot_numbers = {s['lot_number'] for s in scanned_lots}
|
||||
|
||||
# Insert missing 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'], location['location_name'],
|
||||
expected['item'], expected['system_quantity'], session['user_id']])
|
||||
|
||||
flash('Location count completed!', 'success')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'redirect': url_for('counting.count_session', session_id=session_id)
|
||||
})
|
||||
@@ -17,12 +17,12 @@ def upload_current(session_id):
|
||||
|
||||
if 'csv_file' not in request.files:
|
||||
flash('No file part', 'danger')
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions.session_detail', session_id=session_id))
|
||||
|
||||
file = request.files['csv_file']
|
||||
if file.filename == '':
|
||||
flash('No selected file', 'danger')
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions.session_detail', session_id=session_id))
|
||||
|
||||
if file:
|
||||
conn = get_db()
|
||||
@@ -76,7 +76,7 @@ def upload_current(session_id):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions.session_detail', session_id=session_id))
|
||||
|
||||
# --- ROUTE 2: Upload MASTER Baseline (Session Specific) ---
|
||||
@data_imports_bp.route('/session/<int:session_id>/upload_master', methods=['POST'])
|
||||
@@ -85,12 +85,12 @@ def upload_master(session_id):
|
||||
|
||||
if 'csv_file' not in request.files:
|
||||
flash('No file uploaded', 'danger')
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions.session_detail', session_id=session_id))
|
||||
|
||||
file = request.files['csv_file']
|
||||
if file.filename == '':
|
||||
flash('No file selected', 'danger')
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions.session_detail', session_id=session_id))
|
||||
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
@@ -152,4 +152,4 @@ def upload_master(session_id):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('session_detail', session_id=session_id))
|
||||
return redirect(url_for('sessions. session_detail', session_id=session_id))
|
||||
211
blueprints/sessions.py
Normal file
211
blueprints/sessions.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
|
||||
from db import query_db, execute_db
|
||||
from utils import role_required
|
||||
|
||||
sessions_bp = Blueprint('sessions', __name__)
|
||||
|
||||
@sessions_bp.route('/session/create', methods=['GET', 'POST'])
|
||||
@role_required('owner', 'admin')
|
||||
def create_session():
|
||||
"""Create new count session"""
|
||||
if request.method == 'POST':
|
||||
session_name = request.form.get('session_name', '').strip()
|
||||
session_type = request.form.get('session_type')
|
||||
|
||||
if not session_name:
|
||||
flash('Session name is required', 'danger')
|
||||
return redirect(url_for('sessions.create_session'))
|
||||
|
||||
session_id = execute_db('''
|
||||
INSERT INTO CountSessions (session_name, session_type, created_by, branch)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', [session_name, session_type, session['user_id'], 'Main'])
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@sessions_bp.route('/session/<int:session_id>')
|
||||
@role_required('owner', 'admin')
|
||||
def session_detail(session_id):
|
||||
"""Session detail and monitoring page"""
|
||||
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
|
||||
|
||||
if not sess:
|
||||
flash('Session not found', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
# Get statistics
|
||||
stats = query_db('''
|
||||
SELECT
|
||||
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.is_deleted = 0) as total_scans,
|
||||
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 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 weight_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_lots
|
||||
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)
|
||||
|
||||
# Get location progress
|
||||
locations = query_db('''
|
||||
SELECT lc.*, u.full_name as counter_name
|
||||
FROM LocationCounts lc
|
||||
LEFT JOIN Users u ON lc.counted_by = u.user_id
|
||||
WHERE lc.session_id = ?
|
||||
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
|
||||
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
|
||||
''', [session_id])
|
||||
|
||||
return render_template('session_detail.html',
|
||||
count_session=sess,
|
||||
stats=stats,
|
||||
locations=locations,
|
||||
active_counters=active_counters)
|
||||
|
||||
|
||||
@sessions_bp.route('/session/<int:session_id>/status-details/<status>')
|
||||
@role_required('owner', 'admin')
|
||||
def get_status_details(session_id, status):
|
||||
"""Get detailed breakdown for a specific status"""
|
||||
|
||||
try:
|
||||
if status == 'match':
|
||||
# Matched lots (not duplicates) - JOIN with CURRENT for live data
|
||||
items = query_db('''
|
||||
SELECT
|
||||
se.*,
|
||||
u.full_name as scanned_by_name,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.session_id = ?
|
||||
AND se.master_status = 'match'
|
||||
AND se.duplicate_status = '00'
|
||||
AND se.is_deleted = 0
|
||||
ORDER BY se.scan_timestamp DESC
|
||||
''', [session_id])
|
||||
|
||||
elif status == 'duplicates':
|
||||
# Duplicate lots (grouped by lot number) - JOIN with CURRENT
|
||||
items = query_db('''
|
||||
SELECT
|
||||
se.lot_number,
|
||||
se.item,
|
||||
se.description,
|
||||
GROUP_CONCAT(DISTINCT se.scanned_location) as scanned_location,
|
||||
SUM(se.actual_weight) as actual_weight,
|
||||
se.master_expected_location,
|
||||
se.master_expected_weight,
|
||||
GROUP_CONCAT(DISTINCT u.full_name) as scanned_by_name,
|
||||
MIN(se.scan_timestamp) as scan_timestamp,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.session_id = ?
|
||||
AND se.duplicate_status IN ('01', '03', '04')
|
||||
AND se.is_deleted = 0
|
||||
GROUP BY se.lot_number
|
||||
ORDER BY se.lot_number
|
||||
''', [session_id])
|
||||
|
||||
elif status == 'wrong_location':
|
||||
# Wrong location lots - JOIN with CURRENT
|
||||
items = query_db('''
|
||||
SELECT
|
||||
se.*,
|
||||
u.full_name as scanned_by_name,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.session_id = ?
|
||||
AND se.master_status = 'wrong_location'
|
||||
AND se.is_deleted = 0
|
||||
ORDER BY se.scan_timestamp DESC
|
||||
''', [session_id])
|
||||
|
||||
elif status == 'weight_discrepancy':
|
||||
# Weight discrepancies (right location, wrong weight) - JOIN with CURRENT
|
||||
items = query_db('''
|
||||
SELECT
|
||||
se.*,
|
||||
u.full_name as scanned_by_name,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.session_id = ?
|
||||
AND se.master_status = 'match'
|
||||
AND se.duplicate_status = '00'
|
||||
AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01
|
||||
AND se.is_deleted = 0
|
||||
ORDER BY ABS(se.actual_weight - se.master_expected_weight) DESC
|
||||
''', [session_id])
|
||||
|
||||
elif status == 'ghost_lot':
|
||||
# Ghost lots (not in master baseline) - JOIN with CURRENT
|
||||
items = query_db('''
|
||||
SELECT
|
||||
se.*,
|
||||
u.full_name as scanned_by_name,
|
||||
bic.system_bin as current_system_location,
|
||||
bic.system_quantity as current_system_weight
|
||||
FROM ScanEntries se
|
||||
JOIN Users u ON se.scanned_by = u.user_id
|
||||
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
|
||||
WHERE se.session_id = ?
|
||||
AND se.master_status = 'ghost_lot'
|
||||
AND se.is_deleted = 0
|
||||
ORDER BY se.scan_timestamp DESC
|
||||
''', [session_id])
|
||||
|
||||
elif status == 'missing':
|
||||
# Missing lots (in master but not scanned)
|
||||
items = query_db('''
|
||||
SELECT
|
||||
bim.lot_number,
|
||||
bim.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])
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Invalid status'})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': [dict(item) for item in items] if items else []
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in get_status_details: {str(e)}")
|
||||
return jsonify({'success': False, 'message': f'Error: {str(e)}'})
|
||||
194
blueprints/users.py
Normal file
194
blueprints/users.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
from db import query_db, execute_db
|
||||
from utils import role_required
|
||||
|
||||
users_bp = Blueprint('users', __name__)
|
||||
|
||||
@users_bp.route('/settings/users')
|
||||
@role_required('owner', 'admin')
|
||||
def manage_users():
|
||||
"""User management page"""
|
||||
# Get all users
|
||||
if session['role'] == 'owner':
|
||||
# Owners can see everyone
|
||||
users = query_db('SELECT * FROM Users ORDER BY role, full_name')
|
||||
else:
|
||||
# Admins can only see staff
|
||||
users = query_db("SELECT * FROM Users WHERE role = 'staff' ORDER BY full_name")
|
||||
|
||||
return render_template('manage_users.html', users=users)
|
||||
|
||||
|
||||
@users_bp.route('/settings/users/add', methods=['POST'])
|
||||
@role_required('owner', 'admin')
|
||||
def add_user():
|
||||
"""Add a new user"""
|
||||
data = request.get_json()
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
first_name = data.get('first_name', '').strip()
|
||||
last_name = data.get('last_name', '').strip()
|
||||
email = data.get('email', '').strip()
|
||||
role = data.get('role', 'staff')
|
||||
branch = data.get('branch', 'Main')
|
||||
|
||||
# Validation
|
||||
if not username or not password or not first_name or not last_name:
|
||||
return jsonify({'success': False, 'message': 'Username, password, first name, and last name are required'})
|
||||
|
||||
# Admins can't create admins or owners
|
||||
if session['role'] == 'admin' and role in ['admin', 'owner']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied: Admins can only create Staff users'})
|
||||
|
||||
# Check if username exists
|
||||
existing = query_db('SELECT user_id FROM Users WHERE username = ?', [username], one=True)
|
||||
if existing:
|
||||
return jsonify({'success': False, 'message': 'Username already exists'})
|
||||
|
||||
# Create user
|
||||
full_name = f"{first_name} {last_name}"
|
||||
hashed_password = generate_password_hash(password)
|
||||
|
||||
try:
|
||||
execute_db('''
|
||||
INSERT INTO Users (username, password, full_name, role, branch, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
''', [username, hashed_password, full_name, role, branch])
|
||||
|
||||
return jsonify({'success': True, 'message': 'User created successfully'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'Error creating user: {str(e)}'})
|
||||
|
||||
|
||||
@users_bp.route('/settings/users/<int:user_id>', methods=['GET'])
|
||||
@role_required('owner', 'admin')
|
||||
def get_user(user_id):
|
||||
"""Get user details"""
|
||||
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
|
||||
|
||||
if not user:
|
||||
return jsonify({'success': False, 'message': 'User not found'})
|
||||
|
||||
# Admins can't view other admins or owners
|
||||
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
# Split full name
|
||||
name_parts = user['full_name'].split(' ', 1)
|
||||
first_name = name_parts[0] if len(name_parts) > 0 else ''
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else ''
|
||||
|
||||
# Get email, handle None
|
||||
email = user['email'] if user['email'] else ''
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'email': email,
|
||||
'role': user['role'],
|
||||
'branch': user['branch'],
|
||||
'is_active': user['is_active']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@users_bp.route('/settings/users/<int:user_id>/update', methods=['POST'])
|
||||
@role_required('owner', 'admin')
|
||||
def update_user(user_id):
|
||||
"""Update user details"""
|
||||
data = request.get_json()
|
||||
|
||||
# Get existing user
|
||||
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
|
||||
|
||||
if not user:
|
||||
return jsonify({'success': False, 'message': 'User not found'})
|
||||
|
||||
# Admins can't edit other admins or owners
|
||||
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
# Can't edit yourself to change your own role or deactivate
|
||||
if user_id == session['user_id']:
|
||||
if data.get('role') != user['role']:
|
||||
return jsonify({'success': False, 'message': 'Cannot change your own role'})
|
||||
if data.get('is_active') == 0:
|
||||
return jsonify({'success': False, 'message': 'Cannot deactivate yourself'})
|
||||
|
||||
# Build update
|
||||
username = data.get('username', '').strip()
|
||||
first_name = data.get('first_name', '').strip()
|
||||
last_name = data.get('last_name', '').strip()
|
||||
email = data.get('email', '').strip()
|
||||
role = data.get('role', user['role'])
|
||||
branch = data.get('branch', user['branch'])
|
||||
is_active = data.get('is_active', user['is_active'])
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
if not username or not first_name or not last_name:
|
||||
return jsonify({'success': False, 'message': 'Username, first name, and last name are required'})
|
||||
|
||||
# Check if username is taken by another user
|
||||
if username != user['username']:
|
||||
existing = query_db('SELECT user_id FROM Users WHERE username = ? AND user_id != ?', [username, user_id], one=True)
|
||||
if existing:
|
||||
return jsonify({'success': False, 'message': 'Username already taken'})
|
||||
|
||||
# Admins can't change role to admin or owner
|
||||
if session['role'] == 'admin' and role in ['admin', 'owner']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied: Cannot assign Admin or Owner role'})
|
||||
|
||||
full_name = f"{first_name} {last_name}"
|
||||
|
||||
try:
|
||||
if password:
|
||||
# Update with new password
|
||||
hashed_password = generate_password_hash(password)
|
||||
execute_db('''
|
||||
UPDATE Users
|
||||
SET username = ?, full_name = ?, email = ?, role = ?, branch = ?, is_active = ?, password = ?
|
||||
WHERE user_id = ?
|
||||
''', [username, full_name, email, role, branch, is_active, hashed_password, user_id])
|
||||
else:
|
||||
# Update without changing password
|
||||
execute_db('''
|
||||
UPDATE Users
|
||||
SET username = ?, full_name = ?, email = ?, role = ?, branch = ?, is_active = ?
|
||||
WHERE user_id = ?
|
||||
''', [username, full_name, email, role, branch, is_active, user_id])
|
||||
|
||||
return jsonify({'success': True, 'message': 'User updated successfully'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'Error updating user: {str(e)}'})
|
||||
|
||||
|
||||
@users_bp.route('/settings/users/<int:user_id>/delete', methods=['POST'])
|
||||
@role_required('owner', 'admin')
|
||||
def delete_user(user_id):
|
||||
"""Delete (deactivate) a user"""
|
||||
# Get user
|
||||
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
|
||||
|
||||
if not user:
|
||||
return jsonify({'success': False, 'message': 'User not found'})
|
||||
|
||||
# Admins can't delete other admins or owners
|
||||
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
|
||||
return jsonify({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
# Can't delete yourself
|
||||
if user_id == session['user_id']:
|
||||
return jsonify({'success': False, 'message': 'Cannot delete yourself'})
|
||||
|
||||
# Soft delete (deactivate)
|
||||
try:
|
||||
execute_db('UPDATE Users SET is_active = 0 WHERE user_id = ?', [user_id])
|
||||
return jsonify({'success': True, 'message': 'User deleted successfully'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'Error deleting user: {str(e)}'})
|
||||
Binary file not shown.
188
static/css/mobile.css
Normal file
188
static/css/mobile.css
Normal file
@@ -0,0 +1,188 @@
|
||||
/* ==================== MOBILE STYLES (Phones) ==================== */
|
||||
/* Viewport: 360-767px | Target: min-width: 360px and max-width: 767px */
|
||||
/* This file contains overrides for mobile phones (iPhone, Android, etc.) */
|
||||
|
||||
@media screen and (min-width: 360px) and (max-width: 767px) {
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Base Typography
|
||||
--------------------------------------------------------- */
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Navbar
|
||||
--------------------------------------------------------- */
|
||||
.nav-content {
|
||||
flex-direction: row;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Forms
|
||||
--------------------------------------------------------- */
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Dashboard
|
||||
--------------------------------------------------------- */
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.sessions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.baseline-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Scanning Interface
|
||||
--------------------------------------------------------- */
|
||||
.scan-input {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Modals
|
||||
--------------------------------------------------------- */
|
||||
.modal-content {
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
.modal-header-bar {
|
||||
padding: var(--space-md) 0;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.modal-large,
|
||||
.modal-xl {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Detail Sections (Scan Detail Modal)
|
||||
--------------------------------------------------------- */
|
||||
.detail-section {
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-lot {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-input {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-form {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) 0 0 0;
|
||||
}
|
||||
|
||||
.detail-actions .btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Forms (Mobile)
|
||||
--------------------------------------------------------- */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Scroll to Top or Bottom Buttonss
|
||||
--------------------------------------------------------- */
|
||||
/* ---------------------------------------------------------
|
||||
Scroll Buttons - Floating, semi-transparent
|
||||
--------------------------------------------------------- */
|
||||
.scroll-to-top,
|
||||
.scroll-to-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
z-index: 50;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.scroll-to-top:active,
|
||||
.scroll-to-bottom:active {
|
||||
background: rgba(0, 212, 255, 0.6);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scroll-to-top {
|
||||
right: 10px;
|
||||
bottom: 270px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
right: 10px;
|
||||
bottom: 170px;
|
||||
}
|
||||
|
||||
}
|
||||
344
static/css/scanner.css
Normal file
344
static/css/scanner.css
Normal file
@@ -0,0 +1,344 @@
|
||||
/* ==================== SCANNER STYLES (MC9300) ==================== */
|
||||
/* Viewport: 320 x 405 | Target: max-width: 359px */
|
||||
/* This file contains overrides for Zebra MC9300 handheld scanners */
|
||||
|
||||
@media screen and (max-width: 359px) {
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
CSS Variables - Tighter spacing for small screen
|
||||
--------------------------------------------------------- */
|
||||
:root {
|
||||
--space-2xl: 0.8rem;
|
||||
--space-xl: 0.6rem;
|
||||
--space-lg: 0.4rem;
|
||||
--space-md: 0.3rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Navbar - Minimal, compact
|
||||
--------------------------------------------------------- */
|
||||
.navbar {
|
||||
padding: 4px 10px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.user-badge,
|
||||
.role-pill,
|
||||
.breadcrumb,
|
||||
.settings-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Page Header
|
||||
--------------------------------------------------------- */
|
||||
.page-title {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Location Header - Compact single line top
|
||||
--------------------------------------------------------- */
|
||||
.location-header {
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.location-label {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
font-size: 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.location-stats {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
font-size: 1.4rem;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Scan Input Card - Compact
|
||||
--------------------------------------------------------- */
|
||||
.scan-card {
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.scan-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scan-input {
|
||||
padding: 8px;
|
||||
font-size: 1rem;
|
||||
height: 38px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Scanned/Expected Lists
|
||||
--------------------------------------------------------- */
|
||||
.scans-section,
|
||||
.expected-section {
|
||||
padding: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.scans-header {
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.scans-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Lists - No scroll boxes, natural page flow
|
||||
--------------------------------------------------------- */
|
||||
.scans-grid {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
gap: normal;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
List Rows - 2-row layout for scanner
|
||||
Row 1: Lot number (full width)
|
||||
Row 2: Item + Weight
|
||||
--------------------------------------------------------- */
|
||||
.scan-row,
|
||||
.expected-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 2px 8px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Lot spans full width on row 1 */
|
||||
.scan-row-lot {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Item on row 2, left */
|
||||
.scan-row-item {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
/* Weight on row 2, right */
|
||||
.scan-row-weight {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Hide status column on scanner */
|
||||
.scan-row-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Row Status Colors (more visible without status badge)
|
||||
--------------------------------------------------------- */
|
||||
.scan-row-match {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.scan-row-wrong_location {
|
||||
background: rgba(255, 170, 0, 0.15);
|
||||
}
|
||||
|
||||
.scan-row-ghost_lot {
|
||||
background: rgba(179, 102, 255, 0.15);
|
||||
}
|
||||
|
||||
.scan-row-weight_discrepancy {
|
||||
background: rgba(255, 152, 0, 0.25);
|
||||
}
|
||||
|
||||
.scan-row-duplicate-01,
|
||||
.scan-row-duplicate-04 {
|
||||
background: rgba(0, 163, 255, 0.15);
|
||||
}
|
||||
|
||||
.scan-row-duplicate-03 {
|
||||
background: rgba(255, 140, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Action Buttons (Back / Finish)
|
||||
--------------------------------------------------------- */
|
||||
.finish-section {
|
||||
position: relative; /* Not fixed/floating */
|
||||
bottom: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-buttons-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons-row .btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 0.85rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--color-success);
|
||||
box-shadow: none; /* Remove glow for performance */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Detail Sections (Scan Detail Modal)
|
||||
--------------------------------------------------------- */
|
||||
.detail-section {
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-lot {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-input {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-form {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) 0 0 0;
|
||||
}
|
||||
|
||||
.detail-actions .btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Scroll to Top or Bottom Buttonss
|
||||
--------------------------------------------------------- */
|
||||
/* ---------------------------------------------------------
|
||||
Scroll Buttons - Floating, semi-transparent
|
||||
--------------------------------------------------------- */
|
||||
.scroll-to-top,
|
||||
.scroll-to-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(0, 212, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
z-index: 50;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.scroll-to-top:active,
|
||||
.scroll-to-bottom:active {
|
||||
background: rgba(0, 212, 255, 0.6);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scroll-to-top {
|
||||
right: 10px;
|
||||
bottom: 150px;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
right: 10px;
|
||||
bottom: 70px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
Footer - Hidden on scanner
|
||||
--------------------------------------------------------- */
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/* ScanLook - Inventory Management System CSS */
|
||||
/* Mobile-first, high-contrast design optimized for warehouse scanners */
|
||||
/* Desktop-first design - mobile/scanner overrides in separate files */
|
||||
/* Files: style.css (base), mobile.css (phones), scanner.css (MC9300) */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap');
|
||||
|
||||
@@ -1385,9 +1386,10 @@ body {
|
||||
}
|
||||
|
||||
.scan-row-weight_discrepancy {
|
||||
border-color: #ff9800; /* Orange Border */
|
||||
background: rgba(255, 152, 0, 0.05); /* Very light orange background */
|
||||
border-color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.05);
|
||||
}
|
||||
|
||||
.scan-row-wrong_location {
|
||||
border-color: var(--color-warning);
|
||||
background: rgba(255, 170, 0, 0.05);
|
||||
@@ -1696,12 +1698,6 @@ body {
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.finish-section {
|
||||
position: sticky;
|
||||
bottom: var(--space-lg);
|
||||
@@ -1713,123 +1709,6 @@ body {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ==================== MOBILE OPTIMIZATIONS ==================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
flex-direction: row;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.sessions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.baseline-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.scan-input {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* Scan Detail Modal Mobile Fixes */
|
||||
.modal-content {
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
.modal-header-bar {
|
||||
padding: var(--space-md) 0;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-lot {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-input {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-form {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.modal-large,
|
||||
.modal-xl {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) 0 0 0;
|
||||
}
|
||||
|
||||
.detail-actions .btn {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
@@ -1926,21 +1805,6 @@ body {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Colored column headers */
|
||||
.detail-table th.col-counted {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.detail-table th.col-expected {
|
||||
background: #3d2d00;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.detail-table th.col-current {
|
||||
background: #00293d;
|
||||
color: var(--color-duplicate);
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: var(--space-md);
|
||||
border: none;
|
||||
@@ -2118,10 +1982,6 @@ body {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -2233,9 +2093,8 @@ body {
|
||||
/* Expected rows look "pending" or "unscanned" */
|
||||
.expected-row {
|
||||
opacity: 0.7;
|
||||
border-left: 4px solid var(--color-border); /* Neutral/Gray border */
|
||||
background: var(--color-surface); /* Darker than scanned rows */
|
||||
/* Use the same grid layout as scanned rows */
|
||||
border-left: 4px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1.5fr;
|
||||
gap: var(--space-md);
|
||||
@@ -2255,156 +2114,8 @@ body {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ==================== ZEBRA MC9300 CONSOLIDATED FIX ==================== */
|
||||
@media screen and (max-width: 480px) and (max-height: 600px) {
|
||||
:root {
|
||||
--space-2xl: 0.8rem !important;
|
||||
--space-xl: 0.6rem !important;
|
||||
--space-lg: 0.4rem !important;
|
||||
--space-md: 0.3rem !important;
|
||||
}
|
||||
|
||||
/* Header & Nav Hiding */
|
||||
.user-badge, .role-pill, .breadcrumb { display: none !important; }
|
||||
.navbar { padding: 4px 10px !important; height: 38px !important; }
|
||||
.logo { font-size: 1rem !important; }
|
||||
.btn-logout { padding: 2px 8px !important; font-size: 0.7rem !important; }
|
||||
|
||||
/* Compact Titles */
|
||||
.page-title { font-size: 1.1rem !important; margin: 0 !important; padding: 2px 0 !important; }
|
||||
.page-header { margin-bottom: 5px !important; flex-direction: row !important; align-items: center !important; }
|
||||
|
||||
/* Compact Location Header */
|
||||
.location-header { padding: 5px !important; margin-bottom: 5px !important; border-width: 1px !important; }
|
||||
.location-label { font-size: 0.6rem !important; margin: 0 !important; }
|
||||
.location-name { font-size: 1.4rem !important; margin: 0 !important; }
|
||||
.stat-pill { padding: 2px 6px !important; font-size: 0.7rem !important; }
|
||||
|
||||
/* Compact Input Area */
|
||||
.scan-card { padding: 8px !important; margin-bottom: 6px !important; }
|
||||
.scan-header { display: none !important; } /* Hide "Scan Lot Barcode" text to save space */
|
||||
.scan-input {
|
||||
padding: 8px !important;
|
||||
font-size: 1.1rem !important;
|
||||
height: 40px !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
UPDATED LIST LAYOUT (Single Line, No Status Text)
|
||||
--------------------------------------------------------- */
|
||||
|
||||
/* Reconfigure grid to 3 columns: Lot | Item | Weight (Status hidden) */
|
||||
.scan-row, .expected-row {
|
||||
grid-template-columns: 2fr 1.5fr 1fr !important;
|
||||
gap: 5px !important;
|
||||
padding: 8px 6px !important;
|
||||
margin-bottom: 2px !important;
|
||||
font-size: 0.8rem !important;
|
||||
/* Increase background opacity so color is visible without the badge */
|
||||
}
|
||||
|
||||
/* Hide the status column completely */
|
||||
.scan-row-status { display: none !important; }
|
||||
|
||||
/* Make background colors slightly more visible since the text is gone */
|
||||
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; }
|
||||
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; }
|
||||
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; }
|
||||
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; }
|
||||
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; }
|
||||
|
||||
/* Font tweaks for readability on small screen */
|
||||
.scan-row-lot { font-size: 0.9rem !important; }
|
||||
.scan-row-item { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
|
||||
/* List Containers */
|
||||
.scans-section, .expected-section { padding: 4px !important; border-width: 1px !important; }
|
||||
.scans-header { margin-bottom: 2px !important; padding-bottom: 2px !important; }
|
||||
.scans-title { font-size: 0.85rem !important; }
|
||||
|
||||
/* Scrollable areas - constrain height so page doesn't scroll infinitely */
|
||||
.scans-grid { max-height: 150px !important; overflow-y: auto !important; }
|
||||
|
||||
/* Action Buttons (Fixed at bottom) */
|
||||
.finish-section { margin-top: 5px !important; padding-top: 5px !important; }
|
||||
.action-buttons-row { display: flex !important; gap: 5px !important; }
|
||||
.action-buttons-row .btn { padding: 8px !important; font-size: 0.8rem !important; height: 38px !important; }
|
||||
|
||||
/* Hide footer on scanner */
|
||||
.footer { display: none !important; }
|
||||
}
|
||||
/* ==================== SCANNING INTERFACE (COUNT LOCATION) ==================== */
|
||||
@media screen and (max-width: 480px) and (max-height: 600px) {
|
||||
|
||||
/* 1. Shrink the Location Header (R303D area) */
|
||||
.location-header {
|
||||
padding: 8px !important;
|
||||
margin-bottom: 5px !important;
|
||||
border-width: 1px !important;
|
||||
}
|
||||
.location-label { font-size: 0.6rem !important; margin: 0 !important; }
|
||||
.location-name { font-size: 1.5rem !important; margin: 0 !important; }
|
||||
.location-stats { gap: 5px !important; }
|
||||
.stat-pill { padding: 2px 8px !important; font-size: 0.7rem !important; }
|
||||
|
||||
/* 2. Compact Scan Input Card */
|
||||
.scan-card {
|
||||
padding: 10px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
.scan-header { margin-bottom: 5px !important; }
|
||||
.scan-title { font-size: 1rem !important; }
|
||||
.scan-input {
|
||||
padding: 10px !important;
|
||||
font-size: 1.1rem !important;
|
||||
height: 44px !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
/* 3. Reclaim Space from the Scanned List */
|
||||
.scans-section { padding: 5px !important; }
|
||||
.scans-header { margin-bottom: 4px !important; }
|
||||
.scans-title { font-size: 0.9rem !important; }
|
||||
.scans-grid { max-height: 180px !important; } /* Ensure list is visible but short */
|
||||
|
||||
/* 4. FIX THE GIANT FLOATING BUTTONS */
|
||||
.finish-section {
|
||||
position: relative !important; /* Stop it from floating over content */
|
||||
bottom: 0 !important;
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.action-buttons-row {
|
||||
display: flex !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
/* Make "Back" small and "Finish" primary but compact */
|
||||
.action-buttons-row .btn {
|
||||
flex: 1;
|
||||
padding: 8px !important;
|
||||
font-size: 0.85rem !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--color-success) !important;
|
||||
box-shadow: none !important; /* Remove glow to save rendering power */
|
||||
}
|
||||
|
||||
|
||||
/* Inside the @media query ... */
|
||||
|
||||
/* Make background colors slightly more visible since the text is gone */
|
||||
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; }
|
||||
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; }
|
||||
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; }
|
||||
|
||||
/* ADD THIS LINE: */
|
||||
.scan-row-weight_discrepancy { background: rgba(255, 152, 0, 0.25) !important; }
|
||||
|
||||
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; }
|
||||
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; }
|
||||
|
||||
/* Hide scroll buttons on desktop */
|
||||
.scroll-to-top,
|
||||
.scroll-to-bottom {
|
||||
display: none;
|
||||
}
|
||||
@@ -6,17 +6,25 @@
|
||||
<div class="dashboard-container">
|
||||
<!-- Mode Selector -->
|
||||
<div class="mode-selector">
|
||||
<button class="mode-btn mode-btn-active" onclick="window.location.href='{{ url_for('dashboard') }}'">
|
||||
<button class="mode-btn mode-btn-active" data-href="{{ url_for('dashboard') }}">
|
||||
👔 Admin Console
|
||||
</button>
|
||||
<button class="mode-btn" onclick="window.location.href='{{ url_for('staff_mode') }}'">
|
||||
<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">
|
||||
<h1 class="page-title">Admin Dashboard</h1>
|
||||
<a href="{{ url_for('create_session') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
||||
<span class="btn-icon">+</span> New Session
|
||||
</a>
|
||||
</div>
|
||||
@@ -59,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<a href="{{ url_for('session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
|
||||
<a href="{{ url_for('sessions.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
@@ -71,7 +79,7 @@
|
||||
<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('create_session') }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
|
||||
Create First Session
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<title>{% block title %}ScanLook{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scanner.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -25,7 +28,7 @@
|
||||
<div class="settings-dropdown">
|
||||
<button class="btn-settings" onclick="toggleSettings()">⚙️</button>
|
||||
<div id="settingsMenu" class="settings-menu">
|
||||
<a href="{{ url_for('manage_users') }}" class="settings-item">
|
||||
<a href="{{ url_for('users.manage_users') }}" class="settings-item">
|
||||
<span class="settings-icon">👥</span> Manage Users
|
||||
</a>
|
||||
</div>
|
||||
@@ -62,6 +65,8 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© 2026 Javier Torres. All Rights Reserved.</p>
|
||||
<p class="text-muted"><small>v{{ version }}</small></p>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
</html>
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<div class="scan-row scan-row-{{ row_class }}"
|
||||
data-entry-id="{{ scan.entry_id }}"
|
||||
onclick="openScanDetail({{ scan.entry_id }})">
|
||||
onclick="openScanDetail('{{ scan.entry_id }}')">
|
||||
<div class="scan-row-lot">{{ scan.lot_number }}</div>
|
||||
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
|
||||
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
<div class="finish-section">
|
||||
<div class="action-buttons-row">
|
||||
<a href="{{ url_for('my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
|
||||
<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()">
|
||||
@@ -168,6 +168,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="scroll-to-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="scroll-to-bottom" onclick="window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'})">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -191,7 +201,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
|
||||
});
|
||||
|
||||
function checkDuplicate() {
|
||||
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -260,7 +270,7 @@ function submitScan(weight) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -560,7 +570,7 @@ function deleteFromDetail(entryId) {
|
||||
function finishLocation() {
|
||||
if (!confirm('Are you finished counting this location?')) return;
|
||||
|
||||
fetch('{{ url_for("finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
fetch('{{ url_for("counting.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
})
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="bin-actions">
|
||||
<a href="{{ url_for('count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
|
||||
<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 }})">
|
||||
<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 }}')">
|
||||
<button class="btn btn-danger" onclick="deleteBinCount('{{ bin.location_count_id }}', '{{ bin.location_name }}')">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="startBinForm" action="{{ url_for('start_bin_count', session_id=count_session.session_id) }}" method="POST">
|
||||
<form id="startBinForm" action="{{ url_for('counting.start_bin_count', session_id=count_session.session_id) }}" method="POST">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bin Number *</label>
|
||||
<input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number">
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<div>
|
||||
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
|
||||
<h1 class="page-title">{{ count_session.session_name }}</h1>
|
||||
<!-- Fixed variable name from session.session_type to count_session.session_type -->
|
||||
<span class="session-type-badge session-type-{{ count_session.session_type }}">
|
||||
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,6 +31,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not count_session.master_baseline_timestamp %}
|
||||
<!-- Note: Using data_imports blueprint URL -->
|
||||
<form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
|
||||
<input type="file" name="csv_file" accept=".csv" required class="file-input">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button>
|
||||
@@ -41,8 +43,8 @@
|
||||
<div class="baseline-label">CURRENT Baseline (Optional)</div>
|
||||
<div class="baseline-status">
|
||||
{% if count_session.current_baseline_timestamp %}
|
||||
<span class="status-badge status-success">Last Updated:
|
||||
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div>
|
||||
<span class="status-badge status-success">✓ Uploaded</span>
|
||||
<small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
|
||||
{% else %}
|
||||
<span class="status-badge status-neutral">Not Uploaded</span>
|
||||
{% endif %}
|
||||
@@ -65,27 +67,27 @@
|
||||
<h2 class="section-title">Real-Time Statistics</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-match" onclick="showStatusDetails('match', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-match" onclick="showStatusDetails('match')">
|
||||
<div class="stat-number">{{ stats.matched or 0 }}</div>
|
||||
<div class="stat-label">✓ Matched</div>
|
||||
</div>
|
||||
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates')">
|
||||
<div class="stat-number">{{ stats.duplicates or 0 }}</div>
|
||||
<div class="stat-label">🔵 Duplicates</div>
|
||||
</div>
|
||||
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy')">
|
||||
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
|
||||
<div class="stat-label">⚖️ Weight Discrepancy</div>
|
||||
</div>
|
||||
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location')">
|
||||
<div class="stat-number">{{ stats.wrong_location or 0 }}</div>
|
||||
<div class="stat-label">⚠ Wrong Location</div>
|
||||
</div>
|
||||
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot')">
|
||||
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
|
||||
<div class="stat-label">🟣 Ghost Lots</div>
|
||||
</div>
|
||||
<div class="stat-card stat-missing" onclick="showStatusDetails('missing', {{ count_session.session_id }})">
|
||||
<div class="stat-card stat-missing" onclick="showStatusDetails('missing')">
|
||||
<div class="stat-number">{{ stats.missing_lots or 0 }}</div>
|
||||
<div class="stat-label">🔴 Missing</div>
|
||||
</div>
|
||||
@@ -129,7 +131,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loc in locations %}
|
||||
<tr class="location-row-clickable" onclick="showLocationDetails({{ loc.location_count_id }}, '{{ loc.location_name }}', '{{ loc.status }}')">
|
||||
<!-- Refactored to use data attributes instead of direct Jinja injection in onclick -->
|
||||
<tr class="location-row-clickable"
|
||||
data-id="{{ loc.location_count_id }}"
|
||||
data-name="{{ loc.location_name }}"
|
||||
data-status="{{ loc.status }}"
|
||||
onclick="handleLocationClick(this)">
|
||||
<td><strong>{{ loc.location_name }}</strong></td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ loc.status }}">
|
||||
@@ -246,7 +253,10 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showStatusDetails(status, sessionId) {
|
||||
// Store the Session ID globally to use in functions without passing it every time
|
||||
const CURRENT_SESSION_ID = "{{ count_session.session_id }}";
|
||||
|
||||
function showStatusDetails(status) {
|
||||
document.getElementById('statusModal').style.display = 'flex';
|
||||
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
|
||||
|
||||
@@ -261,8 +271,8 @@ function showStatusDetails(status, sessionId) {
|
||||
};
|
||||
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
|
||||
|
||||
// Fetch details
|
||||
fetch(`/session/${sessionId}/status-details/${status}`)
|
||||
// Fetch details using the blueprint URL structure
|
||||
fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
@@ -421,6 +431,14 @@ let currentLocationName = '';
|
||||
let currentLocationStatus = '';
|
||||
let currentLocationData = null;
|
||||
|
||||
// New helper function to handle click from data attributes
|
||||
function handleLocationClick(row) {
|
||||
const id = row.getAttribute('data-id');
|
||||
const name = row.getAttribute('data-name');
|
||||
const status = row.getAttribute('data-status');
|
||||
showLocationDetails(id, name, status);
|
||||
}
|
||||
|
||||
function showLocationDetails(locationCountId, locationName, status) {
|
||||
currentLocationId = locationCountId;
|
||||
currentLocationName = locationName;
|
||||
@@ -594,6 +612,7 @@ function closeFinalizeConfirm() {
|
||||
}
|
||||
|
||||
function confirmFinalize() {
|
||||
// Note: The /complete endpoint is handled by blueprints/counting.py
|
||||
fetch(`/location/${currentLocationId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -625,6 +644,7 @@ function closeReopenConfirm() {
|
||||
}
|
||||
|
||||
function confirmReopen() {
|
||||
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
|
||||
fetch(`/location/${currentLocationId}/reopen`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<!-- Mode Selector (only for admins) -->
|
||||
{% if session.role in ['owner', 'admin'] %}
|
||||
<div class="mode-selector">
|
||||
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'">
|
||||
👔 Admin Console
|
||||
</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="mode-btn">
|
||||
Admin Console
|
||||
</a>
|
||||
<button class="mode-btn mode-btn-active">
|
||||
📦 Scanning Mode
|
||||
Scanning Mode
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item">
|
||||
{% for s in sessions %}
|
||||
<a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
|
||||
<div class="session-list-info">
|
||||
<h3 class="session-list-name">{{ session.session_name }}</h3>
|
||||
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span>
|
||||
<h3 class="session-list-name">{{ s.session_name }}</h3>
|
||||
<span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>
|
||||
</div>
|
||||
<div class="session-list-action">
|
||||
<span class="arrow-icon">→</span>
|
||||
|
||||
31
utils.py
Normal file
31
utils.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from functools import wraps
|
||||
from flask import session, flash, redirect, url_for
|
||||
from db import query_db
|
||||
|
||||
def login_required(f):
|
||||
"""Require login for route"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Please log in to access this page', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def role_required(*roles):
|
||||
"""Require specific role(s) for route"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Please log in to access this page', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
|
||||
if not user or user['role'] not in roles:
|
||||
flash('You do not have permission to access this page', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
Reference in New Issue
Block a user