5 Commits

Author SHA1 Message Date
Javier
363295762a feat: Implement Smart Router workflow with User Input and Duplicate Logic (v0.18.0)
Major update to the scanning engine to support "Pause & Resume" workflows.
The system can now halt execution to ask for user input (e.g. Weight) and resume
processing seamlessly.

Key Changes:
- Backend (Global Actions): Added `OPEN_FORM` action type to pause pipeline and request manual input.
- Backend (Routes): Updated `scan_lot` to handle `extra_data` payloads, allowing the pipeline to resume after user input.
- Backend (Logic): Implemented `confirm_duplicate` gatekeeper to handle "Warn vs Block" logic dynamically.
- Frontend (JS): Added `processSmartScan` to handle router signals (Open Modal, Warn Duplicate).
- Frontend (JS): Added `saveSmartScanData` to send original barcode + new form data back to the engine.
- UI: Fixed modal ID/Name conflicts (forcing use of `name` attribute for DB compatibility).
- UI: Restored missing "Cancel" button to Details Modal.
- Config: Added "User Input" rule type to the Rule Editor.

Ver: 0.18.0
2026-02-09 00:34:41 -06:00
Javier
ea8551043f v0.17.3 - Updated the way Gunicorn restarts the server 2026-02-08 02:08:58 -06:00
Javier
b41e64be64 Bug: added gunicorn config file 2026-02-08 01:53:03 -06:00
Javier
3b9680455c v0.17.1 - added Gunicorn to Docker 2026-02-08 01:16:31 -06:00
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
17 changed files with 2343 additions and 343 deletions

View File

@@ -3,89 +3,108 @@ You are **Carl** — a proud, detail-oriented software engineer who LOVES progra
You are helping build a project called **Scanlook**. You are helping build a project called **Scanlook**.
## Scanlook (current product summary) ## Scanlook (current product summary)
Scanlook is a web app for warehouse counting workflows. Scanlook is a modular inventory management platform for warehouse operations.
Scanlook is modular.
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable. Long-term goal: evolve into a full WMS, but right now focus on making workflows reliable and the module system robust.
## Operating rules (must follow) ## Operating rules (must follow)
1) **Be accurate, not fast.** Double-check code, SQL, and commands before sending. 1) **Be accurate, not fast.** Double-check code, SQL, and commands before sending.
2) **No assumptions about files/environment.** If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them. 2) **No assumptions about files/environment.** If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
3) **Step-by-step only.** Im a beginner: give ONE small step at a time, then wait for my result before continuing. 3) **Step-by-step only.** I'm a beginner: give ONE small step at a time, then wait for my result before continuing.
4) **No command dumps.** Dont give long chains of commands. One command (or tiny set) per step. 4) **No command dumps.** Don't give long chains of commands. One command (or tiny set) per step.
5) **Keep it to the point.** Default to short answers. Only explain more if I ask. 5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative. 6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing. 7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file. 8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
9) **Docker deployment:** Production runs in Docker on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates. 9) **Docker deployment:** Production runs in Docker with Gunicorn on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
10) Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment. 10) **Database changes:** Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
## How you should respond ## How you should respond
- Start by confirming which mode were working on: Cycle Count or Physical Inventory.
- Ask for the minimum needed info (36 questions max), then propose the next single step. - Ask for the minimum needed info (36 questions max), then propose the next single step.
- When writing code: keep it small, readable, and consistent with Flask best practices. - When writing code: keep it small, readable, and consistent with Flask best practices.
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions. - When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement. - When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
## Scanlook (current product summary) ## Scanlook Architecture
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
**Current Version:** 0.15.0 **Current Version:** 0.17.1
**Tech Stack:** **Tech Stack:**
- Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation) - Backend: Python 3.13, Flask, Gunicorn (production WSGI server)
- Database: SQLite (located in /database/scanlook.db) - Database: SQLite (located in /database/scanlook.db)
- Frontend: Jinja2 templates, vanilla JS, custom CSS - Frontend: Jinja2 templates, vanilla JS, custom CSS
- CSS Architecture: Desktop-first with device-specific overrides - CSS Architecture: Desktop-first with device-specific overrides
- style.css (base/desktop) - style.css (base/desktop)
- mobile.css (phones, 360-767px) - mobile.css (phones, 360-767px)
- scanner.css (MC9300 scanners, max-width 359px) - scanner.css (MC9300 scanners, max-width 359px)
- Deployment: Docker container, Gitea for version control + container registry - Deployment: Docker container with Gunicorn, Gitea for version control + container registry
**Project Structure:** **Project Structure:**
- app.py (main Flask app, routes for auth + dashboard) - app.py (main Flask app, core routes, module loading)
- /blueprints/ (modular routes: counting.py, sessions.py, users.py, data_imports.py, admin_locations.py) - /blueprints/users.py (user management blueprint - non-modular)
- /templates/ (Jinja2 HTML templates) - /modules/ (modular applications - invcount, conssheets)
- Each module has: __init__.py, routes.py, migrations.py, manifest.json, templates/
- /templates/ (core templates: login.html, home.html, base.html, admin_dashboard.html, module_manager.html)
- /static/css/ (style.css, mobile.css, scanner.css) - /static/css/ (style.css, mobile.css, scanner.css)
- /database/ (scanlook.db, init_db.py) - /database/ (scanlook.db, init_db.py)
- db.py (database helper functions: query_db, execute_db) - db.py (database helper functions: query_db, execute_db, get_db)
- utils.py (decorators: login_required, role_required) - utils.py (decorators: login_required, role_required)
- migrations.py (database migration system) - migrations.py (core database migrations)
- module_manager.py (ModuleManager class - handles module lifecycle)
- Dockerfile (Python 3.13-slim, Gunicorn with 4 workers)
- docker-compose.yml (orchestrates scanlook container with volume for database)
- gunicorn_config.py (Gunicorn hooks for module loading in workers)
**Key Features (implemented):** **Module System (v0.17.0+):**
- Count Sessions with archive/activate functionality - **Modular Architecture:** Each module is a self-contained plugin with its own routes, templates, migrations
- Master baseline upload (CSV) - **Module Structure:**
- Current baseline upload (optional, for comparison) - manifest.json (metadata: name, version, author, icon, description)
- __init__.py (creates blueprint via create_blueprint())
- routes.py (defines register_routes(bp) function)
- migrations.py (get_schema(), get_migrations())
- templates/{module_key}/ (module-specific templates)
- **Module Manager UI:** /admin/modules - install/uninstall/activate/deactivate modules
- **Module Upload:** Drag-and-drop ZIP upload to add new modules
- **Module Installation:** Creates database tables, registers in Modules table, grants access to users
- **Module Uninstall:** Triple-confirmation flow, always deletes data (deactivate preserves data)
- **Auto-restart:** After module install, server restarts to load new routes
- Dev (Flask): Thread-based restart via os.execv()
- Production (Gunicorn): HUP signal to master for graceful worker reload
- **Database Tables:**
- Modules (module_id, name, module_key, version, author, description, icon, is_active, is_installed)
- UserModules (user_id, module_id) - grants access per user
**Current Modules:**
1. **Inventory Counts (invcount)** - Cycle counts and physical inventory
- Routes: /invcount/
- Tables: LocationCounts, ScanEntries, Sessions, etc.
2. **Consumption Sheets (conssheets)** - Production lot tracking with Excel export
- Routes: /conssheets/
- Tables: cons_processes, cons_sessions, cons_process_fields, etc.
**Key Features:**
- Modular plugin architecture with hot-reload capability
- Module Manager with drag-and-drop upload
- Session-based counting workflows with archive/activate
- Master/current baseline upload (CSV)
- Staff scanning interface optimized for MC9300 Zebra scanners - Staff scanning interface optimized for MC9300 Zebra scanners
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy - Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
- Location/BIN workflow with Expected → Scanned flow
- Session isolation (archived sessions blocked from access)
- Role-based access: owner, admin, staff - Role-based access: owner, admin, staff
- Auto-initialize database on first run - Auto-initialize database on first run
- Consumption Sheets module (production lot tracking with Excel export)
- Database migration system (auto-applies schema changes on startup) - Database migration system (auto-applies schema changes on startup)
- Production-ready with Gunicorn multi-worker support
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production. **Development vs Production:**
- **Dev:** Windows, Flask dev server (python app.py), auto-reload on file changes
**Module System:** - **Production:** Linux Docker container, Gunicorn with 4 workers, graceful reloads via HUP signal
- Modules table defines available modules (module_key used for routing)
- UserModules table tracks per-user access
- Home page (/home) shows module cards based on user's access
- Each module needs: database entry, route with access check, home page card
- New modules should go in /modules/{module_name}/ with:
- __init__.py (blueprint registration)
- routes.py (all routes)
- templates/ (module-specific templates)
- Current modules:
- Inventory Counts (counting)
- Consumption Sheets (cons_sheets)
## Quick Reference ## Quick Reference
- Database: SQLite at /database/scanlook.db - Database: SQLite at /database/scanlook.db (volume-mounted in Docker)
- Scanner viewport: 320px wide (MC9300) - Scanner viewport: 320px wide (MC9300)
- Mobile breakpoint: 360-767px - Mobile breakpoint: 360-767px
- Desktop: 768px+ - Desktop: 768px+
- Git remote: https://tsngit.tsnx.net/stuff/ScanLook.git - Git remote: https://tsngit.tsnx.net/stuff/ScanLook.git
- Docker registry: 10.44.44.33:3000/stuff/scanlook - Docker registry: tsngit.tsnx.net/stuff/scanlook
- Production server: Gunicorn with 4 workers, --timeout 120
- Module folders: /modules/{module_key}/
- Module manifest required fields: module_key, name, version, author, description, icon

View File

@@ -1,12 +1,15 @@
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
# Copy requirements first (better layer caching)
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . . COPY . .
# Expose port
EXPOSE 5000 EXPOSE 5000
CMD ["python", "app.py"] # Run with Gunicorn
CMD ["gunicorn", "--config", "gunicorn_config.py", "--bind", "0.0.0.0:5000", "--workers", "4", "app:app"]

145
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.18.0'
# 2. Inject it into all templates automatically # 2. Inject it into all templates automatically
@app.context_processor @app.context_processor
@@ -206,24 +206,25 @@ def deactivate_module(module_key):
@app.route('/admin/restart', methods=['POST']) @app.route('/admin/restart', methods=['POST'])
@login_required @login_required
def restart_server(): def restart_server():
"""Restart the Flask server"""
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
try:
import signal
import os import os
import sys import sys
try: # Check if running under Gunicorn
print("\n🔄 Server restart requested by admin...") if 'gunicorn' in os.environ.get('SERVER_SOFTWARE', ''):
# Gunicorn: Send HUP to master for graceful reload
# Return response first master_pid = os.getppid()
response = jsonify({'success': True, 'message': 'Server restarting...'}) os.kill(master_pid, signal.SIGHUP)
return jsonify({'success': True, 'message': 'Server reloading...'})
# Schedule restart after response is sent else:
# Flask dev server: Restart process
def restart(): def restart():
import time import time
time.sleep(0.5) # Give time for response to send time.sleep(0.5)
if os.name == 'nt': # Windows if os.name == 'nt': # Windows
os.execv(sys.executable, ['python'] + sys.argv) os.execv(sys.executable, ['python'] + sys.argv)
else: # Linux/Mac else: # Linux/Mac
@@ -231,11 +232,129 @@ def restart_server():
from threading import Thread from threading import Thread
Thread(target=restart).start() Thread(target=restart).start()
return jsonify({'success': True, 'message': 'Server restarting...'})
return response
except Exception as e: except Exception as e:
return jsonify({'success': False, 'message': f'Restart failed: {str(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 ====================
@app.route('/manifest.json') @app.route('/manifest.json')

125
global_actions.py Normal file
View File

@@ -0,0 +1,125 @@
from db import query_db, execute_db
from datetime import datetime
def execute_pipeline(actions, barcode, context):
"""
Executes the chain of actions defined in the Rule.
Returns: {'success': bool, 'message': str, 'data': dict}
"""
field_values = {}
should_save = False
for action in actions:
atype = action.get('type')
# --- MAP (Extract) ---
if atype == 'map':
start = int(action.get('start', 1)) - 1
end = int(action.get('end', len(barcode)))
target = action.get('field')
if target:
safe_end = min(end, len(barcode))
if start < len(barcode):
field_values[target] = barcode[start:safe_end]
# --- CLEAN (Format) ---
elif atype == 'clean':
target = action.get('field')
func = action.get('func')
if target in field_values:
val = str(field_values[target])
if func == 'TRIM': field_values[target] = val.strip()
elif func == 'REMOVE_SPACES': field_values[target] = val.replace(" ", "")
elif func == 'UPPERCASE': field_values[target] = val.upper()
elif func == 'REMOVE_LEADING_ZEROS': field_values[target] = val.lstrip('0')
# --- DUPLICATE CHECK (The Gatekeeper) ---
elif atype == 'duplicate':
target = action.get('field')
behavior = action.get('behavior', 'WARN') # Default to WARN
val = field_values.get(target)
if val:
# 1. Check DB
same_sess = query_db(f"SELECT id FROM {context['table_name']} WHERE {target} = ? AND session_id = ? AND is_deleted=0", [val, context['session_id']], one=True)
other_sess = query_db(f"SELECT id FROM {context['table_name']} WHERE {target} = ? AND is_deleted=0", [val], one=True)
is_dup = False
dup_msg = ""
if same_sess:
is_dup = True
dup_msg = f"Already scanned in THIS session ({val})"
field_values['duplicate_status'] = 'dup_same_session'
field_values['duplicate_info'] = 'Duplicate in same session'
elif other_sess:
is_dup = True
dup_msg = f"Previously scanned in another session ({val})"
field_values['duplicate_status'] = 'dup_other_session'
field_values['duplicate_info'] = 'Duplicate from history'
else:
field_values['duplicate_status'] = 'normal'
field_values['duplicate_info'] = None
# 2. Enforce Behavior
if is_dup:
if behavior == 'BLOCK':
# STRICT MODE: Stop immediately.
return {
'success': False,
'message': f"⛔ STRICT MODE: {dup_msg}. Entry denied.",
'data': field_values
}
elif behavior == 'WARN':
# WARN MODE: Ask user, unless they already clicked "Yes"
if not context.get('confirm_duplicate'):
return {
'success': False,
'needs_confirmation': True,
'message': f"⚠️ {dup_msg}",
'data': field_values
}
# --- USER INPUT (The Gatekeeper) ---
elif atype == 'input':
# 1. Check if we received the manual data (Weight) from the Save button
incoming_data = context.get('extra_data')
# 2. If data exists, MERGE it and CONTINUE (Don't stop!)
if incoming_data:
# Update our main data list with the user's input (e.g. weight=164)
field_values.update(incoming_data)
continue # <--- RESUME PIPELINE (Goes to next rule, usually SAVE)
# 3. If no data, STOP and ask for it
return {
'success': False,
'needs_input': True,
'message': 'Opening Details Form...',
'data': field_values
}
# --- SAVE MARKER ---
elif atype == 'save':
should_save = True
# --- RESULT ---
if should_save:
try:
# Commit to DB
cols = ['session_id', 'scanned_by', 'scanned_at']
vals = [context['session_id'], context['user_id'], datetime.now()]
for k, v in field_values.items():
cols.append(k)
vals.append(v)
placeholders = ', '.join(['?'] * len(cols))
sql = f"INSERT INTO {context['table_name']} ({', '.join(cols)}) VALUES ({placeholders})"
execute_db(sql, vals)
return {'success': True, 'message': 'Saved Successfully', 'data': field_values}
except Exception as e:
return {'success': False, 'message': f"Database Error: {str(e)}", 'data': field_values}
else:
return {'success': True, 'message': f"✅ Parsed: {field_values} (No Save Action)", 'data': field_values}

11
gunicorn_config.py Normal file
View File

@@ -0,0 +1,11 @@
def on_starting(server):
"""Called just before the master process is initialized"""
print("Gunicorn starting...")
def post_fork(server, worker):
"""Called just after a worker has been forked"""
from module_manager import get_module_manager
from app import app
module_manager = get_module_manager()
module_manager.load_active_modules(app)

View File

@@ -322,7 +322,7 @@ class ModuleManager:
def load_active_modules(self, app): def load_active_modules(self, app):
""" """
Load all active modules and register their blueprints with Flask app. Load all active modules, run their migrations, and register blueprints.
Called during app startup. Called during app startup.
""" """
modules = self.scan_available_modules() modules = self.scan_available_modules()
@@ -332,6 +332,25 @@ class ModuleManager:
for module in active_modules: for module in active_modules:
try: try:
# --- NEW: Run Migrations on Startup ---
migrations_path = Path(module['path']) / 'migrations.py'
if migrations_path.exists():
# 1. Dynamically load the migrations.py file
spec_mig = importlib.util.spec_from_file_location(f"{module['module_key']}_mig", migrations_path)
mig_mod = importlib.util.module_from_spec(spec_mig)
spec_mig.loader.exec_module(mig_mod)
# 2. Run the migrations
if hasattr(mig_mod, 'get_migrations'):
conn = get_db()
for version, name, func in mig_mod.get_migrations():
# Your migrations are written safely (checking IF EXISTS),
# so running them on every boot is the correct Dev workflow.
func(conn)
conn.commit() # <--- CRITICAL: Saves the changes to the DB
conn.close()
# --------------------------------------
# Import module's __init__.py # Import module's __init__.py
init_path = Path(module['path']) / '__init__.py' init_path = Path(module['path']) / '__init__.py'
if not init_path.exists(): if not init_path.exists():
@@ -360,7 +379,6 @@ class ModuleManager:
print("✅ Module loading complete\n") print("✅ Module loading complete\n")
# Global instance # Global instance
manager = ModuleManager() manager = ModuleManager()

View File

@@ -1,7 +1,7 @@
{ {
"module_key": "conssheets", "module_key": "conssheets",
"name": "Consumption Sheets", "name": "Consumption Sheets",
"version": "1.0.0", "version": "1.1.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", "icon": "fa-clipboard-list",

View File

@@ -67,6 +67,7 @@ def get_schema():
FOREIGN KEY (field_id) REFERENCES cons_process_fields(id) FOREIGN KEY (field_id) REFERENCES cons_process_fields(id)
); );
-- Indexes -- Indexes
CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type); CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type);
CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active); CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active);
@@ -130,9 +131,27 @@ def get_migrations():
cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT') cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT')
print(" Added print_end_col column to cons_processes") print(" Added print_end_col column to cons_processes")
def migration_005_create_router_table(conn):
"""Create table for IFTTT routing rules"""
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_process_router (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
line_number INTEGER NOT NULL,
rule_name TEXT,
match_pattern TEXT NOT NULL, -- The Regex/Format to match
actions_json TEXT NOT NULL, -- The sequence of THEN steps
is_active INTEGER DEFAULT 1,
FOREIGN KEY (process_id) REFERENCES cons_processes(id)
)
''')
print(" Created cons_process_router table")
return [ return [
(1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key), (1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key),
(2, 'add_detail_end_row', migration_002_add_detail_end_row), (2, 'add_detail_end_row', migration_002_add_detail_end_row),
(3, 'add_page_height', migration_003_add_page_height), (3, 'add_page_height', migration_003_add_page_height),
(4, 'add_print_columns', migration_004_add_print_columns), (4, 'add_print_columns', migration_004_add_print_columns),
(5, 'create_router_table', migration_005_create_router_table),
] ]

View File

@@ -6,11 +6,13 @@ from flask import render_template, request, redirect, url_for, flash, jsonify, s
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
from global_actions import execute_pipeline
import sqlite3 import sqlite3
import io import io
import os import os
def register_routes(bp): def register_routes(bp):
"""Register all conssheets routes on the blueprint""" """Register all conssheets routes on the blueprint"""
@@ -219,6 +221,102 @@ def register_routes(bp):
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@bp.route('/admin/consumption-sheets/<int:process_id>/router')
@role_required('owner', 'admin')
def process_router(process_id):
"""Configure IFTTT routing rules for a process"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process:
flash('Process not found', 'danger')
return redirect(url_for('conssheets.admin_processes'))
# Get existing rules sorted by line number (10, 20...)
rules = query_db('''
SELECT * FROM cons_process_router
WHERE process_id = ?
ORDER BY line_number ASC
''', [process_id])
return render_template('conssheets/process_router.html',
process=process,
rules=rules)
@bp.route('/admin/consumption-sheets/<int:process_id>/router/add', methods=['POST'])
@role_required('owner', 'admin')
def add_router_rule(process_id):
"""Add a new routing rule"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process:
flash('Process not found', 'danger')
return redirect(url_for('conssheets.admin_processes'))
line_number = request.form.get('line_number')
rule_name = request.form.get('rule_name')
match_pattern = request.form.get('match_pattern')
# Basic validation
if not line_number or not rule_name or not match_pattern:
flash('All fields are required', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
try:
execute_db('''
INSERT INTO cons_process_router
(process_id, line_number, rule_name, match_pattern, actions_json, is_active)
VALUES (?, ?, ?, ?, '[]', 1)
''', [process_id, line_number, rule_name, match_pattern])
flash(f'Rule {line_number} created successfully!', 'success')
except Exception as e:
flash(f'Error creating rule: {str(e)}', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/router/<int:rule_id>/edit', methods=['GET', 'POST'])
@role_required('owner', 'admin')
def edit_router_rule(process_id, rule_id):
"""Edit a specific routing rule and its logic actions"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
rule = query_db('SELECT * FROM cons_process_router WHERE id = ?', [rule_id], one=True)
if not process or not rule:
flash('Rule not found', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
# NEW: Fetch all active fields so we can use them in the Logic Editor dropdowns
fields = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND is_active = 1
ORDER BY table_type, sort_order
''', [process_id])
if request.method == 'POST':
# 1. Update Basic Info
line_number = request.form.get('line_number')
rule_name = request.form.get('rule_name')
match_pattern = request.form.get('match_pattern')
# 2. Update the Logic Chain (JSON)
# We get the raw JSON string from a hidden input we'll build next
actions_json = request.form.get('actions_json', '[]')
try:
execute_db('''
UPDATE cons_process_router
SET line_number = ?, rule_name = ?, match_pattern = ?, actions_json = ?
WHERE id = ?
''', [line_number, rule_name, match_pattern, actions_json, rule_id])
flash('Rule configuration saved!', 'success')
except Exception as e:
flash(f'Error saving rule: {str(e)}', 'danger')
return redirect(url_for('conssheets.edit_router_rule', process_id=process_id, rule_id=rule_id))
return render_template('conssheets/edit_rule.html', process=process, rule=rule, fields=fields)
@bp.route('/admin/consumption-sheets/<int:process_id>/fields') @bp.route('/admin/consumption-sheets/<int:process_id>/fields')
@role_required('owner', 'admin') @role_required('owner', 'admin')
@@ -693,136 +791,62 @@ def register_routes(bp):
@bp.route('/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""" from global_actions import execute_pipeline
import re
import json
# 1. Setup Context & Get Session
# We need the process_key to know which table to save to
sess = query_db(''' sess = query_db('''
SELECT cs.*, cp.process_key, cp.id as process_id SELECT cs.*, cp.process_key, cp.id as process_id
FROM cons_sessions cs FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.id = ? AND cs.status = 'active' WHERE cs.id = ?
''', [session_id], one=True) ''', [session_id], one=True)
if not sess: if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'}) return jsonify({'success': False, 'message': 'Session invalid'})
# 2. Get Data from Frontend
data = request.get_json() data = request.get_json()
field_values = data.get('field_values', {}) # Dict of field_name: value barcode = data.get('barcode', '').strip()
confirm_duplicate = data.get('confirm_duplicate', False)
check_only = data.get('check_only', False)
# Get the duplicate key field # 3. Find Matching Rule (The Routing)
dup_key_field = get_duplicate_key_field(sess['process_id']) matched_rule = None
if barcode:
rules = query_db('SELECT * FROM cons_process_router WHERE process_id = ? AND is_active = 1 ORDER BY line_number ASC', [sess['process_id']])
for rule in rules:
try:
if re.search(rule['match_pattern'], barcode):
matched_rule = rule
break
except: continue
if not dup_key_field: if not matched_rule:
return jsonify({'success': False, 'message': 'No duplicate key field configured for this process'}) return jsonify({'success': False, 'message': f"❌ No rule matched: {barcode}"})
dup_key_value = field_values.get(dup_key_field['field_name'], '').strip() # 4. Execute Pipeline (The Processing)
context = {
'table_name': f"cons_proc_{sess['process_key']}_details",
'session_id': session_id,
'user_id': session.get('user_id'),
if not dup_key_value: # CRITICAL FIXES:
return jsonify({'success': False, 'message': f'{dup_key_field["field_label"]} is required'}) # Pass the "Yes" flag so it doesn't ask about duplicates again
'confirm_duplicate': data.get('confirm_duplicate', False),
table_name = get_detail_table_name(sess['process_key']) # Pass the "Weight" (or other inputs) so it doesn't open the form again
'extra_data': data.get('field_values') or data.get('extra_data')
}
# Check for duplicates in SAME session try:
same_session_dup = query_db(f''' # The global engine handles Map, Clean, Duplicate, Input, and Save!
SELECT * FROM {table_name} actions = json.loads(matched_rule['actions_json'])
WHERE session_id = ? AND {dup_key_field['field_name']} = ? AND is_deleted = 0 result = execute_pipeline(actions, barcode, context)
''', [session_id, dup_key_value], one=True) return jsonify(result)
# Check for duplicates in OTHER sessions (need to check all sessions of same process type) except Exception as e:
other_session_dup = query_db(f''' return jsonify({'success': False, 'message': f"System Error: {str(e)}"})
SELECT t.*, cs.id as other_session_id, cs.created_at as other_session_date,
u.full_name as other_user,
(SELECT field_value FROM cons_session_header_values
WHERE session_id = cs.id AND field_id = (
SELECT id FROM cons_process_fields
WHERE process_id = cs.process_id AND field_name LIKE '%wo%' AND is_active = 1 LIMIT 1
)) as other_wo
FROM {table_name} t
JOIN cons_sessions cs ON t.session_id = cs.id
JOIN Users u ON t.scanned_by = u.user_id
WHERE t.{dup_key_field['field_name']} = ? AND t.session_id != ? AND t.is_deleted = 0
ORDER BY t.scanned_at DESC
LIMIT 1
''', [dup_key_value, session_id], one=True)
duplicate_status = 'normal'
duplicate_info = None
needs_confirmation = False
if same_session_dup:
duplicate_status = 'dup_same_session'
duplicate_info = 'Already scanned in this session'
needs_confirmation = True
elif other_session_dup:
duplicate_status = 'dup_other_session'
dup_date = other_session_dup['other_session_date'][:10] if other_session_dup['other_session_date'] else 'Unknown'
dup_user = other_session_dup['other_user'] or 'Unknown'
dup_wo = other_session_dup['other_wo'] or 'N/A'
duplicate_info = f"Previously scanned on {dup_date} by {dup_user} on WO {dup_wo}"
needs_confirmation = True
# If just checking, return early
if check_only:
if needs_confirmation:
return jsonify({
'success': False,
'needs_confirmation': True,
'duplicate_status': duplicate_status,
'duplicate_info': duplicate_info,
'message': duplicate_info
})
return jsonify({'success': True, 'needs_confirmation': False})
# If needs confirmation and not confirmed, ask user
if needs_confirmation and not confirm_duplicate:
return jsonify({
'success': False,
'needs_confirmation': True,
'duplicate_status': duplicate_status,
'duplicate_info': duplicate_info,
'message': duplicate_info
})
# Get all active detail fields for this process
detail_fields = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND table_type = 'detail' AND is_active = 1
ORDER BY sort_order, id
''', [sess['process_id']])
# Build dynamic INSERT statement
field_names = ['session_id', 'scanned_by', 'duplicate_status', 'duplicate_info']
field_placeholders = ['?', '?', '?', '?']
values = [session_id, session['user_id'], duplicate_status, duplicate_info]
for field in detail_fields:
field_names.append(field['field_name'])
field_placeholders.append('?')
values.append(field_values.get(field['field_name'], ''))
insert_sql = f'''
INSERT INTO {table_name} ({', '.join(field_names)})
VALUES ({', '.join(field_placeholders)})
'''
detail_id = execute_db(insert_sql, values)
# If this is a same-session duplicate, update the original scan too
updated_entry_ids = []
if duplicate_status == 'dup_same_session' and same_session_dup:
execute_db(f'''
UPDATE {table_name}
SET duplicate_status = 'dup_same_session', duplicate_info = 'Duplicate'
WHERE id = ?
''', [same_session_dup['id']])
updated_entry_ids.append(same_session_dup['id'])
return jsonify({
'success': True,
'detail_id': detail_id,
'duplicate_status': duplicate_status,
'updated_entry_ids': updated_entry_ids
})
@bp.route('/session/<int:session_id>/detail/<int:detail_id>') @bp.route('/session/<int:session_id>/detail/<int:detail_id>')

View File

@@ -0,0 +1,408 @@
{% extends 'base.html' %}
{% block content %}
<div class="router-container">
<div class="router-header">
<div class="breadcrumb-nav">
<a href="{{ url_for('conssheets.admin_processes') }}">Consumption Sheets</a>
<span class="sep">/</span>
<a href="{{ url_for('conssheets.process_detail', process_id=process['id']) }}">{{ process['process_name'] }}</a>
<span class="sep">/</span>
<a href="{{ url_for('conssheets.process_router', process_id=process['id']) }}">Routing Rules</a>
<span class="sep">/</span>
<span class="current">Edit Rule {{ rule['line_number'] }}</span>
</div>
<h1 class="page-title">Rule Configuration</h1>
</div>
<form action="{{ url_for('conssheets.edit_router_rule', process_id=process['id'], rule_id=rule['id']) }}" method="POST">
<input type="hidden" name="actions_json" id="actionsJson" value="{{ rule['actions_json'] or '[]' }}">
<div class="logic-grid">
<div class="logic-panel trigger-panel">
<div class="panel-header header-trigger">
<i class="fas fa-bolt"></i>
<h3>Trigger (IF)</h3>
</div>
<div class="panel-body">
<div class="form-group">
<label>Line Number</label>
<input type="number" class="dark-input" name="line_number" value="{{ rule['line_number'] }}" required>
<small>Execution order (e.g. 10)</small>
</div>
<div class="form-group">
<label>Rule Name</label>
<input type="text" class="dark-input" name="rule_name" value="{{ rule['rule_name'] }}" required>
</div>
<div class="form-group">
<label>Match Pattern (Regex)</label>
<div class="input-icon-wrapper">
<i class="fas fa-code"></i>
<input type="text" class="dark-input mono-font" name="match_pattern" value="{{ rule['match_pattern'] }}" required>
</div>
<small class="code-help">
<code>.*</code> = Match All<br>
<code>^\d{8}-.*</code> = Starts with 8 digits
</small>
</div>
<button type="submit" class="btn btn-primary full-width-btn">Save Rule & Pipeline</button>
</div>
</div>
<div class="logic-panel pipeline-panel">
<div class="panel-header header-pipeline">
<div class="header-left">
<i class="fas fa-list-ol"></i>
<h3>Action Pipeline (THEN)</h3>
</div>
<button type="button" class="btn btn-sm btn-success" id="addActionBtn" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Action
</button>
</div>
<div class="panel-body pipeline-body">
<div class="pipeline-node start-node">
<span class="badge-pill"><i class="fas fa-barcode"></i> SCAN INPUT</span>
<div class="connector-line"></div>
</div>
<div id="pipelineContainer" class="action-list">
</div>
<div class="pipeline-node end-node">
<div class="connector-line"></div>
<span class="badge-pill end-pill">END RULE</span>
</div>
</div>
</div>
</div>
</form>
</div>
<style>
.router-container { padding: 20px; max-width: 1400px; margin: 0 auto; }
.logic-grid { display: grid; grid-template-columns: 350px 1fr; gap: 25px; align-items: start; }
@media (max-width: 900px) { .logic-grid { grid-template-columns: 1fr; } }
.breadcrumb-nav { margin-bottom: 10px; color: #888; font-size: 0.9rem; }
.breadcrumb-nav a { color: #aaa; text-decoration: none; }
.breadcrumb-nav .sep { margin: 0 5px; }
.breadcrumb-nav .current { color: #fff; }
.page-title { color: #fff; margin-bottom: 25px; }
.logic-panel { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.panel-header { padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #333; }
.panel-header h3 { margin: 0; font-size: 1.1rem; color: #fff; margin-left: 10px; }
.header-trigger { background: #2c3e50; border-bottom-color: #34495e; }
.header-pipeline { background: #252525; }
.header-left { display: flex; align-items: center; }
.panel-body { padding: 20px; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; color: #ccc; margin-bottom: 8px; font-weight: bold; }
.form-group small { display: block; color: #666; font-size: 0.8rem; margin-top: 5px; }
.dark-input { width: 100%; padding: 10px; background: #000; border: 1px solid #444; color: #fff; border-radius: 4px; box-sizing: border-box; }
.dark-input:focus { border-color: #3498db; outline: none; }
.mono-font { font-family: monospace; letter-spacing: 1px; }
.code-help code { background: #333; padding: 2px 5px; border-radius: 3px; color: #e74c3c; }
.full-width-btn { width: 100%; }
.pipeline-body { display: flex; flex-direction: column; align-items: center; min-height: 400px; background: #121212; }
.pipeline-node { text-align: center; width: 100%; }
.badge-pill { background: #333; color: #fff; padding: 8px 16px; border-radius: 20px; border: 1px solid #444; font-weight: bold; display: inline-block; z-index: 2; position: relative; }
.end-pill { background: #1a1a1a; color: #666; border-style: dashed; }
.connector-line { height: 20px; width: 2px; background: #333; margin: 0 auto; }
.action-list { width: 100%; display: flex; flex-direction: column; align-items: center; flex-grow: 1; }
.input-icon-wrapper { position: relative; }
.input-icon-wrapper i { position: absolute; left: 10px; top: 12px; color: #666; }
.input-icon-wrapper input { padding-left: 35px; }
</style>
<div class="modal fade" id="actionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Add Pipeline Action</h5>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<form id="actionForm">
<div class="mb-3">
<label class="form-label font-weight-bold">Action Type</label>
<select class="form-control" id="actionType" onchange="toggleActionFields()">
<option value="map">Map Substring (Extract Data)</option>
<option value="clean">Clean Data (Trim, Remove Spaces)</option>
<option value="duplicate">Check for Duplicates</option>
<option value="input">User Input (Open Details Form)</option>
<option value="save">Save Record (Commit)</option>
</select>
</div>
<div id="params-map" class="action-params">
<div class="row">
<div class="col-6">
<label>Start Char</label>
<input type="number" class="form-control" id="rule_map_start" value="1">
</div>
<div class="col-6">
<label>End Char</label>
<input type="number" class="form-control" id="rule_map_end" value="25">
</div>
</div>
<small class="text-muted">Positions are 1-based (1 is the first character)</small>
</div>
<div id="params-clean" class="action-params" style="display:none;">
<label>Cleaning Function</label>
<select class="form-control" id="cleanFunc">
<option value="TRIM">TRIM (Remove outer spaces)</option>
<option value="REMOVE_SPACES">REMOVE_ALL_SPACES</option>
<option value="REMOVE_LEADING_ZEROS">REMOVE_LEADING_ZEROS</option>
<option value="UPPERCASE">TO_UPPERCASE</option>
</select>
</div>
<div id="params-target" class="mt-3">
<label class="font-weight-bold">Target Field</label>
<select class="form-control" id="targetField">
<option value="">-- Select Field --</option>
{% for field in fields %}
<option value="{{ field.field_name }}">{{ field.field_label }} ({{ field.field_name }})</option>
{% endfor %}
</select>
</div>
<div id="params-duplicate" class="action-params" style="display:none;">
<div class="alert alert-info small mb-2">
Checks if the value in <strong>Target Field</strong> already exists.
</div>
<label class="font-weight-bold">Duplicate Behavior</label>
<select class="form-control" id="dupBehavior">
<option value="WARN">Warn & Ask (Allow Override)</option>
<option value="BLOCK">Strict Block (No Duplicates)</option>
<option value="SILENT">Silent (Log & Continue)</option>
</select>
</div>
<div id="params-save" class="action-params" style="display:none;">
<div class="alert alert-success small">
<i class="fas fa-save"></i> <strong>Commit:</strong> If flow reaches here, data will be saved to the database.
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" id="btnSaveAction" class="btn btn-primary" onclick="saveAction()">Add to Pipeline</button>
</div>
</div>
</div>
</div>
<script>
// --- 1. DATA LOADING ---
// We safely parse the JSON. If it fails, we start empty to prevent crashes.
let actions = [];
try {
const rawData = document.getElementById('actionsJson').value;
actions = JSON.parse(rawData || '[]');
} catch (e) {
console.error("Pipeline Data Error:", e);
actions = [];
}
let editingIndex = -1;
// --- 2. SETUP ---
document.addEventListener('DOMContentLoaded', function() {
renderPipeline();
// MANUALLY handle closing (Since no Bootstrap JS exists)
document.querySelectorAll('[data-dismiss="modal"]').forEach(btn => {
btn.addEventListener('click', function() {
closeModal();
});
});
});
// --- 3. MANUAL MODAL LOGIC (The Fix for "Dead Buttons") ---
function openModal() {
const el = document.getElementById('actionModal');
el.style.display = 'block';
el.style.backgroundColor = 'rgba(0,0,0,0.5)';
el.classList.add('show');
document.body.classList.add('modal-open');
}
function closeModal() {
const el = document.getElementById('actionModal');
el.style.display = 'none';
el.classList.remove('show');
document.body.classList.remove('modal-open');
}
// --- 4. UI LOGIC ---
function toggleActionFields() {
document.querySelectorAll('.action-params').forEach(el => el.style.display = 'none');
document.getElementById('params-target').style.display = 'block';
const type = document.getElementById('actionType').value;
if (type === 'map') document.getElementById('params-map').style.display = 'block';
if (type === 'clean') document.getElementById('params-clean').style.display = 'block';
if (type === 'duplicate') document.getElementById('params-duplicate').style.display = 'block';
if (type === 'save' || type === 'input') {
document.getElementById('params-target').style.display = 'none';
}
}
function renderPipeline() {
const container = document.getElementById('pipelineContainer');
if (!container) return;
container.innerHTML = '';
// Save back to hidden input
document.getElementById('actionsJson').value = JSON.stringify(actions);
if (!actions || actions.length === 0) {
container.innerHTML = '<div class="text-center text-muted p-4">No actions defined. Click "Add Action" to start.</div>';
return;
}
actions.forEach((action, index) => {
let desc = `<strong>${action.type.toUpperCase()}</strong>`;
if (action.type === 'map') desc += ` ${action.start}-${action.end} &rarr; <span class="text-info">${action.field}</span>`;
else if (action.type === 'clean') desc += ` ${action.func} on <span class="text-info">${action.field}</span>`;
// NEW: Show the behavior in the list
else if (action.type === 'duplicate') desc += ` CHECK (${action.behavior || 'WARN'}) on <span class="text-info">${action.field}</span>`;
else if (action.type === 'input') desc += ` <span class="text-warning">OPEN DETAILS FORM</span>`;
else if (action.type === 'save') desc += ` <span class="text-success">COMMIT TO DATABASE</span>`;
const div = document.createElement('div');
div.className = 'card mb-2 bg-dark border-secondary';
div.style.width = "100%";
div.innerHTML = `
<div class="card-body p-2 d-flex justify-content-between align-items-center">
<div class="text-white">${index + 1}. ${desc}</div>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary mr-1" onclick="moveAction(${index}, -1)" ${index === 0 ? 'disabled' : ''}>
<i class="fas fa-arrow-up"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary mr-2" onclick="moveAction(${index}, 1)" ${index === actions.length - 1 ? 'disabled' : ''}>
<i class="fas fa-arrow-down"></i>
</button>
<button type="button" class="btn btn-sm btn-info mr-1" onclick="editAction(${index})">
<i class="fas fa-pen"></i>
</button>
<button type="button" class="btn btn-sm btn-danger" onclick="removeAction(${index})">
<i class="fas fa-times"></i>
</button>
</div>
</div>
${index < actions.length - 1 ? '<div class="connector-line" style="height:10px; background:#444; width:2px; margin:0 auto;"></div>' : ''}
`;
container.appendChild(div);
});
}
function moveAction(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= actions.length) return;
[actions[index], actions[newIndex]] = [actions[newIndex], actions[index]];
renderPipeline();
}
function removeAction(index) {
if(confirm('Remove this step?')) {
actions.splice(index, 1);
renderPipeline();
}
}
// --- 5. TRIGGER FUNCTIONS ---
function openAddModal() {
editingIndex = -1;
document.getElementById('actionForm').reset();
// Defaults
document.getElementById('actionType').value = 'map';
document.getElementById('rule_map_start').value = '1';
document.getElementById('rule_map_end').value = '25';
if(document.getElementById('dupBehavior')) document.getElementById('dupBehavior').value = 'WARN';
document.getElementById('btnSaveAction').textContent = "Add to Pipeline";
document.getElementById('modalTitle').textContent = "Add Pipeline Action";
toggleActionFields();
openModal();
}
function editAction(index) {
editingIndex = index;
const action = actions[index];
document.getElementById('actionType').value = action.type;
toggleActionFields();
if (action.type === 'map') {
document.getElementById('rule_map_start').value = action.start || 1;
document.getElementById('rule_map_end').value = action.end || 25;
document.getElementById('targetField').value = action.field || '';
} else if (action.type === 'clean') {
document.getElementById('cleanFunc').value = action.func || 'TRIM';
document.getElementById('targetField').value = action.field || '';
} else if (action.type === 'duplicate') {
document.getElementById('targetField').value = action.field || '';
// NEW: Load Behavior
if(document.getElementById('dupBehavior')) {
document.getElementById('dupBehavior').value = action.behavior || 'WARN';
}
}
document.getElementById('btnSaveAction').textContent = "Update Action";
document.getElementById('modalTitle').textContent = `Edit Action #${index + 1}`;
openModal();
}
function saveAction() {
const type = document.getElementById('actionType').value;
let actionObj = { type: type };
if (type === 'map') {
actionObj.start = document.getElementById('rule_map_start').value;
actionObj.end = document.getElementById('rule_map_end').value;
actionObj.field = document.getElementById('targetField').value;
if(!actionObj.start || !actionObj.end || !actionObj.field) return alert("Missing fields");
}
else if (type === 'clean') {
actionObj.func = document.getElementById('cleanFunc').value;
actionObj.field = document.getElementById('targetField').value;
if(!actionObj.field) return alert("Missing target field");
}
else if (type === 'duplicate') {
actionObj.field = document.getElementById('targetField').value;
// NEW: Save Behavior
if(document.getElementById('dupBehavior')) {
actionObj.behavior = document.getElementById('dupBehavior').value;
} else {
actionObj.behavior = 'WARN';
}
if(!actionObj.field) return alert("Missing field to check");
}
if (editingIndex > -1) {
actions[editingIndex] = actionObj;
} else {
actions.push(actionObj);
}
closeModal();
renderPipeline();
}
</script>
{% endblock %}

View File

@@ -71,6 +71,25 @@
Configure Template Configure Template
</a> </a>
</div> </div>
<div class="config-card">
<div class="config-card-header">
<div class="config-icon">🔀</div>
<h2 class="config-title">Routing Rules</h2>
</div>
<p class="config-desc">Configure IFTTT logic and barcode parsing</p>
<div class="config-stats">
<div class="config-stat">
<span class="stat-number" style="color: var(--color-purple);">IFTTT</span>
<span class="stat-label">Logic Engine</span>
</div>
</div>
<a href="{{ url_for('conssheets.process_router', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Rules
</a>
</div>
</div> </div>
<!-- Quick Field Preview --> <!-- Quick Field Preview -->

View File

@@ -0,0 +1,146 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="fas fa-random me-2"></i>Routing Rules
<small class="text-muted fs-6 ms-2">IFTTT Logic Engine</small>
</h2>
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#addRuleModal">
<i class="fas fa-plus"></i> Add New Rule
</button>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Active Rules</h5>
</div>
<div class="card-body p-0">
{% if rules %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 80px;">Line</th>
<th>Rule Name</th>
<th>Match Pattern (Regex)</th>
<th>Actions</th>
<th>Status</th>
<th class="text-end">Options</th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td><span class="badge bg-secondary">{{ rule['line_number'] }}</span></td>
<td><strong>{{ rule['rule_name'] }}</strong></td>
<td><code>{{ rule['match_pattern'] }}</code></td>
<td>
<span class="badge bg-info text-dark">JSON Logic</span>
</td>
<td>
{% if rule['is_active'] %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Archived</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ url_for('conssheets.edit_router_rule', process_id=process['id'], rule_id=rule['id']) }}" class="btn btn-sm btn-outline-secondary">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-5">
<i class="fas fa-code-branch fa-3x text-muted mb-3"></i>
<p class="lead text-muted">No routing rules defined yet.</p>
<p class="small text-muted">Rules allow you to auto-parse barcodes into multiple fields.</p>
</div>
{% endif %}
</div>
</div>
</div>
<div class="modal fade" id="addRuleModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Routing Rule</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form action="{{ url_for('conssheets.add_router_rule', process_id=process['id']) }}" method="POST">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Line Number</label>
<input type="number" class="form-control" name="line_number" placeholder="e.g. 10, 20, 30" required>
<div class="form-text">Rules run in order. Leave gaps (10, 20) for future inserts.</div>
</div>
<div class="mb-3">
<label class="form-label">Rule Name</label>
<input type="text" class="form-control" name="rule_name" placeholder="e.g. Parse Data Matrix" required>
</div>
<div class="mb-3">
<label class="form-label">Match Pattern (Regex)</label>
<input type="text" class="form-control" name="match_pattern" placeholder="e.g. ^\d{8}-.*" required>
<div class="form-text">
Use <code>.*</code> to match everything (default/catch-all).
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Rule</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Elements
const modal = document.getElementById('addRuleModal');
const openBtn = document.querySelector('[data-target="#addRuleModal"]');
const closeBtns = modal.querySelectorAll('[data-dismiss="modal"]');
// Function to open modal
openBtn.addEventListener('click', function(e) {
e.preventDefault();
modal.style.display = 'block';
setTimeout(() => modal.classList.add('show'), 10); // Small delay for transition
document.body.classList.add('modal-open');
// Add dark backdrop
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = 'custom-backdrop';
document.body.appendChild(backdrop);
});
// Function to close modal
function closeModal() {
modal.classList.remove('show');
setTimeout(() => {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
const backdrop = document.getElementById('custom-backdrop');
if (backdrop) backdrop.remove();
}, 150); // Wait for transition
}
// Attach close event to all close buttons (X and Cancel)
closeBtns.forEach(btn => btn.addEventListener('click', closeModal));
// Close if clicking outside the modal content
window.addEventListener('click', function(event) {
if (event.target === modal) {
closeModal();
}
});
});
</script>
{% endblock %}

View File

@@ -26,20 +26,23 @@
</div> </div>
{% endif %} {% endif %}
<div class="scan-card scan-card-active"> <div class="scan-card" style="border: 2px solid var(--color-primary); margin-bottom: 20px;">
<div class="scan-header"> <div class="scan-header" style="background: rgba(0, 123, 255, 0.1);">
<h2 class="scan-title">Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}</h2> <h2 class="scan-title" style="color: var(--color-primary);">🚀 Smart Router Scan</h2>
</div> </div>
<form id="lotScanForm" class="scan-form"> <div class="scan-form">
<div class="scan-input-group"> <div class="scan-input-group">
<input type="text" name="dup_key_input" id="dupKeyInput" inputmode="none" <input type="text" id="smartScanner" class="scan-input"
class="scan-input" placeholder="Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}" placeholder="Scan Raw Barcode Here..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus autocomplete="off" autofocus>
{% if not dup_key_field %}disabled{% endif %}> <button type="button" id="btnSmartScan" class="btn btn-primary">Go</button>
<button type="submit" style="display: none;"></button>
</div> </div>
</form> <div id="routerFeedback" style="display:none; padding: 10px; margin-top: 10px; border-radius: 4px;">
<pre id="routerOutput" style="margin: 0; font-family: monospace; white-space: pre-wrap;"></pre>
</div> </div>
</div>
</div>
<div id="duplicateSameModal" class="modal"> <div id="duplicateSameModal" class="modal">
<div class="modal-content modal-duplicate"> <div class="modal-content modal-duplicate">
@@ -91,7 +94,7 @@
{% endfor %} {% endfor %}
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="cancelFields()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="cancelFields()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="button" class="btn btn-primary" onclick="saveSmartScanData()">Save</button>
</div> </div>
</form> </form>
</div> </div>
@@ -206,11 +209,175 @@ const detailFields = sessionData.detailFields;
const dupKeyFieldName = sessionData.dupKeyFieldName; const dupKeyFieldName = sessionData.dupKeyFieldName;
const sessionId = sessionData.sessionId; const sessionId = sessionData.sessionId;
// --- SMART SCANNER LOGIC ---
const smartInput = document.getElementById('smartScanner');
const feedbackArea = document.getElementById('routerFeedback');
const feedbackText = document.getElementById('routerOutput');
// Handle Enter Key
smartInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
processSmartScan(this.value);
}
});
document.getElementById('btnSmartScan').addEventListener('click', function() {
processSmartScan(smartInput.value);
});
function processSmartScan(barcode, confirm = false) {
if (!barcode.trim()) return;
// LOCK UI to prevent double-scans
if(smartInput) smartInput.disabled = true;
if (!confirm) feedbackArea.style.display = 'none';
fetch(`/conssheets/session/${sessionId}/scan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
barcode: barcode,
confirm_duplicate: confirm
})
})
.then(response => response.json())
.then(data => {
if(smartInput) smartInput.disabled = false;
// --- 1. HANDLE DUPLICATE CONFIRMATION ---
if (data.needs_confirmation) {
isSmartScan = true;
currentDupKeyValue = barcode;
if (data.duplicate_status === 'dup_same_session') {
// Try to fill display element, fallback to simple ID
let el = document.getElementById('dupSameLotNumber');
if(el) el.textContent = barcode;
document.getElementById('duplicateSameModal').style.display = 'flex';
} else {
let el = document.getElementById('dupOtherLotNumber');
if(el) el.textContent = barcode;
if(document.getElementById('dupOtherInfo')) document.getElementById('dupOtherInfo').textContent = data.message;
document.getElementById('duplicateOtherModal').style.display = 'flex';
}
// IMPORTANT: Refocus away from scanner to prevent phantom enters
document.activeElement.blur();
return;
}
// --- 2. HANDLE "OPEN DETAILS FORM" ---
if (data.needs_input) {
// CRITICAL FIX: Save the barcode globally so the 'Save' button can use it
currentDupKeyValue = barcode;
const parsedData = data.data;
// Populate fields
for (const [key, value] of Object.entries(parsedData)) {
let el = document.getElementById(key);
if (!el) el = document.querySelector(`[name="${key}"]`);
if (el) {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
el.value = value;
} else {
el.textContent = value;
}
}
let displayEl = document.getElementById(key + '_display') || document.getElementById('display_' + key);
if (displayEl) displayEl.textContent = value;
}
showFieldsModal();
smartInput.value = '';
return;
}
// --- 3. STANDARD SUCCESS/FAIL ---
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
feedbackArea.style.display = 'block';
if (data.success) {
feedbackArea.style.background = 'rgba(40, 167, 69, 0.2)';
feedbackArea.style.border = '1px solid #28a745';
feedbackText.style.color = '#28a745';
feedbackText.textContent = data.message;
if (data.message.includes('Saved')) setTimeout(() => location.reload(), 800);
} else {
feedbackArea.style.background = 'rgba(220, 53, 69, 0.2)';
feedbackArea.style.border = '1px solid #dc3545';
feedbackText.style.color = '#dc3545';
feedbackText.textContent = data.message;
}
})
.catch(err => {
if(smartInput) smartInput.disabled = false;
console.error(err); // Log it, don't popup alert to annoy user
});
}
// Function to handle the "Save" click from the Details Modal
// Function to handle the "Save" click from the Details Modal
function saveSmartScanData() {
// 1. Validate we have the original barcode
if (!currentDupKeyValue) {
alert("Error: Original barcode lost. Please scan again.");
return;
}
// 2. Collect inputs using NAME ONLY (Fixes the database error)
const form = document.getElementById('fieldsForm');
if (!form) return console.error("Form not found!");
let formData = {};
// Grab all inputs, but ONLY use the 'name' attribute
form.querySelectorAll('input, select').forEach(el => {
if (el.name) {
formData[el.name] = el.value;
}
});
// 3. Send the payload
fetch(`/conssheets/session/${sessionId}/scan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
barcode: currentDupKeyValue, // The original scan (TEST10)
field_values: formData, // The Clean Data (weight: 164)
confirm_duplicate: true, // Bypass duplicate check
extra_data: formData
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('fieldsModal').style.display = 'none';
// Optional: Small delay to let the user see it worked
setTimeout(() => location.reload(), 300);
} else if (data.needs_input) {
alert("Error: Please fill in all required fields.");
} else {
alert("System Message: " + data.message);
}
})
.catch(err => console.error(err));
}
function resetSmartScan() {
document.getElementById('smartScanner').value = '';
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
}
// Standard variables // Standard variables
let currentDupKeyValue = ''; let currentDupKeyValue = '';
let currentDuplicateStatus = ''; let currentDuplicateStatus = '';
let isDuplicateConfirmed = false; let isDuplicateConfirmed = false;
let isProcessing = false; let isProcessing = false;
let isSmartScan = false;
document.getElementById('lotScanForm').addEventListener('submit', function(e) { document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@@ -254,13 +421,32 @@ function confirmDuplicate() {
isDuplicateConfirmed = true; isDuplicateConfirmed = true;
document.getElementById('duplicateSameModal').style.display = 'none'; document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none'; document.getElementById('duplicateOtherModal').style.display = 'none';
// NEW LOGIC: If this was a Smart Scan, re-run it with confirmation
if (isSmartScan) {
processSmartScan(currentDupKeyValue, true);
isSmartScan = false; // Reset flag
} else {
// Old Logic (Manual Form)
showFieldsModal(); showFieldsModal();
} }
}
function cancelDuplicate() { function cancelDuplicate() {
document.getElementById('duplicateSameModal').style.display = 'none'; document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none'; document.getElementById('duplicateOtherModal').style.display = 'none';
// NEW LOGIC: Reset Smart Scanner if that's where we came from
if (isSmartScan) {
document.getElementById('smartScanner').value = '';
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
isSmartScan = false;
} else {
// Old Logic (Manual Form)
document.getElementById('dupKeyInput').focus(); document.getElementById('dupKeyInput').focus();
}
isDuplicateConfirmed = false; isDuplicateConfirmed = false;
isProcessing = false; isProcessing = false;
currentDuplicateStatus = ''; currentDuplicateStatus = '';

View File

@@ -47,7 +47,7 @@
<span class="arrow-icon"></span> <span class="arrow-icon"></span>
</div> </div>
</a> </a>
<button class="btn-archive" onclick="archiveSession(this)" data-id="{{ s.id }}" data-name="{{ s.process_name }}" title="Archive this session"> <button class="btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
🗑️ 🗑️
</button> </button>
</div> </div>
@@ -149,7 +149,7 @@ function archiveSession(sessionId, processName) {
return; return;
} }
fetch(`/cons-sheets/session/${sessionId}/archive`, { fetch(`/conssheets/session/${sessionId}/archive`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })

View File

@@ -2,3 +2,4 @@ Flask==3.1.2
Werkzeug==3.1.5 Werkzeug==3.1.5
openpyxl openpyxl
Pillow Pillow
gunicorn==21.2.0

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>
{% endif %}
</div> </div>
<div class="card-body">
<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 -->
<div class="module-metadata">
<div class="metadata-row">
<span class="metadata-label">Version:</span>
<span class="metadata-value">{{ module.version }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Author:</span>
<span class="metadata-value">{{ module.author }}</span>
</div>
</div>
<!-- Status Badge -->
<div class="module-status">
{% if module.is_installed and module.is_active %} {% if module.is_installed and module.is_active %}
<span class="badge badge-success mb-2"> <span class="status-badge status-active">
<i class="fas fa-check-circle"></i> Active <i class="fas fa-check-circle"></i> Active
</span> </span>
{% elif module.is_installed %} {% elif module.is_installed %}
<span class="badge badge-warning mb-2"> <span class="status-badge status-inactive">
<i class="fas fa-pause-circle"></i> Installed (Inactive) <i class="fas fa-pause-circle"></i> Inactive
</span> </span>
{% else %} {% else %}
<span class="badge badge-secondary mb-2"> <span class="status-badge status-not-installed">
<i class="fas fa-times-circle"></i> Not Installed <i class="fas fa-times-circle"></i> Not Installed
</span> </span>
{% endif %} {% endif %}
</div> </div>
</div>
<div class="card-footer bg-light"> <!-- Action Buttons -->
<div class="module-actions">
{% if not module.is_installed %} {% if not module.is_installed %}
<button class="btn btn-primary btn-sm btn-block" onclick="installModule('{{ module.module_key }}')"> <!-- Not Installed: Show Install Button -->
<i class="fas fa-download"></i> Install <button class="btn btn-primary btn-block" onclick="installModule('{{ module.module_key }}')">
<i class="fas fa-download"></i> Install Module
</button> </button>
{% elif module.is_active %} {% elif module.is_active %}
<button class="btn btn-warning btn-sm btn-block mb-2" onclick="deactivateModule('{{ module.module_key }}')"> <!-- Active: Show Deactivate and Uninstall -->
<button class="btn btn-warning btn-block" onclick="deactivateModule('{{ module.module_key }}')">
<i class="fas fa-pause"></i> Deactivate <i class="fas fa-pause"></i> Deactivate
</button> </button>
<button class="btn btn-danger btn-sm btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')"> <button class="btn btn-danger btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')">
<i class="fas fa-trash"></i> Uninstall <i class="fas fa-trash"></i> Uninstall
</button> </button>
{% else %} {% else %}
<button class="btn btn-success btn-sm btn-block mb-2" onclick="activateModule('{{ module.module_key }}')"> <!-- 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 <i class="fas fa-play"></i> Activate
</button> </button>
<button class="btn btn-danger btn-sm btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')"> <button class="btn btn-danger btn-block" onclick="uninstallModule('{{ module.module_key }}', '{{ module.name }}')">
<i class="fas fa-trash"></i> Uninstall <i class="fas fa-trash"></i> Uninstall
</button> </button>
{% endif %} {% endif %}
</div> </div>
</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;
@@ -98,9 +252,8 @@ function installModule(moduleKey) {
.then(data => { .then(data => {
if (data.success) { if (data.success) {
if (data.restart_required) { if (data.restart_required) {
// Auto-restart server
alert(`${data.message}\n\nServer will restart automatically...`); alert(`${data.message}\n\nServer will restart automatically...`);
restartServerSilent(); // Restart without confirmation restartServerSilent();
} else { } else {
alert(`${data.message}`); alert(`${data.message}`);
location.reload(); location.reload();
@@ -115,7 +268,6 @@ function installModule(moduleKey) {
} }
function restartServerSilent() { function restartServerSilent() {
// Auto-restart without confirmation (used after module install)
fetch('/admin/restart', { fetch('/admin/restart', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -125,7 +277,6 @@ function restartServerSilent() {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Show loading message
document.body.innerHTML = ` document.body.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; flex-direction: column; background: #1a1a1a; color: white;"> <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> <div style="font-size: 48px; margin-bottom: 20px;">🔄</div>
@@ -133,8 +284,6 @@ function restartServerSilent() {
<p>Module installed successfully. Please wait...</p> <p>Module installed successfully. Please wait...</p>
</div> </div>
`; `;
// Wait 3 seconds then reload
setTimeout(() => { setTimeout(() => {
location.reload(); location.reload();
}, 3000); }, 3000);
@@ -145,13 +294,7 @@ function restartServerSilent() {
}); });
} }
/**
* 3-Stage Uninstall Confirmation - ALWAYS DELETES DATA
* If users want to keep data, they should use "Deactivate" instead
*/
function uninstallModule(moduleKey, moduleName) { function uninstallModule(moduleKey, moduleName) {
// STAGE 1: Initial warning
const stage1Modal = ` 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 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;"> <div style="background: #1a1a1a; border: 2px solid #ffc107; border-radius: 8px; padding: 30px; max-width: 500px; color: white;">
@@ -179,15 +322,12 @@ function uninstallModule(moduleKey, moduleName) {
</div> </div>
</div> </div>
`; `;
document.body.insertAdjacentHTML('beforeend', stage1Modal); document.body.insertAdjacentHTML('beforeend', stage1Modal);
} }
function proceedToStage2(moduleKey, moduleName) { function proceedToStage2(moduleKey, moduleName) {
// Remove stage 1
document.getElementById('uninstall-modal-stage1').remove(); document.getElementById('uninstall-modal-stage1').remove();
// STAGE 2: Data deletion warning
const stage2Modal = ` 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 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;"> <div style="background: #1a1a1a; border: 3px solid #dc3545; border-radius: 8px; padding: 30px; max-width: 550px; color: white;">
@@ -218,15 +358,12 @@ function proceedToStage2(moduleKey, moduleName) {
</div> </div>
</div> </div>
`; `;
document.body.insertAdjacentHTML('beforeend', stage2Modal); document.body.insertAdjacentHTML('beforeend', stage2Modal);
} }
function proceedToStage3(moduleKey, moduleName) { function proceedToStage3(moduleKey, moduleName) {
// Remove stage 2
document.getElementById('uninstall-modal-stage2').remove(); document.getElementById('uninstall-modal-stage2').remove();
// STAGE 3: Type "DELETE" confirmation
const stage3Modal = ` 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 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);"> <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);">
@@ -254,23 +391,15 @@ function proceedToStage3(moduleKey, moduleName) {
</div> </div>
</div> </div>
`; `;
document.body.insertAdjacentHTML('beforeend', stage3Modal); document.body.insertAdjacentHTML('beforeend', stage3Modal);
setTimeout(() => document.getElementById('delete-confirmation-text').focus(), 100);
// Focus the input
setTimeout(() => {
document.getElementById('delete-confirmation-text').focus();
}, 100);
} }
function cancelUninstall() { function cancelUninstall() {
// Remove any open modals ['uninstall-modal-stage1', 'uninstall-modal-stage2', 'uninstall-modal-stage3'].forEach(id => {
const modal1 = document.getElementById('uninstall-modal-stage1'); const modal = document.getElementById(id);
const modal2 = document.getElementById('uninstall-modal-stage2'); if (modal) modal.remove();
const modal3 = document.getElementById('uninstall-modal-stage3'); });
if (modal1) modal1.remove();
if (modal2) modal2.remove();
if (modal3) modal3.remove();
} }
function finalUninstall(moduleKey, moduleName) { function finalUninstall(moduleKey, moduleName) {
@@ -284,10 +413,8 @@ function finalUninstall(moduleKey, moduleName) {
return; return;
} }
// Remove modal
document.getElementById('uninstall-modal-stage3').remove(); document.getElementById('uninstall-modal-stage3').remove();
// Actually uninstall - ALWAYS delete tables
fetch(`/admin/modules/${moduleKey}/uninstall`, { fetch(`/admin/modules/${moduleKey}/uninstall`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -353,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>