3 Commits

Author SHA1 Message Date
Javier
56b0d6d398 feat: Complete modular plugin architecture v0.17.0
Major architectural refactor:
- Convert invcount and conssheets to self-contained modules
- Add Module Manager with upload/install/uninstall
- Implement auto-restart after module installation
- Add drag-and-drop module upload system
- Create triple-confirmation uninstall flow
- Redesign Module Manager UI with card layout
- Support dynamic module loading from /modules/

This enables easy distribution and installation of new modules
without code changes to core application.
2026-02-08 00:54:12 -06:00
Javier
22d7a349a2 Bug: Fixed Imports of Consumption Sheets Module 2026-02-07 12:15:47 -06:00
Javier
3afc096fd4 Bug: Fixed some small icon issues. 2026-02-07 10:00:43 -06:00
21 changed files with 1456 additions and 249 deletions

196
app.py
View File

@@ -28,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version # 1. Define the version
APP_VERSION = '0.16.0' # Bumped version for modular architecture APP_VERSION = '0.17.0' # Bumped version for modular architecture
# 2. Inject it into all templates automatically # 2. Inject it into all templates automatically
@app.context_processor @app.context_processor
@@ -162,43 +162,8 @@ def install_module(module_key):
result = module_manager.install_module(module_key) result = module_manager.install_module(module_key)
# Hot-reload: Register the blueprint immediately if installation succeeded
if result['success']: if result['success']:
try: result['restart_required'] = True
from pathlib import Path
import importlib.util
import sys
module = module_manager.get_module_by_key(module_key)
if module:
init_path = Path(module['path']) / '__init__.py'
# Import the module
spec = importlib.util.spec_from_file_location(
f"modules.{module_key}",
init_path
)
module_package = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module_package
spec.loader.exec_module(module_package)
# Create and register blueprint
if hasattr(module_package, 'create_blueprint'):
blueprint = module_package.create_blueprint()
app.register_blueprint(blueprint)
print(f"🔥 Hot-loaded: {module['name']} at {module.get('routes_prefix')}")
result['message'] += ' (Module loaded - no restart needed!)'
else:
print(f"⚠️ Module {module_key} missing create_blueprint()")
result['message'] += ' (Restart required - missing create_blueprint)'
else:
print(f"⚠️ Could not find module {module_key} after installation")
result['message'] += ' (Restart required - module not found)'
except Exception as e:
print(f"❌ Hot-reload failed for {module_key}: {e}")
import traceback
traceback.print_exc()
result['message'] += f' (Restart required - hot-reload failed)'
return jsonify(result) return jsonify(result)
@@ -209,7 +174,11 @@ def uninstall_module(module_key):
if session.get('role') not in ['owner', 'admin']: if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403 return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.uninstall_module(module_key, drop_tables=True) # Check if user wants to keep data
keep_data = request.args.get('keep_data') == 'true'
drop_tables = not keep_data
result = module_manager.uninstall_module(module_key, drop_tables=drop_tables)
return jsonify(result) return jsonify(result)
@@ -234,6 +203,157 @@ def deactivate_module(module_key):
result = module_manager.deactivate_module(module_key) result = module_manager.deactivate_module(module_key)
return jsonify(result) return jsonify(result)
@app.route('/admin/restart', methods=['POST'])
@login_required
def restart_server():
"""Restart the Flask server"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
import os
import sys
try:
print("\n🔄 Server restart requested by admin...")
# Return response first
response = jsonify({'success': True, 'message': 'Server restarting...'})
# Schedule restart after response is sent
def restart():
import time
time.sleep(0.5) # Give time for response to send
if os.name == 'nt': # Windows
os.execv(sys.executable, ['python'] + sys.argv)
else: # Linux/Mac
os.execv(sys.executable, [sys.executable] + sys.argv)
from threading import Thread
Thread(target=restart).start()
return response
except Exception as e:
return jsonify({'success': False, 'message': f'Restart failed: {str(e)}'})
"""
Add this route to app.py
"""
@app.route('/admin/modules/upload', methods=['POST'])
@login_required
def upload_module():
"""Upload and extract a module package"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
if 'module_file' not in request.files:
return jsonify({'success': False, 'message': 'No file uploaded'})
file = request.files['module_file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'})
if not file.filename.endswith('.zip'):
return jsonify({'success': False, 'message': 'File must be a ZIP archive'})
try:
import zipfile
import tempfile
import shutil
from pathlib import Path
import json
# Create temp directory
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded file
zip_path = os.path.join(temp_dir, 'module.zip')
file.save(zip_path)
# Extract zip
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)
# Find the module folder (should contain manifest.json)
module_folder = None
manifest_path = None
# Check if manifest.json is at root of zip
if os.path.exists(os.path.join(temp_dir, 'manifest.json')):
module_folder = temp_dir
manifest_path = os.path.join(temp_dir, 'manifest.json')
else:
# Look for manifest.json in subdirectories
for item in os.listdir(temp_dir):
item_path = os.path.join(temp_dir, item)
if os.path.isdir(item_path):
potential_manifest = os.path.join(item_path, 'manifest.json')
if os.path.exists(potential_manifest):
module_folder = item_path
manifest_path = potential_manifest
break
if not manifest_path:
return jsonify({
'success': False,
'message': 'Invalid module package: manifest.json not found'
})
# Read and validate manifest
with open(manifest_path, 'r') as f:
manifest = json.load(f)
required_fields = ['module_key', 'name', 'version', 'author']
for field in required_fields:
if field not in manifest:
return jsonify({
'success': False,
'message': f'Invalid manifest.json: missing required field "{field}"'
})
module_key = manifest['module_key']
# Check if module already exists
modules_dir = os.path.join(os.path.dirname(__file__), 'modules')
target_path = os.path.join(modules_dir, module_key)
if os.path.exists(target_path):
return jsonify({
'success': False,
'message': f'Module "{module_key}" already exists. Please uninstall it first or use a different module key.'
})
# Required files check
required_files = ['manifest.json', '__init__.py']
for req_file in required_files:
if not os.path.exists(os.path.join(module_folder, req_file)):
return jsonify({
'success': False,
'message': f'Invalid module package: {req_file} not found'
})
# Copy module to /modules directory
shutil.copytree(module_folder, target_path)
print(f"✅ Module '{manifest['name']}' uploaded successfully to {target_path}")
return jsonify({
'success': True,
'message': f"Module '{manifest['name']}' uploaded successfully! Click Install to activate it."
})
except zipfile.BadZipFile:
return jsonify({'success': False, 'message': 'Invalid ZIP file'})
except json.JSONDecodeError:
return jsonify({'success': False, 'message': 'Invalid manifest.json: not valid JSON'})
except Exception as e:
print(f"❌ Module upload error: {e}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'Upload failed: {str(e)}'})
# ==================== PWA SUPPORT ROUTES ==================== # ==================== PWA SUPPORT ROUTES ====================

View File

@@ -195,6 +195,15 @@ class ModuleManager:
''', [module_key, module['name'], module['version'], ''', [module_key, module['name'], module['version'],
module['author'], module['description']]) module['author'], module['description']])
# Also register in old Modules table for compatibility
old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True)
if not old_module:
execute_db('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active)
VALUES (?, ?, ?, ?, 1)
''', [module['name'], module_key, module['description'], module.get('icon', '')])
# Also register in old Modules table for compatibility # Also register in old Modules table for compatibility
old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True) old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True)
if not old_module: if not old_module:
@@ -214,6 +223,8 @@ class ModuleManager:
traceback.print_exc() traceback.print_exc()
return {'success': False, 'message': f'Installation failed: {str(e)}'} return {'success': False, 'message': f'Installation failed: {str(e)}'}
def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict: def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict:
""" """
Uninstall a module: Uninstall a module:

View File

@@ -4,6 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"author": "STUFF", "author": "STUFF",
"description": "Production lot tracking and consumption reporting with Excel export", "description": "Production lot tracking and consumption reporting with Excel export",
"icon": "fa-clipboard-list",
"requires_roles": ["owner", "admin", "staff"], "requires_roles": ["owner", "admin", "staff"],
"routes_prefix": "/conssheets", "routes_prefix": "/conssheets",
"has_migrations": true, "has_migrations": true,

View File

@@ -1,12 +1,14 @@
""" """
Consumption Sheets Module - Routes Consumption Sheets Module - Routes
Converted from cons_sheets.py Converted from conssheets.py
""" """
from flask import render_template, request, redirect, url_for, flash, jsonify, session, send_file from flask import render_template, request, redirect, url_for, flash, jsonify, session, send_file
from db import query_db, execute_db from db import query_db, execute_db
from utils import login_required, role_required from utils import login_required, role_required
from datetime import datetime from datetime import datetime
import sqlite3
import io import io
import os
def register_routes(bp): def register_routes(bp):
@@ -16,7 +18,7 @@ def register_routes(bp):
# CONSUMPTION SHEETS ROUTES # CONSUMPTION SHEETS ROUTES
# ========================================================================= # =========================================================================
@bp.route('/admin/consumption-sheets') @bp.route('/admin')
@role_required('owner', 'admin') @role_required('owner', 'admin')
def admin_processes(): def admin_processes():
"""List all consumption sheet process types (Active or Archived)""" """List all consumption sheet process types (Active or Archived)"""
@@ -33,7 +35,7 @@ def register_routes(bp):
ORDER BY cp.process_name ASC ORDER BY cp.process_name ASC
''', [is_active_val]) ''', [is_active_val])
return render_template('cons_sheets/admin_processes.html', return render_template('conssheets/admin_processes.html',
processes=processes, processes=processes,
showing_archived=show_archived) showing_archived=show_archived)
@@ -47,7 +49,7 @@ def register_routes(bp):
if not process_name: if not process_name:
flash('Process name is required', 'danger') flash('Process name is required', 'danger')
return redirect(url_for('cons_sheets.create_process')) return redirect(url_for('conssheets.create_process'))
# Generate process_key from name (lowercase, underscores) # Generate process_key from name (lowercase, underscores)
process_key = process_name.lower().replace(' ', '_').replace('-', '_') process_key = process_name.lower().replace(' ', '_').replace('-', '_')
@@ -58,7 +60,7 @@ def register_routes(bp):
existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True) existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True)
if existing: if existing:
flash(f'A process with key "{process_key}" already exists', 'danger') flash(f'A process with key "{process_key}" already exists', 'danger')
return redirect(url_for('cons_sheets.create_process')) return redirect(url_for('conssheets.create_process'))
process_id = execute_db(''' process_id = execute_db('''
INSERT INTO cons_processes (process_key, process_name, created_by) INSERT INTO cons_processes (process_key, process_name, created_by)
@@ -69,15 +71,15 @@ def register_routes(bp):
create_process_detail_table(process_key) create_process_detail_table(process_key)
flash(f'Process "{process_name}" created successfully!', 'success') flash(f'Process "{process_name}" created successfully!', 'success')
return redirect(url_for('cons_sheets.process_detail', process_id=process_id)) return redirect(url_for('conssheets.process_detail', process_id=process_id))
return render_template('cons_sheets/create_process.html') return render_template('conssheets/create_process.html')
def get_db_path(): def get_db_path():
"""Get the database path""" """Get the database path"""
db_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'database', 'scanlook.db') db_path = 'database/scanlook.db'
print(f"DEBUG: Database path is: {db_path}") print(f"DEBUG: Database path is: {db_path}")
print(f"DEBUG: Path exists: {os.path.exists(db_path)}") print(f"DEBUG: Path exists: {os.path.exists(db_path)}")
return db_path return db_path
@@ -167,7 +169,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Soft delete: Set is_active = 0 # Soft delete: Set is_active = 0
# The existing admin_processes route already filters for is_active=1, # The existing admin_processes route already filters for is_active=1,
@@ -175,7 +177,7 @@ def register_routes(bp):
execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id]) execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id])
flash(f'Process "{process["process_name"]}" has been deleted.', 'success') flash(f'Process "{process["process_name"]}" has been deleted.', 'success')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
@bp.route('/admin/consumption-sheets/<int:process_id>/restore', methods=['POST']) @bp.route('/admin/consumption-sheets/<int:process_id>/restore', methods=['POST'])
@@ -184,7 +186,7 @@ def register_routes(bp):
"""Restore a soft-deleted process type""" """Restore a soft-deleted process type"""
execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id]) execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id])
flash('Process has been restored.', 'success') flash('Process has been restored.', 'success')
return redirect(url_for('cons_sheets.admin_processes', archived=1)) return redirect(url_for('conssheets.admin_processes', archived=1))
@@ -196,7 +198,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get header fields # Get header fields
header_fields = query_db(''' header_fields = query_db('''
@@ -212,7 +214,7 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_detail.html', return render_template('conssheets/process_detail.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@@ -226,7 +228,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get header fields # Get header fields
header_fields = query_db(''' header_fields = query_db('''
@@ -242,7 +244,7 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_fields.html', return render_template('conssheets/process_fields.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@@ -256,7 +258,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get all active fields for mapping display # Get all active fields for mapping display
header_fields = query_db(''' header_fields = query_db('''
@@ -271,7 +273,7 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_template.html', return render_template('conssheets/process_template.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@@ -285,21 +287,21 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if 'template_file' not in request.files: if 'template_file' not in request.files:
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
file = request.files['template_file'] file = request.files['template_file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
if not file.filename.endswith('.xlsx'): if not file.filename.endswith('.xlsx'):
flash('Only .xlsx files are allowed', 'danger') flash('Only .xlsx files are allowed', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
# Read file as binary # Read file as binary
template_data = file.read() template_data = file.read()
@@ -313,7 +315,7 @@ def register_routes(bp):
''', [template_data, filename, process_id]) ''', [template_data, filename, process_id])
flash(f'Template "{filename}" uploaded successfully!', 'success') flash(f'Template "{filename}" uploaded successfully!', 'success')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/template/settings', methods=['POST']) @bp.route('/admin/consumption-sheets/<int:process_id>/template/settings', methods=['POST'])
@@ -324,7 +326,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
rows_per_page = request.form.get('rows_per_page', 30) rows_per_page = request.form.get('rows_per_page', 30)
detail_start_row = request.form.get('detail_start_row', 10) detail_start_row = request.form.get('detail_start_row', 10)
@@ -340,11 +342,11 @@ def register_routes(bp):
if not page_height: if not page_height:
flash('Page Height is required for the new strategy', 'danger') flash('Page Height is required for the new strategy', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
except ValueError: except ValueError:
flash('Invalid number values', 'danger') flash('Invalid number values', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
# Update query - We ignore detail_end_row (leave it as is or null) # Update query - We ignore detail_end_row (leave it as is or null)
execute_db(''' execute_db('''
@@ -355,7 +357,7 @@ def register_routes(bp):
''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id]) ''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id])
flash('Settings updated successfully!', 'success') flash('Settings updated successfully!', 'success')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/template/download') @bp.route('/admin/consumption-sheets/<int:process_id>/template/download')
@role_required('owner', 'admin') @role_required('owner', 'admin')
@@ -367,7 +369,7 @@ def register_routes(bp):
if not process or not process['template_file']: if not process or not process['template_file']:
flash('No template found', 'danger') flash('No template found', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
return Response( return Response(
process['template_file'], process['template_file'],
@@ -382,13 +384,13 @@ def register_routes(bp):
"""Add a new field to a process""" """Add a new field to a process"""
if table_type not in ['header', 'detail']: if table_type not in ['header', 'detail']:
flash('Invalid table type', 'danger') flash('Invalid table type', 'danger')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if request.method == 'POST': if request.method == 'POST':
field_label = request.form.get('field_label', '').strip() field_label = request.form.get('field_label', '').strip()
@@ -399,7 +401,7 @@ def register_routes(bp):
if not field_label: if not field_label:
flash('Field label is required', 'danger') flash('Field label is required', 'danger')
return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) return redirect(url_for('conssheets.add_field', process_id=process_id, table_type=table_type))
# Generate field_name from label (lowercase, underscores) # Generate field_name from label (lowercase, underscores)
field_name = field_label.lower().replace(' ', '_').replace('-', '_') field_name = field_label.lower().replace(' ', '_').replace('-', '_')
@@ -413,7 +415,7 @@ def register_routes(bp):
if existing: if existing:
flash(f'A field with name "{field_name}" already exists', 'danger') flash(f'A field with name "{field_name}" already exists', 'danger')
return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) return redirect(url_for('conssheets.add_field', process_id=process_id, table_type=table_type))
# Get next sort_order # Get next sort_order
max_sort = query_db(''' max_sort = query_db('''
@@ -438,9 +440,9 @@ def register_routes(bp):
add_column_to_detail_table(process['process_key'], field_name, field_type) add_column_to_detail_table(process['process_key'], field_name, field_type)
flash(f'Field "{field_label}" added successfully!', 'success') flash(f'Field "{field_label}" added successfully!', 'success')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
return render_template('cons_sheets/add_field.html', return render_template('conssheets/add_field.html',
process=process, process=process,
table_type=table_type) table_type=table_type)
@@ -454,7 +456,7 @@ def register_routes(bp):
if not process or not field: if not process or not field:
flash('Process or field not found', 'danger') flash('Process or field not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if request.method == 'POST': if request.method == 'POST':
field_label = request.form.get('field_label', '').strip() field_label = request.form.get('field_label', '').strip()
@@ -466,7 +468,7 @@ def register_routes(bp):
if not field_label: if not field_label:
flash('Field label is required', 'danger') flash('Field label is required', 'danger')
return redirect(url_for('cons_sheets.edit_field', process_id=process_id, field_id=field_id)) return redirect(url_for('conssheets.edit_field', process_id=process_id, field_id=field_id))
execute_db(''' execute_db('''
UPDATE cons_process_fields UPDATE cons_process_fields
@@ -475,9 +477,9 @@ def register_routes(bp):
''', [field_label, field_type, int(max_length) if max_length else None, is_required, is_duplicate_key, excel_cell or None, field_id]) ''', [field_label, field_type, int(max_length) if max_length else None, is_required, is_duplicate_key, excel_cell or None, field_id])
flash(f'Field "{field_label}" updated successfully!', 'success') flash(f'Field "{field_label}" updated successfully!', 'success')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
return render_template('cons_sheets/edit_field.html', return render_template('conssheets/edit_field.html',
process=process, process=process,
field=field) field=field)
@@ -520,7 +522,7 @@ def register_routes(bp):
''', [process_id], one=True) ''', [process_id], one=True)
@bp.route('/cons-sheets') @bp.route('/')
@login_required @login_required
def index(): def index():
"""Consumption Sheets module landing - show user's sessions""" """Consumption Sheets module landing - show user's sessions"""
@@ -530,7 +532,7 @@ def register_routes(bp):
has_access = query_db(''' has_access = query_db('''
SELECT 1 FROM UserModules um SELECT 1 FROM UserModules um
JOIN Modules m ON um.module_id = m.module_id JOIN Modules m ON um.module_id = m.module_id
WHERE um.user_id = ? AND m.module_key = 'cons_sheets' AND m.is_active = 1 WHERE um.user_id = ? AND m.module_key = 'conssheets' AND m.is_active = 1
''', [user_id], one=True) ''', [user_id], one=True)
if not has_access: if not has_access:
@@ -567,12 +569,12 @@ def register_routes(bp):
SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name
''') ''')
return render_template('cons_sheets/staff_index.html', return render_template('conssheets/staff_index.html',
sessions=sessions_with_counts, sessions=sessions_with_counts,
processes=processes) processes=processes)
@bp.route('/cons-sheets/new/<int:process_id>', methods=['GET', 'POST']) @bp.route('/new/<int:process_id>', methods=['GET', 'POST'])
@login_required @login_required
def new_session(process_id): def new_session(process_id):
"""Create a new scanning session - enter header info""" """Create a new scanning session - enter header info"""
@@ -580,7 +582,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Get header fields for this process # Get header fields for this process
header_fields = query_db(''' header_fields = query_db('''
@@ -600,7 +602,7 @@ def register_routes(bp):
if missing_required: if missing_required:
flash(f'Required fields missing: {", ".join(missing_required)}', 'danger') flash(f'Required fields missing: {", ".join(missing_required)}', 'danger')
return render_template('cons_sheets/new_session.html', return render_template('conssheets/new_session.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
form_data=request.form) form_data=request.form)
@@ -621,15 +623,15 @@ def register_routes(bp):
''', [session_id, field['id'], value]) ''', [session_id, field['id'], value])
flash('Session created! Start scanning lots.', 'success') flash('Session created! Start scanning lots.', 'success')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
return render_template('cons_sheets/new_session.html', return render_template('conssheets/new_session.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
form_data={}) form_data={})
@bp.route('/cons-sheets/session/<int:session_id>') @bp.route('/session/<int:session_id>')
@login_required @login_required
def scan_session(session_id): def scan_session(session_id):
"""Main scanning interface for a session""" """Main scanning interface for a session"""
@@ -643,11 +645,11 @@ def register_routes(bp):
if not sess: if not sess:
flash('Session not found', 'danger') flash('Session not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.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('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Get header values for display # Get header values for display
header_values = query_db(''' header_values = query_db('''
@@ -680,7 +682,7 @@ def register_routes(bp):
dup_key_field_row = get_duplicate_key_field(sess['process_id']) dup_key_field_row = get_duplicate_key_field(sess['process_id'])
dup_key_field = dict(dup_key_field_row) if dup_key_field_row else None dup_key_field = dict(dup_key_field_row) if dup_key_field_row else None
return render_template('cons_sheets/scan_session.html', return render_template('conssheets/scan_session.html',
session=sess, session=sess,
header_values=header_values, header_values=header_values,
scans=scans, scans=scans,
@@ -688,7 +690,7 @@ def register_routes(bp):
dup_key_field=dup_key_field) dup_key_field=dup_key_field)
@bp.route('/cons-sheets/session/<int:session_id>/scan', methods=['POST']) @bp.route('/session/<int:session_id>/scan', methods=['POST'])
@login_required @login_required
def scan_lot(session_id): def scan_lot(session_id):
"""Process a scan with duplicate detection using dynamic tables""" """Process a scan with duplicate detection using dynamic tables"""
@@ -823,7 +825,7 @@ def register_routes(bp):
}) })
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>') @bp.route('/session/<int:session_id>/detail/<int:detail_id>')
@login_required @login_required
def get_detail(session_id, detail_id): def get_detail(session_id, detail_id):
"""Get detail info for editing""" """Get detail info for editing"""
@@ -852,7 +854,7 @@ def register_routes(bp):
return jsonify({'success': True, 'detail': dict(detail)}) return jsonify({'success': True, 'detail': dict(detail)})
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>/update', methods=['POST']) @bp.route('/session/<int:session_id>/detail/<int:detail_id>/update', methods=['POST'])
@login_required @login_required
def update_detail(session_id, detail_id): def update_detail(session_id, detail_id):
"""Update a scanned detail""" """Update a scanned detail"""
@@ -909,7 +911,7 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>/delete', methods=['POST']) @bp.route('/session/<int:session_id>/detail/<int:detail_id>/delete', methods=['POST'])
@login_required @login_required
def delete_detail(session_id, detail_id): def delete_detail(session_id, detail_id):
"""Soft-delete a scanned detail""" """Soft-delete a scanned detail"""
@@ -939,7 +941,7 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/archive', methods=['POST']) @bp.route('/session/<int:session_id>/archive', methods=['POST'])
@login_required @login_required
def archive_session(session_id): def archive_session(session_id):
"""Archive (soft-delete) a session""" """Archive (soft-delete) a session"""
@@ -957,7 +959,7 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/template') @bp.route('/session/<int:session_id>/template')
@login_required @login_required
def download_import_template(session_id): def download_import_template(session_id):
"""Generate a blank Excel template for bulk import""" """Generate a blank Excel template for bulk import"""
@@ -967,7 +969,7 @@ def register_routes(bp):
# Get Process ID # Get Process ID
sess = query_db('SELECT process_id FROM cons_sessions WHERE id = ?', [session_id], one=True) sess = query_db('SELECT process_id FROM cons_sessions WHERE id = ?', [session_id], one=True)
if not sess: return redirect(url_for('cons_sheets.index')) if not sess: return redirect(url_for('conssheets.index'))
# Get Detail Fields # Get Detail Fields
fields = query_db(''' fields = query_db('''
@@ -996,7 +998,7 @@ def register_routes(bp):
headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'} headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'}
) )
@bp.route('/cons-sheets/session/<int:session_id>/import', methods=['POST']) @bp.route('/session/<int:session_id>/import', methods=['POST'])
@login_required @login_required
def import_session_data(session_id): def import_session_data(session_id):
"""Bulk import detail rows from Excel""" """Bulk import detail rows from Excel"""
@@ -1015,17 +1017,17 @@ def register_routes(bp):
if not sess: if not sess:
flash('Session not found', 'danger') flash('Session not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# 2. Check File # 2. Check File
if 'file' not in request.files: if 'file' not in request.files:
flash('No file uploaded', 'danger') flash('No file uploaded', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
file = request.files['file'] file = request.files['file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
try: try:
# 3. Read Excel # 3. Read Excel
@@ -1051,7 +1053,7 @@ def register_routes(bp):
if not col_mapping: if not col_mapping:
flash('Error: No matching columns found in Excel. Please use the template.', 'danger') flash('Error: No matching columns found in Excel. Please use the template.', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
# 4. Process Rows # 4. Process Rows
table_name = f"cons_proc_{sess['process_key']}_details" table_name = f"cons_proc_{sess['process_key']}_details"
@@ -1095,9 +1097,9 @@ def register_routes(bp):
flash(f'Import Error: {str(e)}', 'danger') flash(f'Import Error: {str(e)}', 'danger')
print(f"DEBUG IMPORT ERROR: {str(e)}") # Print to console for good measure print(f"DEBUG IMPORT ERROR: {str(e)}") # Print to console for good measure
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
@bp.route('/cons-sheets/session/<int:session_id>/export') @bp.route('/session/<int:session_id>/export')
@login_required @login_required
def export_session(session_id): def export_session(session_id):
"""Export session: Hide Rows Strategy + Manual Column Widths""" """Export session: Hide Rows Strategy + Manual Column Widths"""
@@ -1123,7 +1125,7 @@ def register_routes(bp):
if not sess or not sess['template_file']: if not sess or not sess['template_file']:
flash('Session or Template not found', 'danger') flash('Session or Template not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Validation # Validation
page_height = sess['page_height'] page_height = sess['page_height']
@@ -1132,7 +1134,7 @@ def register_routes(bp):
if not page_height: if not page_height:
flash('Configuration Error: Page Height is not set.', 'danger') flash('Configuration Error: Page Height is not set.', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
# Get Data # Get Data
header_fields = query_db(''' header_fields = query_db('''

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Fields <i class="fa-solid fa-arrow-left"></i> Back to Fields
</a> </a>
</div> </div>
@@ -72,7 +72,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Field</button> <button type="submit" class="btn btn-primary">Add Field</button>
</div> </div>
</form> </form>

View File

@@ -18,18 +18,18 @@
<p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p> <p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p>
{% if showing_archived %} {% if showing_archived %}
<a href="{{ url_for('cons_sheets.admin_processes') }}" style="font-size: 0.85rem; color: var(--color-primary); display: inline-flex; align-items: center; gap: 6px;"> <a href="{{ url_for('conssheets.admin_processes') }}" style="font-size: 0.85rem; color: var(--color-primary); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-eye"></i> Return to Active List <i class="fa-solid fa-eye"></i> Return to Active List
</a> </a>
{% else %} {% else %}
<a href="{{ url_for('cons_sheets.admin_processes', archived=1) }}" style="font-size: 0.85rem; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: 6px;"> <a href="{{ url_for('conssheets.admin_processes', archived=1) }}" style="font-size: 0.85rem; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-box-archive"></i> View Archived Processes <i class="fa-solid fa-box-archive"></i> View Archived Processes
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Process <span class="btn-icon">+</span> New Process
</a> </a>
</div> </div>
@@ -48,7 +48,7 @@
{% if showing_archived %} {% if showing_archived %}
<form method="POST" <form method="POST"
action="{{ url_for('cons_sheets.restore_process', process_id=process.id) }}" action="{{ url_for('conssheets.restore_process', process_id=process.id) }}"
style="margin: 0;"> style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);"> <button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);">
<i class="fa-solid fa-trash-arrow-up"></i> <i class="fa-solid fa-trash-arrow-up"></i>
@@ -56,7 +56,7 @@
</form> </form>
{% else %} {% else %}
<form method="POST" <form method="POST"
action="{{ url_for('cons_sheets.delete_process', process_id=process.id) }}" action="{{ url_for('conssheets.delete_process', process_id=process.id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');" onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');"
style="margin: 0;"> style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Delete Process"> <button type="submit" class="btn-icon-only" title="Delete Process">
@@ -86,7 +86,7 @@
</div> </div>
<div class="session-actions"> <div class="session-actions">
<a href="{{ url_for('cons_sheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-block"> <a href="{{ url_for('conssheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-block">
Configure Configure
</a> </a>
</div> </div>
@@ -100,7 +100,7 @@
<div class="empty-icon">📝</div> <div class="empty-icon">📝</div>
<h2 class="empty-title">No Processes Defined</h2> <h2 class="empty-title">No Processes Defined</h2>
<p class="empty-text">Create a process type to get started (e.g., "AD WIP")</p> <p class="empty-text">Create a process type to get started (e.g., "AD WIP")</p>
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
Create First Process Create First Process
</a> </a>
</div> </div>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.admin_processes') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Processes <i class="fa-solid fa-arrow-left"></i> Back to Processes
</a> </a>
</div> </div>
@@ -27,7 +27,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('conssheets.admin_processes') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Create Process</button> <button type="submit" class="btn btn-primary">Create Process</button>
</div> </div>
</form> </form>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Fields <i class="fa-solid fa-arrow-left"></i> Back to Fields
</a> </a>
</div> </div>
@@ -71,7 +71,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
</div> </div>
</form> </form>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back <i class="fa-solid fa-arrow-left"></i> Back
</a> </a>
</div> </div>
@@ -76,7 +76,7 @@
{% endif %} {% endif %}
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" {% if not header_fields %}disabled{% endif %}> <button type="submit" class="btn btn-primary" {% if not header_fields %}disabled{% endif %}>
Start Scanning Start Scanning
</button> </button>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.admin_processes') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Processes <i class="fa-solid fa-arrow-left"></i> Back to Processes
</a> </a>
</div> </div>
@@ -38,7 +38,7 @@
</div> </div>
</div> </div>
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-primary btn-block"> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Fields Configure Fields
</a> </a>
</div> </div>
@@ -67,7 +67,7 @@
</div> </div>
</div> </div>
<a href="{{ url_for('cons_sheets.process_template', process_id=process.id) }}" class="btn btn-primary btn-block"> <a href="{{ url_for('conssheets.process_template', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Template Configure Template
</a> </a>
</div> </div>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to {{ process.process_name }} <i class="fa-solid fa-arrow-left"></i> Back to {{ process.process_name }}
</a> </a>
</div> </div>
@@ -21,7 +21,7 @@
<div class="fields-section"> <div class="fields-section">
<div class="section-header"> <div class="section-header">
<h2 class="section-title">Header Fields</h2> <h2 class="section-title">Header Fields</h2>
<a href="{{ url_for('cons_sheets.add_field', process_id=process.id, table_type='header') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('conssheets.add_field', process_id=process.id, table_type='header') }}" class="btn btn-primary btn-sm">
<span class="btn-icon">+</span> Add Field <span class="btn-icon">+</span> Add Field
</a> </a>
</div> </div>
@@ -48,7 +48,7 @@
<td>{{ '✓' if field.is_required else '—' }}</td> <td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</td> <td>{{ field.excel_cell or '—' }}</td>
<td> <td>
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a> <a href="{{ url_for('conssheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
<button onclick="confirmDelete(this)" <button onclick="confirmDelete(this)"
data-id="{{ field.id }}" data-id="{{ field.id }}"
data-label="{{ field.field_label }}" data-label="{{ field.field_label }}"
@@ -73,7 +73,7 @@
<div class="fields-section"> <div class="fields-section">
<div class="section-header"> <div class="section-header">
<h2 class="section-title">Detail Fields</h2> <h2 class="section-title">Detail Fields</h2>
<a href="{{ url_for('cons_sheets.add_field', process_id=process.id, table_type='detail') }}" class="btn btn-primary btn-sm"> <a href="{{ url_for('conssheets.add_field', process_id=process.id, table_type='detail') }}" class="btn btn-primary btn-sm">
<span class="btn-icon">+</span> Add Field <span class="btn-icon">+</span> Add Field
</a> </a>
</div> </div>
@@ -100,7 +100,7 @@
<td>{{ '✓' if field.is_required else '—' }}</td> <td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</td> <td>{{ field.excel_cell or '—' }}</td>
<td> <td>
<a href="{{ url_for('cons_sheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a> <a href="{{ url_for('conssheets.edit_field', process_id=process.id, field_id=field.id) }}" class="btn btn-secondary btn-sm">Edit</a>
<button onclick="confirmDelete(this)" <button onclick="confirmDelete(this)"
data-id="{{ field.id }}" data-id="{{ field.id }}"
data-label="{{ field.field_label }}" data-label="{{ field.field_label }}"
@@ -156,7 +156,7 @@ function confirmDelete(btn) {
const fieldLabel = btn.dataset.label; const fieldLabel = btn.dataset.label;
if (confirm('Delete field "' + fieldLabel + '"?\n\nThis will soft-delete the field (data preserved but hidden).')) { if (confirm('Delete field "' + fieldLabel + '"?\n\nThis will soft-delete the field (data preserved but hidden).')) {
fetch('{{ url_for("cons_sheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), { fetch('{{ url_for("conssheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), {
method: 'POST' method: 'POST'
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to {{ process.process_name }} <i class="fa-solid fa-arrow-left"></i> Back to {{ process.process_name }}
</a> </a>
</div> </div>
@@ -27,14 +27,14 @@
<div class="template-info"> <div class="template-info">
<span class="template-icon">📄</span> <span class="template-icon">📄</span>
<span class="template-name">{{ process.template_filename }}</span> <span class="template-name">{{ process.template_filename }}</span>
<a href="{{ url_for('cons_sheets.download_template', process_id=process.id) }}" class="btn btn-secondary btn-sm">Download</a> <a href="{{ url_for('conssheets.download_template', process_id=process.id) }}" class="btn btn-secondary btn-sm">Download</a>
</div> </div>
{% else %} {% else %}
<p class="no-template">No template uploaded yet</p> <p class="no-template">No template uploaded yet</p>
{% endif %} {% endif %}
</div> </div>
<form method="POST" action="{{ url_for('cons_sheets.upload_template', process_id=process.id) }}" enctype="multipart/form-data" class="upload-form"> <form method="POST" action="{{ url_for('conssheets.upload_template', process_id=process.id) }}" enctype="multipart/form-data" class="upload-form">
<div class="form-group"> <div class="form-group">
<label for="template_file" class="form-label">Upload New Template</label> <label for="template_file" class="form-label">Upload New Template</label>
<input type="file" id="template_file" name="template_file" accept=".xlsx" class="form-input" required> <input type="file" id="template_file" name="template_file" accept=".xlsx" class="form-input" required>
@@ -48,7 +48,7 @@
<div class="config-section"> <div class="config-section">
<h2 class="section-title">Page Settings</h2> <h2 class="section-title">Page Settings</h2>
<form method="POST" action="{{ url_for('cons_sheets.update_template_settings', process_id=process.id) }}"> <form method="POST" action="{{ url_for('conssheets.update_template_settings', process_id=process.id) }}">
<div class="form-group"> <div class="form-group">
<label for="rows_per_page" class="form-label">Rows Per Page (Capacity)</label> <label for="rows_per_page" class="form-label">Rows Per Page (Capacity)</label>
<input type="number" id="rows_per_page" name="rows_per_page" <input type="number" id="rows_per_page" name="rows_per_page"
@@ -157,12 +157,12 @@
{% endif %} {% endif %}
</div> </div>
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary" style="margin-top: var(--space-lg);"> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-secondary" style="margin-top: var(--space-lg);">
Edit Field Mappings Edit Field Mappings
</a> </a>
{% else %} {% else %}
<div class="empty-state-small"> <div class="empty-state-small">
<p>No fields defined yet. <a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}">Add fields first</a>.</p> <p>No fields defined yet. <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}">Add fields first</a>.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -6,7 +6,7 @@
<div class="count-location-container"> <div class="count-location-container">
<div class="location-header"> <div class="location-header">
<div class="location-info"> <div class="location-info">
<a href="{{ url_for('cons_sheets.index') }}" class="breadcrumb">← Back to Sessions</a> <a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<div class="location-label">{{ session.process_name }}</div> <div class="location-label">{{ session.process_name }}</div>
<div class="header-values"> <div class="header-values">
{% for hv in header_values %} {% for hv in header_values %}
@@ -136,7 +136,7 @@
<div class="finish-section"> <div class="finish-section">
<div class="action-buttons-row"> <div class="action-buttons-row">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary btn-block btn-lg">← Back to Sessions</a> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary btn-block btn-lg">← Back to Sessions</a>
<button class="btn btn-success btn-block btn-lg" onclick="exportToExcel()">📊 Export to Excel</button> <button class="btn btn-success btn-block btn-lg" onclick="exportToExcel()">📊 Export to Excel</button>
</div> </div>
</div> </div>
@@ -157,12 +157,12 @@
<div style="margin-bottom: 30px; padding: 15px; background: var(--color-bg); border-radius: 8px;"> <div style="margin-bottom: 30px; padding: 15px; background: var(--color-bg); border-radius: 8px;">
<p style="font-size: 0.9rem; margin-bottom: 10px;">Step 1: Get the correct format</p> <p style="font-size: 0.9rem; margin-bottom: 10px;">Step 1: Get the correct format</p>
<a href="{{ url_for('cons_sheets.download_import_template', session_id=session['id']) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.download_import_template', session_id=session['id']) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-download"></i> Download Template <i class="fa-solid fa-download"></i> Download Template
</a> </a>
</div> </div>
<form action="{{ url_for('cons_sheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data"> <form action="{{ url_for('conssheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;"> <input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;">
</div> </div>
@@ -226,7 +226,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
function checkDuplicate() { function checkDuplicate() {
const fieldValues = {}; const fieldValues = {};
fieldValues[dupKeyFieldName] = currentDupKeyValue; fieldValues[dupKeyFieldName] = currentDupKeyValue;
fetch(`/cons-sheets/session/${sessionId}/scan`, { fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, check_only: true }) body: JSON.stringify({ field_values: fieldValues, check_only: true })
@@ -300,7 +300,7 @@ function submitScan() {
if (input) fieldValues[field.field_name] = input.value; if (input) fieldValues[field.field_name] = input.value;
} }
}); });
fetch(`/cons-sheets/session/${sessionId}/scan`, { fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed }) body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed })
@@ -359,7 +359,7 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
} }
function openScanDetail(detailId) { function openScanDetail(detailId) {
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}`) fetch(`/conssheets/session/${sessionId}/detail/${detailId}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) displayScanDetail(data.detail); if (data.success) displayScanDetail(data.detail);
@@ -413,7 +413,7 @@ function saveDetail(detailId) {
if (input) fieldValues[field.field_name] = input.value; if (input) fieldValues[field.field_name] = input.value;
}); });
const comment = document.getElementById('editComment').value; const comment = document.getElementById('editComment').value;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/update`, { fetch(`/conssheets/session/${sessionId}/detail/${detailId}/update`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, comment: comment }) body: JSON.stringify({ field_values: fieldValues, comment: comment })
@@ -427,7 +427,7 @@ function saveDetail(detailId) {
function deleteDetail(detailId) { function deleteDetail(detailId) {
if (!confirm('Delete this scan?')) return; if (!confirm('Delete this scan?')) return;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/delete`, { fetch(`/conssheets/session/${sessionId}/detail/${detailId}/delete`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })
@@ -439,7 +439,7 @@ function deleteDetail(detailId) {
} }
function exportToExcel() { function exportToExcel() {
window.location.href = `/cons-sheets/session/${sessionId}/export?format=xlsx`; window.location.href = `/conssheets/session/${sessionId}/export?format=xlsx`;
} }
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {

View File

@@ -17,7 +17,7 @@
<h2 class="section-title">Start New Session</h2> <h2 class="section-title">Start New Session</h2>
<div class="process-buttons"> <div class="process-buttons">
{% for p in processes %} {% for p in processes %}
<a href="{{ url_for('cons_sheets.new_session', process_id=p.id) }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.new_session', process_id=p.id) }}" class="btn btn-primary">
<span class="btn-icon">+</span> {{ p.process_name }} <span class="btn-icon">+</span> {{ p.process_name }}
</a> </a>
{% endfor %} {% endfor %}
@@ -34,7 +34,7 @@
<div class="sessions-list"> <div class="sessions-list">
{% for s in sessions %} {% for s in sessions %}
<div class="session-list-item-container"> <div class="session-list-item-container">
<a href="{{ url_for('cons_sheets.scan_session', session_id=s.id) }}" class="session-list-item"> <a href="{{ url_for('conssheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info"> <div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3> <h3 class="session-list-name">{{ s.process_name }}</h3>
<div class="session-list-meta"> <div class="session-list-meta">

View File

@@ -3,7 +3,8 @@
"name": "Inventory Counts", "name": "Inventory Counts",
"version": "1.0.0", "version": "1.0.0",
"author": "STUFF", "author": "STUFF",
"description": "Cycle counting and physical inventory workflows with session-based tracking", "description": "Cycle Counts and Physical Inventory",
"icon": "fa-clipboard-check",
"requires_roles": ["owner", "admin", "staff"], "requires_roles": ["owner", "admin", "staff"],
"routes_prefix": "/invcount", "routes_prefix": "/invcount",
"has_migrations": true, "has_migrations": true,

View File

@@ -6,16 +6,12 @@
<div class="dashboard-container"> <div class="dashboard-container">
<div class="dashboard-header" style="margin-top: var(--space-lg);"> <div class="dashboard-header" style="margin-top: var(--space-lg);">
<div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);"> <div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);">
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm" title="Back to Home">
<i class="fa-solid fa-arrow-left"></i> Back to Home <i class="fa-solid fa-house"></i>
</a> </a>
<h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1> <h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1>
</div> </div>
<div class="header-right">
<a href="{{ url_for('module_manager_ui') }}" class="btn btn-primary btn-sm">
<i class="fa-solid fa-puzzle-piece"></i> Module Manager
</a>
</div>
</div> </div>
<div class="modules-section"> <div class="modules-section">
@@ -25,13 +21,13 @@
<div class="modules-grid"> <div class="modules-grid">
{% for module in modules %} {% for module in modules %}
<a href="/{{ module.module_key }}/admin" class="module-card module-card-link"> <a href="/{{ module.module_key }}/admin" class="module-card module-card-link">
<div class="module-icon"> <div class="module-icon">
{% if module.icon %} {% if module.icon %}
<i class="{{ module.icon }}"></i> <i class="fa-solid {{ module.icon }}"></i>
{% else %} {% else %}
📦 📦
{% endif %} {% endif %}
</div> </div>
<h3 class="module-name">{{ module.module_name }}</h3> <h3 class="module-name">{{ module.module_name }}</h3>
<p class="module-desc">{{ module.description }}</p> <p class="module-desc">{{ module.description }}</p>
</a> </a>

View File

@@ -29,8 +29,14 @@
<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('admin_dashboard') }}" class="settings-item">
<span class="settings-icon"><i class="fas fa-gauge"></i></span> Admin Dashboard
</a>
<a href="{{ url_for('users.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"><i class="fas fa-users"></i></span> Manage Users
</a>
<a href="{{ url_for('module_manager_ui') }}" class="settings-item">
<span class="settings-icon"><i class="fas fa-puzzle-piece"></i></span> Module Manager
</a> </a>
</div> </div>
</div> </div>

View File

@@ -18,18 +18,18 @@
<p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p> <p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p>
{% if showing_archived %} {% if showing_archived %}
<a href="{{ url_for('cons_sheets.admin_processes') }}" style="font-size: 0.85rem; color: var(--color-primary); display: inline-flex; align-items: center; gap: 6px;"> <a href="{{ url_for('conssheets.admin_processes') }}" style="font-size: 0.85rem; color: var(--color-primary); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-eye"></i> Return to Active List <i class="fa-solid fa-eye"></i> Return to Active List
</a> </a>
{% else %} {% else %}
<a href="{{ url_for('cons_sheets.admin_processes', archived=1) }}" style="font-size: 0.85rem; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: 6px;"> <a href="{{ url_for('conssheets.admin_processes', archived=1) }}" style="font-size: 0.85rem; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-box-archive"></i> View Archived Processes <i class="fa-solid fa-box-archive"></i> View Archived Processes
</a> </a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Process <span class="btn-icon">+</span> New Process
</a> </a>
</div> </div>
@@ -48,7 +48,7 @@
{% if showing_archived %} {% if showing_archived %}
<form method="POST" <form method="POST"
action="{{ url_for('cons_sheets.restore_process', process_id=process.id) }}" action="{{ url_for('conssheets.restore_process', process_id=process.id) }}"
style="margin: 0;"> style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);"> <button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);">
<i class="fa-solid fa-trash-arrow-up"></i> <i class="fa-solid fa-trash-arrow-up"></i>
@@ -56,7 +56,7 @@
</form> </form>
{% else %} {% else %}
<form method="POST" <form method="POST"
action="{{ url_for('cons_sheets.delete_process', process_id=process.id) }}" action="{{ url_for('conssheets.delete_process', process_id=process.id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');" onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');"
style="margin: 0;"> style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Delete Process"> <button type="submit" class="btn-icon-only" title="Delete Process">
@@ -86,7 +86,7 @@
</div> </div>
<div class="session-actions"> <div class="session-actions">
<a href="{{ url_for('cons_sheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-block"> <a href="{{ url_for('conssheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-block">
Configure Configure
</a> </a>
</div> </div>
@@ -100,7 +100,7 @@
<div class="empty-icon">📝</div> <div class="empty-icon">📝</div>
<h2 class="empty-title">No Processes Defined</h2> <h2 class="empty-title">No Processes Defined</h2>
<p class="empty-text">Create a process type to get started (e.g., "AD WIP")</p> <p class="empty-text">Create a process type to get started (e.g., "AD WIP")</p>
<a href="{{ url_for('cons_sheets.create_process') }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
Create First Process Create First Process
</a> </a>
</div> </div>

View File

@@ -5,19 +5,12 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <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"> <div class="dashboard-header">
<h1 class="page-title">Welcome, {{ session.full_name }}</h1> <h1 class="page-title">Welcome, {{ session.full_name }}</h1>
<p class="page-subtitle">Select a module to get started</p>
</div> </div>
<div><p class="page-subtitle">Select a module to get started</p></div>
{% if modules %} {% if modules %}
<div class="module-grid"> <div class="module-grid">

View File

@@ -3,86 +3,240 @@
{% block title %}Module Manager - ScanLook{% endblock %} {% block title %}Module Manager - ScanLook{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="dashboard-container">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="dashboard-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<h1><i class="fas fa-puzzle-piece"></i> Module Manager</h1> <div>
<a href="{{ url_for('home') }}" class="btn btn-secondary"> <h1 class="page-title"><i class="fas fa-puzzle-piece"></i> Module Manager</h1>
<i class="fas fa-arrow-left"></i> Back to Home <p class="page-subtitle">Install, manage, and configure ScanLook modules</p>
</a> </div>
<button class="btn btn-primary" onclick="openUploadModal()" style="margin-top: 10px;">
<i class="fas fa-upload"></i> Upload
</button>
</div> </div>
<p class="lead">Install, uninstall, and manage ScanLook modules</p> {% if modules %}
<div class="module-grid">
<div class="row">
{% for module in modules %} {% for module in modules %}
<div class="col-md-6 col-lg-4 mb-4"> <div class="module-card {% if module.is_active %}module-card-active{% endif %}">
<div class="card h-100 {% if module.is_active %}border-success{% elif module.is_installed %}border-warning{% endif %}"> <!-- Module Icon -->
<div class="card-header {% if module.is_active %}bg-success text-white{% elif module.is_installed %}bg-warning text-dark{% else %}bg-light{% endif %}"> <div class="module-icon">
<h5 class="mb-0"> {% if module.icon %}
<i class="fas fa-cube"></i> {{ module.name }} <i class="fa-solid {{ module.icon }}"></i>
<span class="badge badge-secondary float-right">v{{ module.version }}</span> {% else %}
</h5> <i class="fa-solid fa-cube"></i>
</div> {% endif %}
<div class="card-body"> </div>
<p class="card-text">{{ module.description }}</p>
<p class="mb-2"> <!-- Module Info -->
<small class="text-muted"> <h3 class="module-name">{{ module.name }}</h3>
<strong>Author:</strong> {{ module.author }}<br> <p class="module-desc">{{ module.description }}</p>
<strong>Module Key:</strong> <code>{{ module.module_key }}</code>
</small>
</p>
<div class="mt-3"> <!-- Module Metadata -->
{% if module.is_installed and module.is_active %} <div class="module-metadata">
<span class="badge badge-success mb-2"> <div class="metadata-row">
<i class="fas fa-check-circle"></i> Active <span class="metadata-label">Version:</span>
</span> <span class="metadata-value">{{ module.version }}</span>
{% elif module.is_installed %}
<span class="badge badge-warning mb-2">
<i class="fas fa-pause-circle"></i> Installed (Inactive)
</span>
{% else %}
<span class="badge badge-secondary mb-2">
<i class="fas fa-times-circle"></i> Not Installed
</span>
{% endif %}
</div>
</div> </div>
<div class="card-footer bg-light"> <div class="metadata-row">
{% if not module.is_installed %} <span class="metadata-label">Author:</span>
<button class="btn btn-primary btn-sm btn-block" onclick="installModule('{{ module.module_key }}')"> <span class="metadata-value">{{ module.author }}</span>
<i class="fas fa-download"></i> Install
</button>
{% elif module.is_active %}
<button class="btn btn-warning btn-sm btn-block mb-2" onclick="deactivateModule('{{ module.module_key }}')">
<i class="fas fa-pause"></i> Deactivate
</button>
<button class="btn btn-danger btn-sm btn-block" onclick="uninstallModule('{{ module.module_key }}')">
<i class="fas fa-trash"></i> Uninstall
</button>
{% else %}
<button class="btn btn-success btn-sm btn-block mb-2" onclick="activateModule('{{ module.module_key }}')">
<i class="fas fa-play"></i> Activate
</button>
<button class="btn btn-danger btn-sm btn-block" onclick="uninstallModule('{{ module.module_key }}')">
<i class="fas fa-trash"></i> Uninstall
</button>
{% endif %}
</div> </div>
</div> </div>
<!-- Status Badge -->
<div class="module-status">
{% if module.is_installed and module.is_active %}
<span class="status-badge status-active">
<i class="fas fa-check-circle"></i> Active
</span>
{% elif module.is_installed %}
<span class="status-badge status-inactive">
<i class="fas fa-pause-circle"></i> Inactive
</span>
{% else %}
<span class="status-badge status-not-installed">
<i class="fas fa-times-circle"></i> Not Installed
</span>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="module-actions">
{% if not module.is_installed %}
<!-- Not Installed: Show Install Button -->
<button class="btn btn-primary btn-block" onclick="installModule('{{ module.module_key }}')">
<i class="fas fa-download"></i> Install Module
</button>
{% elif module.is_active %}
<!-- Active: Show Deactivate and Uninstall -->
<button class="btn btn-warning btn-block" onclick="deactivateModule('{{ module.module_key }}')">
<i class="fas fa-pause"></i> Deactivate
</button>
<button class="btn btn-danger btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')">
<i class="fas fa-trash"></i> Uninstall
</button>
{% else %}
<!-- Installed but Inactive: Show Activate and Uninstall -->
<button class="btn btn-success btn-block" onclick="activateModule('{{ module.module_key }}')">
<i class="fas fa-play"></i> Activate
</button>
<button class="btn btn-danger btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')">
<i class="fas fa-trash"></i> Uninstall
</button>
{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %}
{% if not modules %} <div class="empty-state">
<div class="alert alert-info"> <div class="empty-icon"><i class="fas fa-puzzle-piece"></i></div>
<i class="fas fa-info-circle"></i> No modules found in the <code>/modules</code> directory. <h2 class="empty-title">No Modules Found</h2>
<p class="empty-text">No modules found in the <code>/modules</code> directory.</p>
<p class="empty-text">Upload a module package to get started.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<style>
/* Module Manager Specific Styles */
.module-metadata {
margin: var(--space-md) 0;
padding: var(--space-sm) 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.metadata-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 0.875rem;
}
.metadata-label {
color: var(--text-muted);
font-weight: 500;
}
.metadata-value {
color: var(--text-color);
}
.module-status {
margin: var(--space-sm) 0;
text-align: center;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-active {
background: rgba(40, 167, 69, 0.2);
color: #28a745;
border: 1px solid #28a745;
}
.status-inactive {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
border: 1px solid #ffc107;
}
.status-not-installed {
background: rgba(108, 117, 125, 0.2);
color: #6c757d;
border: 1px solid #6c757d;
}
.module-actions {
display: flex;
flex-direction: row;
gap: var(--space-sm);
margin-top: auto;
}
.module-actions .btn {
flex: 1;
justify-content: center;
}
/* Override for active module cards */
.module-card-active {
border-color: var(--success-color);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.15);
}
.module-card-active .module-icon {
background: linear-gradient(135deg, rgba(40, 167, 69, 0.2), rgba(40, 167, 69, 0.1));
color: var(--success-color);
}
/* Make module cards compact */
.module-grid .module-card {
display: flex;
flex-direction: column;
padding: var(--space-md);
gap: var(--space-xs);
}
.module-grid .module-icon {
width: 50px;
height: 50px;
font-size: 1.5rem;
margin: 0 auto var(--space-xs);
}
.module-grid .module-name {
margin: var(--space-xs) 0;
font-size: 1.25rem;
}
.module-grid .module-desc {
flex-grow: 0;
margin: 0 0 var(--space-xs) 0;
font-size: 0.85rem;
line-height: 1.3;
}
.module-metadata {
margin: var(--space-xs) 0;
padding: var(--space-xs) 0;
}
.metadata-row {
padding: 2px 0;
font-size: 0.8125rem;
}
.module-status {
margin: var(--space-xs) 0;
}
.status-badge {
padding: 4px 12px;
font-size: 0.75rem;
}
.module-actions {
margin-top: var(--space-xs);
}
</style>
<script> <script>
// Keep all your existing JavaScript functions exactly as they are
function installModule(moduleKey) { function installModule(moduleKey) {
if (!confirm(`Install module "${moduleKey}"?\n\nThis will create database tables and activate the module.`)) { if (!confirm(`Install module "${moduleKey}"?\n\nThis will create database tables and activate the module.`)) {
return; return;
@@ -97,8 +251,13 @@ function installModule(moduleKey) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
alert(`${data.message}\n\nPlease reload the page.`); if (data.restart_required) {
location.reload(); alert(`${data.message}\n\nServer will restart automatically...`);
restartServerSilent();
} else {
alert(`${data.message}`);
location.reload();
}
} else { } else {
alert(`${data.message}`); alert(`${data.message}`);
} }
@@ -108,11 +267,154 @@ function installModule(moduleKey) {
}); });
} }
function uninstallModule(moduleKey) { function restartServerSilent() {
if (!confirm(`⚠️ UNINSTALL module "${moduleKey}"?\n\nThis will DELETE all module data and cannot be undone!`)) { fetch('/admin/restart', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.body.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; flex-direction: column; background: #1a1a1a; color: white;">
<div style="font-size: 48px; margin-bottom: 20px;">🔄</div>
<h1>Server Restarting...</h1>
<p>Module installed successfully. Please wait...</p>
</div>
`;
setTimeout(() => {
location.reload();
}, 3000);
}
})
.catch(error => {
alert(`❌ Restart failed: ${error}\n\nPlease restart manually.`);
});
}
function uninstallModule(moduleKey, moduleName) {
const stage1Modal = `
<div id="uninstall-modal-stage1" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: #1a1a1a; border: 2px solid #ffc107; border-radius: 8px; padding: 30px; max-width: 500px; color: white;">
<h2 style="color: #ffc107; margin-top: 0;">⚠️ Uninstall Module?</h2>
<p style="margin: 20px 0; font-size: 18px;">Module: <strong>${moduleName}</strong></p>
<p style="margin: 20px 0;">This will:</p>
<ul style="margin: 20px 0; padding-left: 20px;">
<li>Deactivate the module</li>
<li>Remove it from the system</li>
<li>Users will lose access</li>
<li style="color: #dc3545; font-weight: bold;">DELETE ALL DATA PERMANENTLY</li>
</ul>
<p style="margin: 20px 0; background: #2d2d2d; padding: 15px; border-radius: 4px; color: #28a745;">
💡 <strong>Want to keep the data?</strong><br>
Use "Deactivate" instead of "Uninstall"
</p>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button onclick="cancelUninstall()" style="flex: 1; padding: 12px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold;">
Cancel
</button>
<button onclick="proceedToStage2('${moduleKey}', '${moduleName}')" style="flex: 1; padding: 12px; background: #ffc107; color: #1a1a1a; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold;">
Continue
</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', stage1Modal);
}
function proceedToStage2(moduleKey, moduleName) {
document.getElementById('uninstall-modal-stage1').remove();
const stage2Modal = `
<div id="uninstall-modal-stage2" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: #1a1a1a; border: 3px solid #dc3545; border-radius: 8px; padding: 30px; max-width: 550px; color: white;">
<h2 style="color: #dc3545; margin-top: 0;">🚨 Data Will Be Deleted</h2>
<p style="margin: 20px 0; font-size: 18px;">Module: <strong>${moduleName}</strong></p>
<div style="background: #2d2d2d; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #dc3545;">
<p style="margin: 0 0 15px 0; color: #dc3545; font-weight: bold; font-size: 16px;">⚠️ This will permanently delete:</p>
<ul style="margin: 10px 0; color: #fff; line-height: 1.8;">
<li>All sessions</li>
<li>All scans and entries</li>
<li>All locations</li>
<li>All historical data</li>
<li>All database tables</li>
</ul>
<p style="margin: 15px 0 0 0; color: #dc3545; font-weight: bold; font-size: 18px;">THIS CANNOT BE UNDONE!</p>
</div>
<p style="margin: 20px 0; color: #ffc107; text-align: center; font-weight: bold;">
Are you absolutely sure you want to proceed?
</p>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button onclick="cancelUninstall()" style="flex: 1; padding: 14px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold;">
✓ Cancel (Safe)
</button>
<button onclick="proceedToStage3('${moduleKey}', '${moduleName}')" style="flex: 1; padding: 14px; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold;">
Yes, Continue
</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', stage2Modal);
}
function proceedToStage3(moduleKey, moduleName) {
document.getElementById('uninstall-modal-stage2').remove();
const stage3Modal = `
<div id="uninstall-modal-stage3" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: #1a1a1a; border: 4px solid #dc3545; border-radius: 8px; padding: 35px; max-width: 500px; color: white; box-shadow: 0 0 30px rgba(220, 53, 69, 0.5);">
<h2 style="color: #dc3545; margin-top: 0; text-align: center;">🚨 FINAL WARNING 🚨</h2>
<p style="margin: 25px 0; font-size: 18px; text-align: center;">Module: <strong>${moduleName}</strong></p>
<div style="background: #dc3545; color: white; padding: 20px; border-radius: 4px; margin: 25px 0; text-align: center;">
<p style="margin: 0; font-weight: bold; font-size: 20px;">
ALL DATA WILL BE<br>PERMANENTLY DELETED
</p>
</div>
<p style="margin: 25px 0; text-align: center; font-size: 15px;">
This is your <strong style="color: #dc3545;">LAST CHANCE</strong> to cancel.
</p>
<p style="margin: 20px 0; font-weight: bold;">Type <span style="color: #dc3545; font-size: 18px;">DELETE</span> to confirm:</p>
<input type="text" id="delete-confirmation-text" placeholder="Type DELETE here" style="width: 100%; padding: 14px; font-size: 18px; border: 3px solid #dc3545; border-radius: 4px; background: #2d2d2d; color: white; box-sizing: border-box; text-align: center; font-weight: bold;" autocomplete="off">
<p id="delete-text-error" style="color: #dc3545; margin: 10px 0; text-align: center; font-weight: bold; display: none;">❌ You must type DELETE exactly</p>
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button onclick="cancelUninstall()" style="flex: 1; padding: 14px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 17px; font-weight: bold;">
✓ Cancel (Safe)
</button>
<button onclick="finalUninstall('${moduleKey}', '${moduleName}')" style="flex: 1; padding: 14px; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 17px; font-weight: bold;">
Delete Everything
</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', stage3Modal);
setTimeout(() => document.getElementById('delete-confirmation-text').focus(), 100);
}
function cancelUninstall() {
['uninstall-modal-stage1', 'uninstall-modal-stage2', 'uninstall-modal-stage3'].forEach(id => {
const modal = document.getElementById(id);
if (modal) modal.remove();
});
}
function finalUninstall(moduleKey, moduleName) {
const confirmText = document.getElementById('delete-confirmation-text').value;
if (confirmText !== 'DELETE') {
document.getElementById('delete-text-error').style.display = 'block';
document.getElementById('delete-confirmation-text').style.borderColor = '#ff0000';
document.getElementById('delete-confirmation-text').style.boxShadow = '0 0 10px rgba(255, 0, 0, 0.5)';
document.getElementById('delete-confirmation-text').focus();
return; return;
} }
document.getElementById('uninstall-modal-stage3').remove();
fetch(`/admin/modules/${moduleKey}/uninstall`, { fetch(`/admin/modules/${moduleKey}/uninstall`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -122,7 +424,7 @@ function uninstallModule(moduleKey) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
alert(`${data.message}\n\nPlease reload the page.`); alert(`${data.message}\n\nAll data has been permanently deleted.\n\nPlease reload the page.`);
location.reload(); location.reload();
} else { } else {
alert(`${data.message}`); alert(`${data.message}`);
@@ -178,5 +480,387 @@ function deactivateModule(moduleKey) {
alert(`❌ Error: ${error}`); alert(`❌ Error: ${error}`);
}); });
} }
// ==================== MODULE UPLOAD FUNCTIONS ====================
function openUploadModal() {
document.getElementById('upload-modal').style.display = 'flex';
resetUploadModal();
}
function closeUploadModal() {
document.getElementById('upload-modal').style.display = 'none';
resetUploadModal();
}
function closeUploadModalAndReload() {
closeUploadModal();
location.reload();
}
function resetUploadModal() {
document.getElementById('drop-zone').style.display = 'flex';
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('upload-result').style.display = 'none';
document.getElementById('file-input').value = '';
}
// Drag and Drop Handlers
document.addEventListener('DOMContentLoaded', function() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
});
// Handle File Upload
function handleFileUpload(file) {
// Validate file type
if (!file.name.endsWith('.zip')) {
showUploadResult(false, 'Invalid File Type', 'Please upload a ZIP file containing your module.');
return;
}
// Show progress
document.getElementById('drop-zone').style.display = 'none';
document.getElementById('upload-progress').style.display = 'block';
document.getElementById('upload-filename').textContent = file.name;
// Create FormData
const formData = new FormData();
formData.append('module_file', file);
// Upload with progress
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
updateProgress(percentComplete);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
showUploadResult(true, 'Upload Successful!', response.message);
} else {
showUploadResult(false, 'Upload Failed', response.message);
}
} else {
showUploadResult(false, 'Upload Failed', 'Server error: ' + xhr.status);
}
});
xhr.addEventListener('error', () => {
showUploadResult(false, 'Upload Failed', 'Network error occurred');
});
xhr.open('POST', '/admin/modules/upload', true);
xhr.send(formData);
}
function updateProgress(percent) {
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = percent + '%';
if (percent === 100) {
document.getElementById('upload-status').textContent = 'Processing...';
}
}
function showUploadResult(success, title, message) {
document.getElementById('upload-progress').style.display = 'none';
const resultDiv = document.getElementById('upload-result');
resultDiv.style.display = 'block';
resultDiv.className = 'upload-result ' + (success ? 'success' : 'error');
const icon = success
? '<i class="fas fa-check-circle"></i>'
: '<i class="fas fa-exclamation-circle"></i>';
document.getElementById('result-icon').innerHTML = icon;
document.getElementById('result-title').textContent = title;
document.getElementById('result-message').textContent = message;
}
</script> </script>
<!-- Upload Modal -->
<div id="upload-modal" class="upload-modal" style="display: none;">
<div class="upload-modal-overlay" onclick="closeUploadModal()"></div>
<div class="upload-modal-content">
<div class="upload-modal-header">
<h2><i class="fas fa-upload"></i> Upload Module Package</h2>
<button class="close-btn" onclick="closeUploadModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="upload-modal-body">
<!-- Drag & Drop Zone -->
<div id="drop-zone" class="drop-zone">
<div class="drop-zone-content">
<i class="fas fa-cloud-upload-alt"></i>
<h3>Drag & Drop Module ZIP</h3>
<p>or</p>
<label for="file-input" class="btn btn-secondary">
<i class="fas fa-folder-open"></i> Browse Files
</label>
<input type="file" id="file-input" accept=".zip" style="display: none;">
<p class="file-requirements">
<small>
<strong>Requirements:</strong><br>
• ZIP file containing module folder<br>
• Must include manifest.json<br>
• Module key must be unique
</small>
</p>
</div>
</div>
<!-- Upload Progress (hidden by default) -->
<div id="upload-progress" class="upload-progress" style="display: none;">
<div class="progress-info">
<span id="upload-filename"></span>
<span id="upload-status">Uploading...</span>
</div>
<div class="progress-bar-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div class="progress-percentage">
<span id="progress-text">0%</span>
</div>
</div>
<!-- Upload Result (hidden by default) -->
<div id="upload-result" class="upload-result" style="display: none;">
<div id="result-icon"></div>
<h3 id="result-title"></h3>
<p id="result-message"></p>
<button class="btn btn-primary" onclick="closeUploadModalAndReload()">
Close & Refresh
</button>
</div>
</div>
</div>
</div>
<style>
/* Upload Modal */
.upload-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.upload-modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
}
.upload-modal-content {
position: relative;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.upload-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.upload-modal-header h2 {
margin: 0;
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
padding: var(--space-sm);
line-height: 1;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--text-color);
}
.upload-modal-body {
padding: var(--space-xl);
}
/* Drop Zone */
.drop-zone {
border: 3px dashed var(--border-color);
border-radius: 12px;
padding: var(--space-xl);
text-align: center;
transition: all 0.3s;
background: var(--bg-secondary);
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-zone:hover {
border-color: var(--primary-color);
background: rgba(0, 188, 212, 0.05);
}
.drop-zone.drag-over {
border-color: var(--primary-color);
background: rgba(0, 188, 212, 0.1);
transform: scale(1.02);
}
.drop-zone-content {
width: 100%;
}
.drop-zone-content i {
font-size: 4rem;
color: var(--primary-color);
margin-bottom: var(--space-md);
}
.drop-zone-content h3 {
margin: var(--space-md) 0;
color: var(--text-color);
}
.drop-zone-content p {
color: var(--text-muted);
margin: var(--space-sm) 0;
}
.file-requirements {
margin-top: var(--space-lg);
padding: var(--space-md);
background: var(--bg-primary);
border-radius: 8px;
border-left: 4px solid var(--primary-color);
}
/* Upload Progress */
.upload-progress {
text-align: center;
padding: var(--space-xl);
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-md);
color: var(--text-color);
}
.progress-bar-container {
width: 100%;
height: 30px;
background: var(--bg-secondary);
border-radius: 15px;
overflow: hidden;
margin-bottom: var(--space-sm);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--accent-color));
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.progress-percentage {
text-align: center;
font-size: 1.5rem;
font-weight: bold;
color: var(--primary-color);
}
/* Upload Result */
.upload-result {
text-align: center;
padding: var(--space-xl);
}
.upload-result i {
font-size: 5rem;
margin-bottom: var(--space-lg);
}
.upload-result.success i {
color: var(--success-color);
}
.upload-result.error i {
color: var(--danger-color);
}
.upload-result h3 {
margin: var(--space-md) 0;
color: var(--text-color);
}
.upload-result p {
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
</style>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,393 @@
<!-- Add this button to the top of module_manager.html, right after the dashboard-header -->
<div class="upload-section">
<button class="btn btn-primary btn-lg" onclick="openUploadModal()">
<i class="fas fa-upload"></i> Upload New Module
</button>
</div>
<!-- Upload Modal -->
<div id="upload-modal" class="upload-modal" style="display: none;">
<div class="upload-modal-overlay" onclick="closeUploadModal()"></div>
<div class="upload-modal-content">
<div class="upload-modal-header">
<h2><i class="fas fa-upload"></i> Upload Module Package</h2>
<button class="close-btn" onclick="closeUploadModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="upload-modal-body">
<!-- Drag & Drop Zone -->
<div id="drop-zone" class="drop-zone">
<div class="drop-zone-content">
<i class="fas fa-cloud-upload-alt"></i>
<h3>Drag & Drop Module ZIP</h3>
<p>or</p>
<label for="file-input" class="btn btn-secondary">
<i class="fas fa-folder-open"></i> Browse Files
</label>
<input type="file" id="file-input" accept=".zip" style="display: none;">
<p class="file-requirements">
<small>
<strong>Requirements:</strong><br>
• ZIP file containing module folder<br>
• Must include manifest.json<br>
• Module key must be unique
</small>
</p>
</div>
</div>
<!-- Upload Progress (hidden by default) -->
<div id="upload-progress" class="upload-progress" style="display: none;">
<div class="progress-info">
<span id="upload-filename"></span>
<span id="upload-status">Uploading...</span>
</div>
<div class="progress-bar-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div class="progress-percentage">
<span id="progress-text">0%</span>
</div>
</div>
<!-- Upload Result (hidden by default) -->
<div id="upload-result" class="upload-result" style="display: none;">
<div id="result-icon"></div>
<h3 id="result-title"></h3>
<p id="result-message"></p>
<button class="btn btn-primary" onclick="closeUploadModalAndReload()">
Close & Refresh
</button>
</div>
</div>
</div>
</div>
<style>
/* Upload Section */
.upload-section {
margin: var(--space-lg) 0;
text-align: center;
}
/* Upload Modal */
.upload-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.upload-modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
}
.upload-modal-content {
position: relative;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.upload-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg);
border-bottom: 1px solid var(--border-color);
}
.upload-modal-header h2 {
margin: 0;
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
padding: var(--space-sm);
line-height: 1;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--text-color);
}
.upload-modal-body {
padding: var(--space-xl);
}
/* Drop Zone */
.drop-zone {
border: 3px dashed var(--border-color);
border-radius: 12px;
padding: var(--space-xl);
text-align: center;
transition: all 0.3s;
background: var(--bg-secondary);
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-zone:hover {
border-color: var(--primary-color);
background: rgba(0, 188, 212, 0.05);
}
.drop-zone.drag-over {
border-color: var(--primary-color);
background: rgba(0, 188, 212, 0.1);
transform: scale(1.02);
}
.drop-zone-content {
width: 100%;
}
.drop-zone-content i {
font-size: 4rem;
color: var(--primary-color);
margin-bottom: var(--space-md);
}
.drop-zone-content h3 {
margin: var(--space-md) 0;
color: var(--text-color);
}
.drop-zone-content p {
color: var(--text-muted);
margin: var(--space-sm) 0;
}
.file-requirements {
margin-top: var(--space-lg);
padding: var(--space-md);
background: var(--bg-primary);
border-radius: 8px;
border-left: 4px solid var(--primary-color);
}
/* Upload Progress */
.upload-progress {
text-align: center;
padding: var(--space-xl);
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-md);
color: var(--text-color);
}
.progress-bar-container {
width: 100%;
height: 30px;
background: var(--bg-secondary);
border-radius: 15px;
overflow: hidden;
margin-bottom: var(--space-sm);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--accent-color));
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.progress-percentage {
text-align: center;
font-size: 1.5rem;
font-weight: bold;
color: var(--primary-color);
}
/* Upload Result */
.upload-result {
text-align: center;
padding: var(--space-xl);
}
.upload-result i {
font-size: 5rem;
margin-bottom: var(--space-lg);
}
.upload-result.success i {
color: var(--success-color);
}
.upload-result.error i {
color: var(--danger-color);
}
.upload-result h3 {
margin: var(--space-md) 0;
color: var(--text-color);
}
.upload-result p {
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
</style>
<script>
// Upload Modal Functions
function openUploadModal() {
document.getElementById('upload-modal').style.display = 'flex';
resetUploadModal();
}
function closeUploadModal() {
document.getElementById('upload-modal').style.display = 'none';
resetUploadModal();
}
function closeUploadModalAndReload() {
closeUploadModal();
location.reload();
}
function resetUploadModal() {
document.getElementById('drop-zone').style.display = 'flex';
document.getElementById('upload-progress').style.display = 'none';
document.getElementById('upload-result').style.display = 'none';
document.getElementById('file-input').value = '';
}
// Drag and Drop Handlers
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
// Handle File Upload
function handleFileUpload(file) {
// Validate file type
if (!file.name.endsWith('.zip')) {
showUploadResult(false, 'Invalid File Type', 'Please upload a ZIP file containing your module.');
return;
}
// Show progress
document.getElementById('drop-zone').style.display = 'none';
document.getElementById('upload-progress').style.display = 'block';
document.getElementById('upload-filename').textContent = file.name;
// Create FormData
const formData = new FormData();
formData.append('module_file', file);
// Upload with progress
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
updateProgress(percentComplete);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
showUploadResult(true, 'Upload Successful!', response.message);
} else {
showUploadResult(false, 'Upload Failed', response.message);
}
} else {
showUploadResult(false, 'Upload Failed', 'Server error: ' + xhr.status);
}
});
xhr.addEventListener('error', () => {
showUploadResult(false, 'Upload Failed', 'Network error occurred');
});
xhr.open('POST', '/admin/modules/upload', true);
xhr.send(formData);
}
function updateProgress(percent) {
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = percent + '%';
if (percent === 100) {
document.getElementById('upload-status').textContent = 'Processing...';
}
}
function showUploadResult(success, title, message) {
document.getElementById('upload-progress').style.display = 'none';
const resultDiv = document.getElementById('upload-result');
resultDiv.style.display = 'block';
resultDiv.className = 'upload-result ' + (success ? 'success' : 'error');
const icon = success
? '<i class="fas fa-check-circle"></i>'
: '<i class="fas fa-exclamation-circle"></i>';
document.getElementById('result-icon').innerHTML = icon;
document.getElementById('result-title').textContent = title;
document.getElementById('result-message').textContent = message;
}
</script>