v0.12.0 - Add modular system architecture with user-based module access
- Add Modules and UserModules database tables - Create home page with module selection grid - Implement per-user module assignment in user management - Add route guards for module access control - Refactor navigation: login -> home -> modules, admin console via button - Add Font Awesome icons
This commit is contained in:
@@ -29,6 +29,9 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r
|
|||||||
5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
|
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.
|
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
|
||||||
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
|
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
|
||||||
|
8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
|
||||||
|
9) **Database changes:** The app auto-initializes the database if it doesn't exist. Schema is in /database/init_db.py.
|
||||||
|
10) **Docker deployment:** Production runs in Docker on Linux (jisoo). Volume mounts only /app/database to preserve data between updates.
|
||||||
|
|
||||||
## How you should respond
|
## How you should respond
|
||||||
- Start by confirming which mode we’re working on: Cycle Count or Physical Inventory.
|
- Start by confirming which mode we’re working on: Cycle Count or Physical Inventory.
|
||||||
@@ -37,10 +40,53 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r
|
|||||||
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
|
- 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.
|
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
|
||||||
|
|
||||||
## First response checklist (every new task)
|
## Scanlook (current product summary)
|
||||||
Ask for:
|
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
|
||||||
- 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
|
**Current Version:** 0.11.3
|
||||||
- How the Master Inventory list is formatted (CSV columns)
|
|
||||||
- What “Finalize BIN” should do exactly (lock? allow reopen? who can override?)
|
**Tech Stack:**
|
||||||
Then proceed one step at a time.
|
- Backend: Python/Flask, raw SQL (no ORM)
|
||||||
|
- Database: SQLite (located in /database/scanlook.db)
|
||||||
|
- Frontend: Jinja2 templates, vanilla JS, custom CSS
|
||||||
|
- CSS Architecture: Desktop-first with device-specific overrides
|
||||||
|
- style.css (base/desktop)
|
||||||
|
- mobile.css (phones, 360-767px)
|
||||||
|
- scanner.css (MC9300 scanners, max-width 359px)
|
||||||
|
- Deployment: Docker container, Gitea for version control + container registry
|
||||||
|
|
||||||
|
**Project Structure:**
|
||||||
|
- app.py (main Flask app, routes for auth + dashboard)
|
||||||
|
- /blueprints/ (modular routes: counting.py, sessions.py, users.py, data_imports.py, admin_locations.py)
|
||||||
|
- /templates/ (Jinja2 HTML templates)
|
||||||
|
- /static/css/ (style.css, mobile.css, scanner.css)
|
||||||
|
- /database/ (scanlook.db, init_db.py)
|
||||||
|
- db.py (database helper functions: query_db, execute_db)
|
||||||
|
- utils.py (decorators: login_required, role_required)
|
||||||
|
|
||||||
|
**Key Features (implemented):**
|
||||||
|
- Count Sessions with archive/activate functionality
|
||||||
|
- Master baseline upload (CSV)
|
||||||
|
- Current baseline upload (optional, for comparison)
|
||||||
|
- Staff scanning interface optimized for MC9300 Zebra scanners
|
||||||
|
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
|
||||||
|
- Location/BIN workflow with Expected → Scanned flow
|
||||||
|
- Session isolation (archived sessions blocked from access)
|
||||||
|
- Role-based access: owner, admin, staff
|
||||||
|
- Auto-initialize database on first run
|
||||||
|
|
||||||
|
**Two count types:**
|
||||||
|
1. Cycle Count: shows Expected list for the BIN
|
||||||
|
2. Physical Inventory: blind count (no Expected list shown)
|
||||||
|
|
||||||
|
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
- Database: SQLite at /database/scanlook.db
|
||||||
|
- Scanner viewport: 320px wide (MC9300)
|
||||||
|
- Mobile breakpoint: 360-767px
|
||||||
|
- Desktop: 768px+
|
||||||
|
- Git remote: http://10.44.44.33:3000/stuff/ScanLook.git
|
||||||
|
- Docker registry: 10.44.44.33:3000/stuff/scanlook
|
||||||
39
app.py
39
app.py
@@ -36,7 +36,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
|
|||||||
|
|
||||||
|
|
||||||
# 1. Define the version
|
# 1. Define the version
|
||||||
APP_VERSION = '0.11.3'
|
APP_VERSION = '0.12.0'
|
||||||
|
|
||||||
# 2. Inject it into all templates automatically
|
# 2. Inject it into all templates automatically
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -59,7 +59,7 @@ if not os.path.exists(db_path):
|
|||||||
def index():
|
def index():
|
||||||
"""Landing page - redirect based on login status"""
|
"""Landing page - redirect based on login status"""
|
||||||
if 'user_id' in session:
|
if 'user_id' in session:
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('home'))
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ def login():
|
|||||||
session['full_name'] = user['full_name']
|
session['full_name'] = user['full_name']
|
||||||
session['role'] = user['role']
|
session['role'] = user['role']
|
||||||
flash(f'Welcome back, {user["full_name"]}!', 'success')
|
flash(f'Welcome back, {user["full_name"]}!', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('home'))
|
||||||
else:
|
else:
|
||||||
flash('Invalid username or password', 'danger')
|
flash('Invalid username or password', 'danger')
|
||||||
|
|
||||||
@@ -94,11 +94,30 @@ def logout():
|
|||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ROUTES: HOME ====================
|
||||||
|
@app.route('/home')
|
||||||
|
@login_required
|
||||||
|
def home():
|
||||||
|
"""Module selection landing page"""
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
|
||||||
|
# Get modules this user has access to
|
||||||
|
modules = query_db('''
|
||||||
|
SELECT m.module_id, m.module_name, m.module_key, m.description, m.icon
|
||||||
|
FROM Modules m
|
||||||
|
JOIN UserModules um ON m.module_id = um.module_id
|
||||||
|
WHERE um.user_id = ? AND m.is_active = 1
|
||||||
|
ORDER BY m.display_order
|
||||||
|
''', [user_id])
|
||||||
|
|
||||||
|
return render_template('home.html', modules=modules)
|
||||||
|
|
||||||
|
|
||||||
# ==================== ROUTES: DASHBOARD ====================
|
# ==================== ROUTES: DASHBOARD ====================
|
||||||
|
|
||||||
@app.route('/dashboard')
|
@app.route('/admin')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def admin_dashboard():
|
||||||
"""Main dashboard - different views for admin vs staff"""
|
"""Main dashboard - different views for admin vs staff"""
|
||||||
role = session.get('role')
|
role = session.get('role')
|
||||||
|
|
||||||
@@ -137,16 +156,6 @@ def dashboard():
|
|||||||
|
|
||||||
return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
|
return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
|
||||||
|
|
||||||
else:
|
|
||||||
# Staff dashboard
|
|
||||||
active_sessions = query_db('''
|
|
||||||
SELECT session_id, session_name, session_type, created_timestamp
|
|
||||||
FROM CountSessions
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY created_timestamp DESC
|
|
||||||
''')
|
|
||||||
|
|
||||||
return render_template('staff_dashboard.html', sessions=active_sessions)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/staff-mode')
|
@app.route('/staff-mode')
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -10,6 +10,32 @@ def get_active_session(session_id):
|
|||||||
return None
|
return None
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
@counting_bp.route('/counts')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""Counts module landing - show active sessions"""
|
||||||
|
# Check if user has access to this module
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
has_access = query_db('''
|
||||||
|
SELECT 1 FROM UserModules um
|
||||||
|
JOIN Modules m ON um.module_id = m.module_id
|
||||||
|
WHERE um.user_id = ? AND m.module_key = 'counting' AND m.is_active = 1
|
||||||
|
''', [user_id], one=True)
|
||||||
|
|
||||||
|
if not has_access:
|
||||||
|
flash('You do not have access to this module', 'danger')
|
||||||
|
return redirect(url_for('home'))
|
||||||
|
|
||||||
|
active_sessions = query_db('''
|
||||||
|
SELECT session_id, session_name, session_type, created_timestamp
|
||||||
|
FROM CountSessions
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY created_timestamp DESC
|
||||||
|
''')
|
||||||
|
|
||||||
|
return render_template('staff_dashboard.html', sessions=active_sessions)
|
||||||
|
|
||||||
|
|
||||||
@counting_bp.route('/count/<int:session_id>')
|
@counting_bp.route('/count/<int:session_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def count_session(session_id):
|
def count_session(session_id):
|
||||||
@@ -19,7 +45,7 @@ def count_session(session_id):
|
|||||||
|
|
||||||
if not sess:
|
if not sess:
|
||||||
flash('Session not found or not active', 'danger')
|
flash('Session not found or not active', 'danger')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('counting.index'))
|
||||||
|
|
||||||
# Redirect to my_counts page (staff can manage multiple bins)
|
# Redirect to my_counts page (staff can manage multiple bins)
|
||||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||||
@@ -33,11 +59,11 @@ def my_counts(session_id):
|
|||||||
|
|
||||||
if not sess:
|
if not sess:
|
||||||
flash('Session not found', 'danger')
|
flash('Session not found', 'danger')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('counting.index'))
|
||||||
|
|
||||||
if sess['status'] == 'archived':
|
if sess['status'] == 'archived':
|
||||||
flash('This session has been archived', 'warning')
|
flash('This session has been archived', 'warning')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('counting.index'))
|
||||||
|
|
||||||
# Get this user's active bins
|
# Get this user's active bins
|
||||||
active_bins = query_db('''
|
active_bins = query_db('''
|
||||||
@@ -78,7 +104,7 @@ def start_bin_count(session_id):
|
|||||||
sess = get_active_session(session_id)
|
sess = get_active_session(session_id)
|
||||||
if not sess:
|
if not sess:
|
||||||
flash('Session not found or archived', 'warning')
|
flash('Session not found or archived', 'warning')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('counting.index'))
|
||||||
if not sess['master_baseline_timestamp']:
|
if not sess['master_baseline_timestamp']:
|
||||||
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
|
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
|
||||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||||
@@ -146,7 +172,7 @@ def count_location(session_id, location_count_id):
|
|||||||
sess = get_active_session(session_id)
|
sess = get_active_session(session_id)
|
||||||
if not sess:
|
if not sess:
|
||||||
flash('Session not found or archived', 'warning')
|
flash('Session not found or archived', 'warning')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('counting.index'))
|
||||||
if not sess['master_baseline_timestamp']:
|
if not sess['master_baseline_timestamp']:
|
||||||
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
|
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
|
||||||
return redirect(url_for('counting.my_counts', session_id=session_id))
|
return redirect(url_for('counting.my_counts', session_id=session_id))
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ def manage_users():
|
|||||||
# Admins can only see staff
|
# Admins can only see staff
|
||||||
users = query_db("SELECT * FROM Users WHERE role = 'staff' ORDER BY full_name")
|
users = query_db("SELECT * FROM Users WHERE role = 'staff' ORDER BY full_name")
|
||||||
|
|
||||||
return render_template('manage_users.html', users=users)
|
# Get all active modules
|
||||||
|
modules = query_db('SELECT * FROM Modules WHERE is_active = 1 ORDER BY display_order')
|
||||||
|
|
||||||
|
return render_template('manage_users.html', users=users, modules=modules)
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/settings/users/add', methods=['POST'])
|
@users_bp.route('/settings/users/add', methods=['POST'])
|
||||||
@@ -192,3 +195,43 @@ def delete_user(user_id):
|
|||||||
return jsonify({'success': True, 'message': 'User deleted successfully'})
|
return jsonify({'success': True, 'message': 'User deleted successfully'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'message': f'Error deleting user: {str(e)}'})
|
return jsonify({'success': False, 'message': f'Error deleting user: {str(e)}'})
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/settings/users/<int:user_id>/modules', methods=['GET'])
|
||||||
|
@role_required('owner', 'admin')
|
||||||
|
def get_user_modules(user_id):
|
||||||
|
"""Get modules assigned to a user"""
|
||||||
|
modules = query_db('''
|
||||||
|
SELECT module_id FROM UserModules WHERE user_id = ?
|
||||||
|
''', [user_id])
|
||||||
|
|
||||||
|
module_ids = [m['module_id'] for m in modules]
|
||||||
|
return jsonify({'success': True, 'module_ids': module_ids})
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.route('/settings/users/<int:user_id>/modules', methods=['POST'])
|
||||||
|
@role_required('owner', 'admin')
|
||||||
|
def update_user_modules(user_id):
|
||||||
|
"""Update modules assigned to a user"""
|
||||||
|
data = request.get_json()
|
||||||
|
module_ids = data.get('module_ids', [])
|
||||||
|
|
||||||
|
# Verify user exists
|
||||||
|
user = query_db('SELECT user_id FROM Users WHERE user_id = ?', [user_id], one=True)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'success': False, 'message': 'User not found'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove all current assignments
|
||||||
|
execute_db('DELETE FROM UserModules WHERE user_id = ?', [user_id])
|
||||||
|
|
||||||
|
# Add new assignments
|
||||||
|
for module_id in module_ids:
|
||||||
|
execute_db('''
|
||||||
|
INSERT INTO UserModules (user_id, module_id, granted_by)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
''', [user_id, module_id, session['user_id']])
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Modules updated'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'message': str(e)})
|
||||||
@@ -32,6 +32,35 @@ def init_database():
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Modules Table - defines available system modules
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Modules (
|
||||||
|
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
module_name TEXT UNIQUE NOT NULL,
|
||||||
|
module_key TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
display_order INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# UserModules Table - which modules each user can access
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS UserModules (
|
||||||
|
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
module_id INTEGER NOT NULL,
|
||||||
|
granted_by INTEGER,
|
||||||
|
granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES Users(user_id),
|
||||||
|
FOREIGN KEY (module_id) REFERENCES Modules(module_id),
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES Users(user_id),
|
||||||
|
UNIQUE(user_id, module_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
# CountSessions Table
|
# CountSessions Table
|
||||||
# NOTE: current_baseline_version removed - CURRENT is now global
|
# NOTE: current_baseline_version removed - CURRENT is now global
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
|
|||||||
@@ -2152,3 +2152,77 @@ body {
|
|||||||
.session-actions-header {
|
.session-actions-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== MODULE GRID (Home Page) ==================== */
|
||||||
|
|
||||||
|
.module-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-xl);
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-2xl) var(--space-xl);
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-glow), var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--color-primary-glow);
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-card:hover .module-icon {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
box-shadow: 0 0 30px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
/* ==================== MODAL SCROLL FIX ==================== */
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-height: none;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
<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" data-href="{{ url_for('dashboard') }}">
|
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Back to Home
|
||||||
|
</a>
|
||||||
|
<button class="mode-btn mode-btn-active" data-href="{{ url_for('admin_dashboard') }}">
|
||||||
👔 Admin Console
|
👔 Admin Console
|
||||||
</button>
|
</button>
|
||||||
<button class="mode-btn" data-href="{{ url_for('staff_mode') }}">
|
<button class="mode-btn" data-href="{{ url_for('staff_mode') }}">
|
||||||
@@ -38,7 +41,7 @@
|
|||||||
<script>
|
<script>
|
||||||
function toggleArchived() {
|
function toggleArchived() {
|
||||||
const checked = document.getElementById('showArchived').checked;
|
const checked = document.getElementById('showArchived').checked;
|
||||||
window.location.href = '{{ url_for("dashboard") }}' + (checked ? '?show_archived=1' : '');
|
window.location.href = '{{ url_for("admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<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/mobile.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/scanner.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/scanner.css') }}">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<div class="nav-left">
|
<div class="nav-left">
|
||||||
<a href="{{ url_for('dashboard') }}" class="logo">
|
<a href="{{ url_for('home') }}" class="logo">
|
||||||
<span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
|
<span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="count-container">
|
<div class="count-container">
|
||||||
<div class="count-header">
|
<div class="count-header">
|
||||||
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back</a>
|
<a href="{{ url_for('admin_dashboard') }}" class="breadcrumb">← Back</a>
|
||||||
<h1 class="page-title">{{ session.session_name }}</h1>
|
<h1 class="page-title">{{ session.session_name }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Create Session</button>
|
<button type="submit" class="btn btn-primary">Create Session</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
42
templates/home.html
Normal file
42
templates/home.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Home - ScanLook{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard-container">
|
||||||
|
|
||||||
|
<!-- Admin Button (only for admins/owners) -->
|
||||||
|
{% if session.role in ['owner', 'admin'] %}
|
||||||
|
<div class="mode-selector">
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="mode-btn">
|
||||||
|
👔 Admin Console
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1 class="page-title">Welcome, {{ session.full_name }}</h1>
|
||||||
|
<p class="page-subtitle">Select a module to get started</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if modules %}
|
||||||
|
<div class="module-grid">
|
||||||
|
{% for m in modules %}
|
||||||
|
<a href="{{ url_for(m.module_key + '.index') }}" class="module-card">
|
||||||
|
<div class="module-icon">
|
||||||
|
<i class="fa-solid {{ m.icon }}"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="module-name">{{ m.module_name }}</h3>
|
||||||
|
<p class="module-desc">{{ m.description }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🔒</div>
|
||||||
|
<h2 class="empty-title">No Modules Available</h2>
|
||||||
|
<p class="empty-text">You don't have access to any modules. Please contact your administrator.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -129,6 +129,18 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Module Access</label>
|
||||||
|
<div class="module-checkboxes" id="moduleCheckboxes">
|
||||||
|
{% for module in modules %}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="modules" value="{{ module.module_id }}" class="module-checkbox">
|
||||||
|
{{ module.module_name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
|
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Save User</button>
|
<button type="submit" class="btn btn-primary">Save User</button>
|
||||||
@@ -162,9 +174,10 @@ function openAddUser() {
|
|||||||
document.getElementById('passwordOptional').style.display = 'none';
|
document.getElementById('passwordOptional').style.display = 'none';
|
||||||
document.getElementById('password').required = true;
|
document.getElementById('password').required = true;
|
||||||
document.getElementById('activeToggleGroup').style.display = 'none';
|
document.getElementById('activeToggleGroup').style.display = 'none';
|
||||||
|
// Uncheck all modules for new user
|
||||||
|
document.querySelectorAll('.module-checkbox').forEach(cb => cb.checked = false);
|
||||||
document.getElementById('userModal').style.display = 'flex';
|
document.getElementById('userModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditUser(userId) {
|
function openEditUser(userId) {
|
||||||
editingUserId = userId;
|
editingUserId = userId;
|
||||||
document.getElementById('modalTitle').textContent = 'Edit User';
|
document.getElementById('modalTitle').textContent = 'Edit User';
|
||||||
@@ -191,7 +204,22 @@ function openEditUser(userId) {
|
|||||||
const isEditingSelf = user.user_id === {{ session.user_id }};
|
const isEditingSelf = user.user_id === {{ session.user_id }};
|
||||||
document.getElementById('role').disabled = isEditingSelf;
|
document.getElementById('role').disabled = isEditingSelf;
|
||||||
|
|
||||||
|
// Load user's modules
|
||||||
|
fetch('/settings/users/' + userId + '/modules')
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.then(moduleData => {
|
||||||
|
// Uncheck all first
|
||||||
|
document.querySelectorAll('.module-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
// Check assigned modules
|
||||||
|
if (moduleData.success) {
|
||||||
|
moduleData.module_ids.forEach(id => {
|
||||||
|
const cb = document.querySelector(`.module-checkbox[value="${id}"]`);
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Show modal after modules are loaded
|
||||||
document.getElementById('userModal').style.display = 'flex';
|
document.getElementById('userModal').style.display = 'flex';
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
}
|
}
|
||||||
@@ -201,7 +229,6 @@ function openEditUser(userId) {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUserModal() {
|
function closeUserModal() {
|
||||||
document.getElementById('userModal').style.display = 'none';
|
document.getElementById('userModal').style.display = 'none';
|
||||||
document.getElementById('userForm').reset();
|
document.getElementById('userForm').reset();
|
||||||
@@ -234,8 +261,23 @@ document.getElementById('userForm').addEventListener('submit', function(e) {
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Save modules if editing existing user
|
||||||
|
if (userId) {
|
||||||
|
const moduleIds = Array.from(document.querySelectorAll('.module-checkbox:checked'))
|
||||||
|
.map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
fetch(`/settings/users/${userId}/modules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ module_ids: moduleIds })
|
||||||
|
}).then(() => {
|
||||||
closeUserModal();
|
closeUserModal();
|
||||||
location.reload();
|
location.reload();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
closeUserModal();
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(data.message);
|
alert(data.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
|
<a href="{{ url_for('counting.index') }}" class="breadcrumb">← Back to Sessions</a>
|
||||||
<h1 class="page-title">My Active Counts</h1>
|
<h1 class="page-title">My Active Counts</h1>
|
||||||
<p class="page-subtitle">{{ count_session.session_name }}</p>
|
<p class="page-subtitle">{{ count_session.session_name }}</p>
|
||||||
{% if not count_session.master_baseline_timestamp %}
|
{% if not count_session.master_baseline_timestamp %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="session-detail-container">
|
<div class="session-detail-container">
|
||||||
<div class="session-detail-header">
|
<div class="session-detail-header">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('dashboard') }}{% if count_session.status == 'archived' %}?show_archived=1{% endif %}" class="breadcrumb">← Back to Dashboard</a>
|
<a href="{{ url_for('admin_dashboard') }}{% if count_session.status == 'archived' %}?show_archived=1{% endif %}" class="breadcrumb">← Back to Dashboard</a>
|
||||||
<h1 class="page-title">
|
<h1 class="page-title">
|
||||||
{{ count_session.session_name }}
|
{{ count_session.session_name }}
|
||||||
{% if count_session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
{% if count_session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
|
||||||
@@ -694,7 +694,7 @@ function archiveSession() {
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
window.location.href = '{{ url_for("dashboard") }}';
|
window.location.href = '{{ url_for("admin_dashboard") }}';
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || 'Error archiving session');
|
alert(data.message || 'Error archiving session');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,19 @@
|
|||||||
<!-- 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">
|
||||||
<a href="{{ url_for('dashboard') }}" class="mode-btn">
|
<a href="{{ url_for('admin_dashboard') }}" class="mode-btn">
|
||||||
Admin Console
|
👔 Admin Console
|
||||||
</a>
|
</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 %}
|
||||||
|
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
|
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md);">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i> Back to Home
|
||||||
|
</a>
|
||||||
<h1 class="page-title">Select Count Session</h1>
|
<h1 class="page-title">Select Count Session</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user