Compare commits

...

5 Commits

24 changed files with 1892 additions and 1545 deletions

46
AI Prompt.txt Normal file
View 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.** Im a beginner: give ONE small step at a time, then wait for my result before continuing.
4) **No command dumps.** Dont 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 were working on: Cycle Count or Physical Inventory.
- Ask for the minimum needed info (36 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.

Binary file not shown.

1249
app.py

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

View File

@@ -17,12 +17,12 @@ def upload_current(session_id):
if 'csv_file' not in request.files: if 'csv_file' not in request.files:
flash('No file part', 'danger') 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'] file = request.files['csv_file']
if file.filename == '': if file.filename == '':
flash('No selected file', 'danger') 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: if file:
conn = get_db() conn = get_db()
@@ -76,7 +76,7 @@ def upload_current(session_id):
finally: finally:
conn.close() 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) --- # --- ROUTE 2: Upload MASTER Baseline (Session Specific) ---
@data_imports_bp.route('/session/<int:session_id>/upload_master', methods=['POST']) @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: if 'csv_file' not in request.files:
flash('No file uploaded', 'danger') 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'] file = request.files['csv_file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') 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() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
@@ -152,4 +152,4 @@ def upload_master(session_id):
finally: finally:
conn.close() 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
View 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
View 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
View 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
View 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;
}
}

View File

@@ -1,5 +1,6 @@
/* ScanLook - Inventory Management System CSS */ /* 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'); @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 { .scan-row-weight_discrepancy {
border-color: #ff9800; /* Orange Border */ border-color: #ff9800;
background: rgba(255, 152, 0, 0.05); /* Very light orange background */ background: rgba(255, 152, 0, 0.05);
} }
.scan-row-wrong_location { .scan-row-wrong_location {
border-color: var(--color-warning); border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.05); background: rgba(255, 170, 0, 0.05);
@@ -1696,12 +1698,6 @@ body {
gap: var(--space-lg); gap: var(--space-lg);
} }
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
.finish-section { .finish-section {
position: sticky; position: sticky;
bottom: var(--space-lg); bottom: var(--space-lg);
@@ -1713,123 +1709,6 @@ body {
gap: var(--space-md); 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 */ /* Scrollbar Styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;
@@ -1926,21 +1805,6 @@ body {
color: var(--color-text-muted); 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 { .detail-table td {
padding: var(--space-md); padding: var(--space-md);
border: none; border: none;
@@ -2118,10 +1982,6 @@ body {
gap: var(--space-sm); gap: var(--space-sm);
} }
.btn-block {
width: 100%;
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -2233,9 +2093,8 @@ body {
/* Expected rows look "pending" or "unscanned" */ /* Expected rows look "pending" or "unscanned" */
.expected-row { .expected-row {
opacity: 0.7; opacity: 0.7;
border-left: 4px solid var(--color-border); /* Neutral/Gray border */ border-left: 4px solid var(--color-border);
background: var(--color-surface); /* Darker than scanned rows */ background: var(--color-surface);
/* Use the same grid layout as scanned rows */
display: grid; display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr; grid-template-columns: 2fr 1fr 1fr 1.5fr;
gap: var(--space-md); gap: var(--space-md);
@@ -2255,156 +2114,8 @@ body {
box-shadow: none; box-shadow: none;
} }
/* ==================== ZEBRA MC9300 CONSOLIDATED FIX ==================== */ /* Hide scroll buttons on desktop */
@media screen and (max-width: 480px) and (max-height: 600px) { .scroll-to-top,
:root { .scroll-to-bottom {
--space-2xl: 0.8rem !important; display: none;
--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; }
} }

View File

@@ -6,17 +6,25 @@
<div class="dashboard-container"> <div class="dashboard-container">
<!-- Mode Selector --> <!-- Mode Selector -->
<div class="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 👔 Admin Console
</button> </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 📦 Scanning Mode
</button> </button>
</div> </div>
<script>
document.querySelectorAll('.mode-selector button').forEach(btn => {
btn.addEventListener('click', function() {
window.location.href = this.getAttribute('data-href');
});
});
</script>
<div class="dashboard-header"> <div class="dashboard-header">
<h1 class="page-title">Admin Dashboard</h1> <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 <span class="btn-icon">+</span> New Session
</a> </a>
</div> </div>
@@ -59,7 +67,7 @@
</div> </div>
<div class="session-actions"> <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 View Details
</a> </a>
</div> </div>
@@ -71,7 +79,7 @@
<div class="empty-icon">📋</div> <div class="empty-icon">📋</div>
<h2 class="empty-title">No Active Sessions</h2> <h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Create a new count session to get started</p> <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 Create First Session
</a> </a>
</div> </div>

View File

@@ -8,6 +8,9 @@
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{% block title %}ScanLook{% endblock %}</title> <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/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 %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
@@ -25,7 +28,7 @@
<div class="settings-dropdown"> <div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button> <button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<div id="settingsMenu" class="settings-menu"> <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 <span class="settings-icon">👥</span> Manage Users
</a> </a>
</div> </div>
@@ -62,6 +65,8 @@
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
<p>&copy; 2026 Javier Torres. All Rights Reserved.</p> <p>&copy; 2026 Javier Torres. All Rights Reserved.</p>
<p class="text-muted"><small>v{{ version }}</small></p>
</div> </div>
</footer> </footer>
</html> </html>

View File

@@ -97,9 +97,9 @@
{% set row_class = 'weight_discrepancy' %} {% set row_class = 'weight_discrepancy' %}
{% endif %} {% endif %}
<div class="scan-row scan-row-{{ row_class }}" <div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}" 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-lot">{{ scan.lot_number }}</div>
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div> <div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div> <div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
@@ -160,7 +160,7 @@
<div class="finish-section"> <div class="finish-section">
<div class="action-buttons-row"> <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 ← Back to My Counts
</a> </a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()"> <button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
@@ -168,6 +168,16 @@
</button> </button>
</div> </div>
</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> </div>
<script> <script>
@@ -191,7 +201,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
}); });
function checkDuplicate() { 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -260,7 +270,7 @@ function submitScan(weight) {
return; 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -560,7 +570,7 @@ function deleteFromDetail(entryId) {
function finishLocation() { function finishLocation() {
if (!confirm('Are you finished counting this location?')) return; 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', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })

View File

@@ -37,14 +37,14 @@
</div> </div>
</div> </div>
<div class="bin-actions"> <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 Resume Counting
</a> </a>
<div class="bin-actions-row"> <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 ✓ Mark Complete
</button> </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 🗑️ Delete
</button> </button>
</div> </div>
@@ -105,7 +105,7 @@
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button> <button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button>
</div> </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"> <div class="form-group">
<label class="form-label">Bin Number *</label> <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"> <input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number">

View File

@@ -8,8 +8,9 @@
<div> <div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a> <a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">{{ count_session.session_name }}</h1> <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 }}"> <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> </span>
</div> </div>
</div> </div>
@@ -30,6 +31,7 @@
{% endif %} {% endif %}
</div> </div>
{% if not count_session.master_baseline_timestamp %} {% 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"> <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"> <input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button> <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-label">CURRENT Baseline (Optional)</div>
<div class="baseline-status"> <div class="baseline-status">
{% if count_session.current_baseline_timestamp %} {% if count_session.current_baseline_timestamp %}
<span class="status-badge status-success">Last Updated: <span class="status-badge status-success">✓ Uploaded</span>
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div> <small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
{% else %} {% else %}
<span class="status-badge status-neutral">Not Uploaded</span> <span class="status-badge status-neutral">Not Uploaded</span>
{% endif %} {% endif %}
@@ -65,27 +67,27 @@
<h2 class="section-title">Real-Time Statistics</h2> <h2 class="section-title">Real-Time Statistics</h2>
<div class="stats-grid"> <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-number">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div> <div class="stat-label">✓ Matched</div>
</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-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div> <div class="stat-label">🔵 Duplicates</div>
</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-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div> <div class="stat-label">⚖️ Weight Discrepancy</div>
</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-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div> <div class="stat-label">⚠ Wrong Location</div>
</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-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div> <div class="stat-label">🟣 Ghost Lots</div>
</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-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div> <div class="stat-label">🔴 Missing</div>
</div> </div>
@@ -129,7 +131,12 @@
</thead> </thead>
<tbody> <tbody>
{% for loc in locations %} {% 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><strong>{{ loc.location_name }}</strong></td>
<td> <td>
<span class="status-badge status-{{ loc.status }}"> <span class="status-badge status-{{ loc.status }}">
@@ -246,7 +253,10 @@
</div> </div>
<script> <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('statusModal').style.display = 'flex';
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>'; 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'; document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details // Fetch details using the blueprint URL structure
fetch(`/session/${sessionId}/status-details/${status}`) fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -421,6 +431,14 @@ let currentLocationName = '';
let currentLocationStatus = ''; let currentLocationStatus = '';
let currentLocationData = null; 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) { function showLocationDetails(locationCountId, locationName, status) {
currentLocationId = locationCountId; currentLocationId = locationCountId;
currentLocationName = locationName; currentLocationName = locationName;
@@ -594,6 +612,7 @@ function closeFinalizeConfirm() {
} }
function confirmFinalize() { function confirmFinalize() {
// Note: The /complete endpoint is handled by blueprints/counting.py
fetch(`/location/${currentLocationId}/complete`, { fetch(`/location/${currentLocationId}/complete`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -625,6 +644,7 @@ function closeReopenConfirm() {
} }
function confirmReopen() { function confirmReopen() {
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
fetch(`/location/${currentLocationId}/reopen`, { fetch(`/location/${currentLocationId}/reopen`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -656,4 +676,4 @@ document.addEventListener('keydown', function(e) {
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -7,11 +7,11 @@
<!-- Mode Selector (only for admins) --> <!-- Mode Selector (only for admins) -->
{% if session.role in ['owner', 'admin'] %} {% if session.role in ['owner', 'admin'] %}
<div class="mode-selector"> <div class="mode-selector">
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'"> <a href="{{ url_for('dashboard') }}" class="mode-btn">
👔 Admin Console Admin Console
</button> </a>
<button class="mode-btn mode-btn-active"> <button class="mode-btn mode-btn-active">
📦 Scanning Mode Scanning Mode
</button> </button>
</div> </div>
{% endif %} {% endif %}
@@ -22,11 +22,11 @@
{% if sessions %} {% if sessions %}
<div class="sessions-list"> <div class="sessions-list">
{% for session in sessions %} {% for s in sessions %}
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item"> <a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
<div class="session-list-info"> <div class="session-list-info">
<h3 class="session-list-name">{{ session.session_name }}</h3> <h3 class="session-list-name">{{ s.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span> <span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>
</div> </div>
<div class="session-list-action"> <div class="session-list-action">
<span class="arrow-icon"></span> <span class="arrow-icon"></span>

31
utils.py Normal file
View 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