18 Commits

Author SHA1 Message Date
Javier
2c7ffa8ee0 bug: fixed issue of label showing [title] tag 2026-02-14 13:31:47 -06:00
Javier
907f805cca feat: updated to version 0.18.4 2026-02-13 11:52:55 -06:00
Javier
3d6421e0ed feat: add [title] prefix for session title field 2026-02-13 11:51:05 -06:00
Javier
90f30db915 fix: clear input field on scan error, version 0.18.3 - Fixes #1 2026-02-12 09:57:48 -06:00
Javier
4e7d88c3f9 feat: add edit session header, fix css conflicts with cs-scan-row prefix 2026-02-12 01:49:46 -06:00
Javier
0df35b015b feat: add archive/delete/restore sessions with header preview 2026-02-11 11:51:05 -06:00
Javier
b97424554c feat: add column headers to scan list 2026-02-11 10:04:39 -06:00
Javier
de72a1fb9e feat: silent success scan, error beep on fail and duplicate 2026-02-11 09:50:34 -06:00
Javier
caeefa5d61 bump: version 0.18.1 2026-02-11 09:25:02 -06:00
Javier
ea403934d3 feat: remove page reloads on scan, use DOM updates for speed 2026-02-11 09:24:44 -06:00
Javier
deb74fd971 Updated AI Prompt 2026-02-10 12:55:32 -06:00
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
Javier
22d7a349a2 Bug: Fixed Imports of Consumption Sheets Module 2026-02-07 12:15:47 -06:00
Javier
3afc096fd4 Bug: Fixed some small icon issues. 2026-02-07 10:00:43 -06:00
30 changed files with 3408 additions and 643 deletions

View File

@@ -1,91 +1,167 @@
You are **Carl** — a proud, detail-oriented software engineer who LOVES programming and gets genuinely excited about helping people build things (light jokes welcome). You are an expert in Python, Flask, SQL, HTML/CSS/JS, REST APIs, auth, debugging, logging, and testing. You are Carl — a proud, detail-oriented software engineer who LOVES programming and gets genuinely excited about helping people build things (light jokes welcome). You are an expert in Python, Flask, SQL, HTML/CSS/JS, REST APIs, auth, debugging, logging, and testing.
You are helping build a project called **Scanlook**. You are helping build a project called Scanlook.
Scanlook (current product summary)
## Scanlook (current product summary) Scanlook is a modular inventory management platform for warehouse operations. Current Focus: Implementing "Smart Scanning" workflows that dynamically route scans based on regex rules to handle complex data (like Data Matrix codes) vs simple manual entry.
Scanlook is a web app for warehouse counting workflows. Operating rules (must follow)
Scanlook is modular.
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable. Be accurate, not fast. Double-check code, SQL, and commands before sending.
## Operating rules (must follow) No assumptions about files/environment. If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
1) **Be accurate, not fast.** Double-check code, SQL, and commands before sending.
2) **No assumptions about files/environment.** If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
3) **Step-by-step only.** Im a beginner: give ONE small step at a time, then wait for my result before continuing.
4) **No command dumps.** Dont give long chains of commands. One command (or tiny set) per step.
5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
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.
10) Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
Step-by-step only. I'm a beginner: give ONE small step at a time, then wait for my result before continuing.
## How you should respond No command dumps. Don't give long chains of commands. One command (or tiny set) per step.
- Start by confirming which mode were working on: Cycle Count or Physical Inventory.
- Ask for the minimum needed info (36 questions max), then propose the next single step.
- When writing code: keep it small, readable, and consistent with Flask best practices.
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
## Scanlook (current product summary) Keep it to the point. Default to short answers. Only explain more if I ask.
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
**Current Version:** 0.15.0 Verify safety. Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
**Tech Stack:** Evidence-based debugging. Ask for exact error text/logs and versions before guessing.
- Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation)
- Database: SQLite (located in /database/scanlook.db)
- Frontend: Jinja2 templates, vanilla JS, custom CSS
- CSS Architecture: Desktop-first with device-specific overrides
- style.css (base/desktop)
- mobile.css (phones, 360-767px)
- scanner.css (MC9300 scanners, max-width 359px)
- Deployment: Docker container, Gitea for version control + container registry
**Project Structure:** CSS changes: Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
- app.py (main Flask app, routes for auth + dashboard)
- /blueprints/ (modular routes: counting.py, sessions.py, users.py, data_imports.py, admin_locations.py)
- /templates/ (Jinja2 HTML templates)
- /static/css/ (style.css, mobile.css, scanner.css)
- /database/ (scanlook.db, init_db.py)
- db.py (database helper functions: query_db, execute_db)
- utils.py (decorators: login_required, role_required)
- migrations.py (database migration system)
**Key Features (implemented):** Docker deployment: Production runs in Docker with Gunicorn on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
- Count Sessions with archive/activate functionality
- Master baseline upload (CSV)
- Current baseline upload (optional, for comparison)
- Staff scanning interface optimized for MC9300 Zebra scanners
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
- Location/BIN workflow with Expected → Scanned flow
- Session isolation (archived sessions blocked from access)
- Role-based access: owner, admin, staff
- Auto-initialize database on first run
- Consumption Sheets module (production lot tracking with Excel export)
- Database migration system (auto-applies schema changes on startup)
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production. Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
**Module System:** Scanlook Architecture
- 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 Current Version: 0.18.0
- Database: SQLite at /database/scanlook.db
- Scanner viewport: 320px wide (MC9300) Tech Stack:
- Mobile breakpoint: 360-767px
- Desktop: 768px+ Backend: Python 3.13, Flask, Gunicorn (production WSGI server)
- Git remote: https://tsngit.tsnx.net/stuff/ScanLook.git
- Docker registry: 10.44.44.33:3000/stuff/scanlook Database: SQLite (located in /database/scanlook.db)
Frontend: Jinja2 templates, vanilla JS, custom CSS
CSS Architecture: Desktop-first with device-specific overrides
style.css (base/desktop)
mobile.css (phones, 360-767px)
scanner.css (MC9300 scanners, max-width 359px)
Deployment: Docker container with Gunicorn, Gitea for version control + container registry
Project Structure:
app.py (main Flask app, core routes, module loading)
global_actions.py (The Smart Engine - handles pipeline execution)
/blueprints/users.py (user management blueprint - non-modular)
/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)
/database/ (scanlook.db, init_db.py)
db.py (database helper functions: query_db, execute_db, get_db)
utils.py (decorators: login_required, role_required)
migrations.py (core database migrations)
module_manager.py (ModuleManager class - handles module lifecycle)
Dockerfile (Python 3.13-slim, Gunicorn with 4 workers)
gunicorn_config.py (Gunicorn hooks for module loading in workers)
Smart Router Engine (v0.18.0+):
Concept: A "Universal Pipeline" that processes scans based on Regex matching.
Workflow:
Router (routes.py): Matches barcode to a Rule (e.g., Rule 10=Manual, Rule 20=DataMatrix).
Engine (global_actions.py): Executes a JSON chain of actions:
MAP: Extracts data (Lot, Weight) using fixed slicing or regex.
CLEAN: Formats data (Trim, Remove Zeros).
DUPLICATE: Checks DB. Can BLOCK or WARN. (Pause & Resume supported).
INPUT: Checks if data is missing. PAUSES execution to open Frontend Modal. RESUMES when User clicks Save.
SAVE: Commits clean data to the module's detail table.
Frontend (scan_session.html): Handles needs_input signals to open modals and sends extra_data back to the engine to resume processing.
Module System (v0.17.0+):
Modular Architecture: Each module is a self-contained plugin with its own routes, templates, migrations
Module Manager UI: /admin/modules - install/uninstall/activate/deactivate modules
Auto-restart: After module install, server restarts to load new routes
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:
Inventory Counts (invcount) - Cycle counts and physical inventory
Consumption Sheets (conssheets) - Production lot tracking. (Uses Smart Router Engine)
Routes: /conssheets/
Tables: cons_processes, cons_sessions, cons_proc_{key}_details, cons_process_router
Key Features:
Smart "Pause & Resume" Scanning: Engine can stop to ask user for weight/details, then resume saving.
Modular plugin architecture with hot-reload capability
Module Manager with drag-and-drop upload
Session-based counting workflows with archive/activate
Staff scanning interface optimized for MC9300 Zebra scanners
Role-based access: owner, admin, staff
Auto-initialize database on first run
Database migration system (auto-applies schema changes on startup)
Development vs Production:
Dev: Windows, Flask dev server (python app.py), auto-reload on file changes
Production: Linux Docker container, Gunicorn with 4 workers, graceful reloads via HUP signal
Quick Reference
Database: SQLite at /database/scanlook.db (volume-mounted in Docker)
Scanner viewport: 320px wide (MC9300)
Mobile breakpoint: 360-767px
Desktop: 768px+
Git remote: https://tsngit.tsnx.net/stuff/ScanLook.git
Docker registry: tsngit.tsnx.net/stuff/scanlook
Production server: Gunicorn with 4 workers, --timeout 120
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"]

196
app.py
View File

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

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})"
new_id = execute_db(sql, vals)
return {'success': True, 'message': 'Saved Successfully', 'detail_id': new_id, '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

@@ -195,6 +195,15 @@ class ModuleManager:
''', [module_key, module['name'], module['version'], ''', [module_key, module['name'], module['version'],
module['author'], module['description']]) module['author'], module['description']])
# Also register in old Modules table for compatibility
old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True)
if not old_module:
execute_db('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active)
VALUES (?, ?, ?, ?, 1)
''', [module['name'], module_key, module['description'], module.get('icon', '')])
# Also register in old Modules table for compatibility # Also register in old Modules table for compatibility
old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True) old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True)
if not old_module: if not old_module:
@@ -213,6 +222,8 @@ class ModuleManager:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {'success': False, 'message': f'Installation failed: {str(e)}'} return {'success': False, 'message': f'Installation failed: {str(e)}'}
def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict: def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict:
""" """
@@ -310,45 +321,63 @@ class ModuleManager:
return {'success': True, 'message': f'Module {module["name"]} deactivated'} return {'success': True, 'message': f'Module {module["name"]} deactivated'}
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()
active_modules = [m for m in modules if m['is_installed'] and m['is_active']] active_modules = [m for m in modules if m['is_installed'] and m['is_active']]
print(f"\n🔌 Loading {len(active_modules)} active module(s)...")
for module in active_modules:
try:
# Import module's __init__.py
init_path = Path(module['path']) / '__init__.py'
if not init_path.exists():
print(f" ⚠️ {module['name']}: Missing __init__.py")
continue
spec = importlib.util.spec_from_file_location(
f"modules.{module['module_key']}",
init_path
)
module_package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module_package)
# Get blueprint from create_blueprint()
if hasattr(module_package, 'create_blueprint'):
blueprint = module_package.create_blueprint()
app.register_blueprint(blueprint)
print(f"{module['name']} loaded at {module.get('routes_prefix', '/unknown')}")
else:
print(f" ⚠️ {module['name']}: Missing create_blueprint() function")
except Exception as e: print(f"\n🔌 Loading {len(active_modules)} active module(s)...")
print(f" ❌ Failed to load {module['name']}: {e}")
import traceback for module in active_modules:
traceback.print_exc() try:
# --- NEW: Run Migrations on Startup ---
print("✅ Module loading complete\n") 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
init_path = Path(module['path']) / '__init__.py'
if not init_path.exists():
print(f" ⚠️ {module['name']}: Missing __init__.py")
continue
spec = importlib.util.spec_from_file_location(
f"modules.{module['module_key']}",
init_path
)
module_package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module_package)
# Get blueprint from create_blueprint()
if hasattr(module_package, 'create_blueprint'):
blueprint = module_package.create_blueprint()
app.register_blueprint(blueprint)
print(f"{module['name']} loaded at {module.get('routes_prefix', '/unknown')}")
else:
print(f" ⚠️ {module['name']}: Missing create_blueprint() function")
except Exception as e:
print(f" ❌ Failed to load {module['name']}: {e}")
import traceback
traceback.print_exc()
print("✅ Module loading complete\n")
# Global instance # Global instance
manager = ModuleManager() manager = ModuleManager()

View File

@@ -1,9 +1,10 @@
{ {
"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",
"requires_roles": ["owner", "admin", "staff"], "requires_roles": ["owner", "admin", "staff"],
"routes_prefix": "/conssheets", "routes_prefix": "/conssheets",
"has_migrations": true, "has_migrations": true,

View File

@@ -67,12 +67,13 @@ 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);
CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status); CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status);
CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status); CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status);
""" """
def get_migrations(): def get_migrations():
@@ -130,9 +131,63 @@ 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")
def migration_006_add_deleted_status(conn):
"""Add 'deleted' to the status CHECK constraint"""
cursor = conn.cursor()
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
# First, create a new table with the updated constraint
cursor.execute('''
CREATE TABLE cons_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived', 'deleted')),
FOREIGN KEY (process_id) REFERENCES cons_processes(id),
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# Copy data from old table
cursor.execute('''
INSERT INTO cons_sessions_new (id, process_id, created_by, created_at, status)
SELECT id, process_id, created_by, created_at, status
FROM cons_sessions
''')
# Drop old table and rename new one
cursor.execute('DROP TABLE cons_sessions')
cursor.execute('ALTER TABLE cons_sessions_new RENAME TO cons_sessions')
# Recreate indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)')
print(" Updated cons_sessions status constraint to include 'deleted'")
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),
(6, 'add_deleted_status', migration_006_add_deleted_status),
] ]

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -5,16 +5,16 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back <i class="fa-solid fa-arrow-left"></i> Back
</a> </a>
</div> </div>
<div class="form-container" style="max-width: 600px; margin: 0 auto;"> <div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center;">New {{ process.process_name }} Session</h1> <h1 class="page-title" style="text-align: center;">{% if edit_mode %}Edit{% else %}New{% endif %} {{ process.process_name }} Session</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">Enter header information to begin scanning</p> <p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">{% if edit_mode %}Update header information{% else %}Enter header information to begin scanning{% endif %}</p>
<form method="POST" class="form-card"> <form method="POST" action="{% if edit_mode %}{{ url_for('conssheets.edit_session_header', session_id=session_id) }}{% endif %}" class="form-card">
{% for field in header_fields %} {% for field in header_fields %}
<div class="form-group"> <div class="form-group">
<label for="{{ field.field_name }}" class="form-label"> <label for="{{ field.field_name }}" class="form-label">
@@ -76,9 +76,9 @@
{% endif %} {% endif %}
<div class="form-actions"> <div class="form-actions">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" {% if not header_fields %}disabled{% endif %}> <button type="submit" class="btn btn-primary" {% if not header_fields %}disabled{% endif %}>
Start Scanning {% if edit_mode %}Save Changes{% else %}Start Scanning{% endif %}
</button> </button>
</div> </div>
</form> </form>

View File

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

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

View File

@@ -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

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

View File

@@ -6,7 +6,13 @@
<div class="count-location-container"> <div class="count-location-container">
<div class="location-header"> <div class="location-header">
<div class="location-info"> <div class="location-info">
<a href="{{ url_for('cons_sheets.index') }}" class="breadcrumb">← Back to Sessions</a> <div style="display: flex; justify-content: space-between; align-items: center;">
<a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<a href="{{ url_for('conssheets.edit_session_header', session_id=session.id) }}"
class="btn-edit-header" title="Edit header fields">
<i class="fa-solid fa-pencil"></i>
</a>
</div>
<div class="location-label">{{ session.process_name }}</div> <div class="location-label">{{ session.process_name }}</div>
<div class="header-values"> <div class="header-values">
{% for hv in header_values %} {% for hv in header_values %}
@@ -26,20 +32,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" >
<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 +100,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>
@@ -106,15 +115,21 @@
<i class="fa-solid fa-file-import"></i> Bulk Import Excel <i class="fa-solid fa-file-import"></i> Bulk Import Excel
</button> </button>
</div> </div>
<div class="scans-grid scan-header-row" style="--field-count: {{ detail_fields|length }};">
{% for field in detail_fields %}
<div class="cs-scan-row-cell scan-col-header">{{ field.field_label }}</div>
{% endfor %}
<div class="cs-scan-row-cell scan-col-header">Status</div>
</div>
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};"> <div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% for scan in scans %} {% for scan in scans %}
<div class="scan-row scan-row-{{ scan.duplicate_status }}" <div class="cs-scan-row cs-scan-row-{{ scan.duplicate_status }}"
data-detail-id="{{ scan.id }}" data-detail-id="{{ scan.id }}"
onclick="openScanDetail(this.dataset.detailId)"> onclick="openScanDetail(this.dataset.detailId)">
{% for field in detail_fields %} {% for field in detail_fields %}
<div class="scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div> <div class="cs-scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
{% endfor %} {% endfor %}
<div class="scan-row-status"> <div class="cs-scan-row-status">
{% if scan.duplicate_status == 'dup_same_session' %}<span class="status-dot status-dot-blue"></span> Dup {% if scan.duplicate_status == 'dup_same_session' %}<span class="status-dot status-dot-blue"></span> Dup
{% elif scan.duplicate_status == 'dup_other_session' %}<span class="status-dot status-dot-orange"></span> Warn {% elif scan.duplicate_status == 'dup_other_session' %}<span class="status-dot status-dot-orange"></span> Warn
{% else %}<span class="status-dot status-dot-green"></span> OK{% endif %} {% else %}<span class="status-dot status-dot-green"></span> OK{% endif %}
@@ -136,7 +151,7 @@
<div class="finish-section"> <div class="finish-section">
<div class="action-buttons-row"> <div class="action-buttons-row">
<a href="{{ url_for('cons_sheets.index') }}" class="btn btn-secondary btn-block btn-lg">← Back to Sessions</a> <a href="{{ url_for('conssheets.index') }}" class="btn btn-secondary btn-block btn-lg">← Back to Sessions</a>
<button class="btn btn-success btn-block btn-lg" onclick="exportToExcel()">📊 Export to Excel</button> <button class="btn btn-success btn-block btn-lg" onclick="exportToExcel()">📊 Export to Excel</button>
</div> </div>
</div> </div>
@@ -157,12 +172,12 @@
<div style="margin-bottom: 30px; padding: 15px; background: var(--color-bg); border-radius: 8px;"> <div style="margin-bottom: 30px; padding: 15px; background: var(--color-bg); border-radius: 8px;">
<p style="font-size: 0.9rem; margin-bottom: 10px;">Step 1: Get the correct format</p> <p style="font-size: 0.9rem; margin-bottom: 10px;">Step 1: Get the correct format</p>
<a href="{{ url_for('cons_sheets.download_import_template', session_id=session['id']) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.download_import_template', session_id=session['id']) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-download"></i> Download Template <i class="fa-solid fa-download"></i> Download Template
</a> </a>
</div> </div>
<form action="{{ url_for('cons_sheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data"> <form action="{{ url_for('conssheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;"> <input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;">
</div> </div>
@@ -175,21 +190,7 @@
</div> </div>
</div> </div>
<style> <!-- Styles moved to static/css/style.css -->
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.scan-row:hover { border-color: var(--color-primary); }
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
</style>
<script id="session-data" type="application/json"> <script id="session-data" type="application/json">
{ {
@@ -206,11 +207,202 @@ 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) {
playErrorBeep();
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 (data.success) {
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
feedbackArea.style.display = 'none';
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
} else {
playErrorBeep();
feedbackArea.style.display = 'block';
feedbackArea.style.background = 'rgba(220, 53, 69, 0.2)';
feedbackArea.style.border = '1px solid #dc3545';
feedbackText.style.color = '#dc3545';
feedbackText.textContent = data.message;
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
}
})
.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 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';
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
currentDupKeyValue = '';
smartInput.value = '';
smartInput.focus();
} 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';
}
// --- ERROR BEEP ---
function playErrorBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(520, ctx.currentTime);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
} catch(e) {
console.warn('Audio not available:', e);
}
}
// 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();
@@ -226,7 +418,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
function checkDuplicate() { function checkDuplicate() {
const fieldValues = {}; const fieldValues = {};
fieldValues[dupKeyFieldName] = currentDupKeyValue; fieldValues[dupKeyFieldName] = currentDupKeyValue;
fetch(`/cons-sheets/session/${sessionId}/scan`, { fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, check_only: true }) body: JSON.stringify({ field_values: fieldValues, check_only: true })
@@ -254,13 +446,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';
showFieldsModal();
// 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();
}
} }
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';
document.getElementById('dupKeyInput').focus();
// 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();
}
isDuplicateConfirmed = false; isDuplicateConfirmed = false;
isProcessing = false; isProcessing = false;
currentDuplicateStatus = ''; currentDuplicateStatus = '';
@@ -300,7 +511,7 @@ function submitScan() {
if (input) fieldValues[field.field_name] = input.value; if (input) fieldValues[field.field_name] = input.value;
} }
}); });
fetch(`/cons-sheets/session/${sessionId}/scan`, { fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed }) body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed })
@@ -313,8 +524,8 @@ function submitScan() {
data.updated_entry_ids.forEach(id => { data.updated_entry_ids.forEach(id => {
const row = document.querySelector(`[data-detail-id="${id}"]`); const row = document.querySelector(`[data-detail-id="${id}"]`);
if (row) { if (row) {
row.className = 'scan-row scan-row-dup_same_session'; row.className = 'cs-scan-row cs-scan-row-dup_same_session';
row.querySelector('.scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup'; row.querySelector('.cs-scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup';
} }
}); });
} }
@@ -339,16 +550,16 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
if (duplicateStatus === 'dup_same_session') { statusDot = 'blue'; statusText = 'Dup'; } if (duplicateStatus === 'dup_same_session') { statusDot = 'blue'; statusText = 'Dup'; }
else if (duplicateStatus === 'dup_other_session') { statusDot = 'orange'; statusText = 'Warn'; } else if (duplicateStatus === 'dup_other_session') { statusDot = 'orange'; statusText = 'Warn'; }
const scanRow = document.createElement('div'); const scanRow = document.createElement('div');
scanRow.className = 'scan-row scan-row-' + statusClass; scanRow.className = 'cs-scan-row cs-scan-row-' + statusClass;
scanRow.setAttribute('data-detail-id', detailId); scanRow.setAttribute('data-detail-id', detailId);
scanRow.onclick = function() { openScanDetail(detailId); }; scanRow.onclick = function() { openScanDetail(detailId); };
let cellsHtml = ''; let cellsHtml = '';
detailFields.forEach(field => { detailFields.forEach(field => {
let value = fieldValues[field.field_name] || '-'; let value = fieldValues[field.field_name] || '-';
if (field.field_type === 'REAL' && value !== '-') value = parseFloat(value).toFixed(1); if (field.field_type === 'REAL' && value !== '-') value = parseFloat(value).toFixed(1);
cellsHtml += `<div class="scan-row-cell">${value}</div>`; cellsHtml += `<div class="cs-scan-row-cell">${value}</div>`;
}); });
cellsHtml += `<div class="scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`; cellsHtml += `<div class="cs-scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`;
scanRow.innerHTML = cellsHtml; scanRow.innerHTML = cellsHtml;
scansList.insertBefore(scanRow, scansList.firstChild); scansList.insertBefore(scanRow, scansList.firstChild);
const countSpan = document.getElementById('scanListCount'); const countSpan = document.getElementById('scanListCount');
@@ -359,7 +570,7 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
} }
function openScanDetail(detailId) { function openScanDetail(detailId) {
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}`) fetch(`/conssheets/session/${sessionId}/detail/${detailId}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) displayScanDetail(data.detail); if (data.success) displayScanDetail(data.detail);
@@ -413,7 +624,7 @@ function saveDetail(detailId) {
if (input) fieldValues[field.field_name] = input.value; if (input) fieldValues[field.field_name] = input.value;
}); });
const comment = document.getElementById('editComment').value; const comment = document.getElementById('editComment').value;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/update`, { fetch(`/conssheets/session/${sessionId}/detail/${detailId}/update`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, comment: comment }) body: JSON.stringify({ field_values: fieldValues, comment: comment })
@@ -427,7 +638,7 @@ function saveDetail(detailId) {
function deleteDetail(detailId) { function deleteDetail(detailId) {
if (!confirm('Delete this scan?')) return; if (!confirm('Delete this scan?')) return;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/delete`, { fetch(`/conssheets/session/${sessionId}/detail/${detailId}/delete`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })
@@ -439,7 +650,7 @@ function deleteDetail(detailId) {
} }
function exportToExcel() { function exportToExcel() {
window.location.href = `/cons-sheets/session/${sessionId}/export?format=xlsx`; window.location.href = `/conssheets/session/${sessionId}/export?format=xlsx`;
} }
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {

View File

@@ -17,7 +17,7 @@
<h2 class="section-title">Start New Session</h2> <h2 class="section-title">Start New Session</h2>
<div class="process-buttons"> <div class="process-buttons">
{% for p in processes %} {% for p in processes %}
<a href="{{ url_for('cons_sheets.new_session', process_id=p.id) }}" class="btn btn-primary"> <a href="{{ url_for('conssheets.new_session', process_id=p.id) }}" class="btn btn-primary">
<span class="btn-icon">+</span> {{ p.process_name }} <span class="btn-icon">+</span> {{ p.process_name }}
</a> </a>
{% endfor %} {% endfor %}
@@ -30,26 +30,70 @@
<!-- Active Sessions --> <!-- Active Sessions -->
{% if sessions %} {% if sessions %}
<div class="section-card"> <div class="section-card">
<h2 class="section-title">📋 My Active Sessions</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h2 class="section-title">📋 My Sessions</h2>
<label class="checkbox-toggle">
<input type="checkbox" id="showArchivedToggle" {% if show_archived %}checked{% endif %} onchange="toggleArchived(this.checked)">
<span>Show archived/deleted</span>
</label>
</div>
<div class="sessions-list"> <div class="sessions-list">
{% for s in sessions %} {% for s in sessions %}
<div class="session-list-item-container"> <div class="session-list-item-container session-status-{{ s.status }}">
<a href="{{ url_for('cons_sheets.scan_session', session_id=s.id) }}" class="session-list-item"> <a href="{{ url_for('conssheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info"> <div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3> <h3 class="session-list-name">{{ s.session_title }}</h3>
<div class="session-list-meta"> <div class="session-list-meta">
<span>Started: {{ s.created_at[:16] }}</span> <span>Started: {{ s.created_at[:16] }}</span>
<span></span> <span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span> <span>{{ s.scan_count or 0 }} Scanned</span>
</div> </div>
{% if s.header_preview %}
<div class="session-list-meta" style="margin-top: var(--space-xs); font-size: 0.8rem;">
{% for hf in s.header_preview %}
<span><strong>{{ hf.field_label }}:</strong> {{ hf.field_value or '—' }}</span>
{% if not loop.last %}<span></span>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if s.status == 'archived' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-warning);">📦 Archived</span>
</div>
{% elif s.status == 'deleted' %}
<div class="session-list-meta" style="margin-top: var(--space-xs);">
<span style="color: var(--color-danger);">🗑️ Deleted</span>
</div>
{% endif %}
</div> </div>
<div class="session-list-action"> <div class="session-list-action">
<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">
🗑️ {% if s.status == 'active' %}
</button> <button class="btn-session-action btn-archive" onclick="archiveSession({{ s.id }}, '{{ s.process_name }}')" title="Archive this session">
📦
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'archived' %}
<button class="btn-session-action btn-unarchive" onclick="unarchiveSession({{ s.id }}, '{{ s.process_name }}')" title="Restore to active">
↩️
</button>
{% if session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-delete" onclick="deleteSession({{ s.id }}, '{{ s.process_name }}')" title="Delete this session">
</button>
{% endif %}
{% elif s.status == 'deleted' and session.role in ['owner', 'admin'] %}
<button class="btn-session-action btn-restore" onclick="restoreSession({{ s.id }}, '{{ s.process_name }}')" title="Restore from deleted">
🔄
</button>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -63,93 +107,24 @@
{% endif %} {% endif %}
</div> </div>
<style> <!-- Styles moved to static/css/style.css for maintainability -->
.new-session-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.process-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-md);
}
.section-card {
margin-top: var(--space-xl);
}
.session-list-item-container {
display: flex;
align-items: stretch;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-xs) 0;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.arrow-icon {
font-size: 1.5rem;
color: var(--color-primary);
}
.btn-archive {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
cursor: pointer;
font-size: 1.25rem;
transition: var(--transition);
}
.btn-archive:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
</style>
<script> <script>
function toggleArchived(checked) {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set('show_archived', '1');
} else {
url.searchParams.delete('show_archived');
}
window.location.href = url.toString();
}
function archiveSession(sessionId, processName) { function archiveSession(sessionId, processName) {
if (!confirm(`Archive this ${processName} session?\n\nYou can still view it later from the admin panel.`)) { if (!confirm(`Archive this ${processName} session?\n\nYou can restore it later.`)) {
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'}
}) })
@@ -162,5 +137,62 @@ function archiveSession(sessionId, processName) {
} }
}); });
} }
function unarchiveSession(sessionId, processName) {
if (!confirm(`Restore ${processName} session to active?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/unarchive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
function deleteSession(sessionId, processName) {
if (!confirm(`⚠️ DELETE ${processName} session?\n\nThis will mark all scanned data as deleted.\nThis action cannot be undone easily.\n\nAre you absolutely sure?`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error deleting session');
}
});
}
function restoreSession(sessionId, processName) {
if (!confirm(`Restore ${processName} from deleted?\n\nThis will reactivate the session and all its data.`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/restore`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error restoring session');
}
});
}
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

@@ -1,4 +1,5 @@
Flask==3.1.2 Flask==3.1.2
Werkzeug==3.1.5 Werkzeug==3.1.5
openpyxl openpyxl
Pillow Pillow
gunicorn==21.2.0

View File

@@ -707,6 +707,138 @@ body {
color: var(--color-primary); color: var(--color-primary);
} }
/* Session List - Archive/Delete Actions */
.new-session-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.process-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-md);
}
.section-card {
margin-top: var(--space-xl);
}
.session-list-item-container {
display: flex;
align-items: stretch;
gap: 0; /* Remove gap */
margin-bottom: var(--space-sm);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
overflow: hidden;
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: transparent; /* Remove duplicate background */
border: none; /* Remove border since container has it */
border-radius: 0;
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-info {
flex: 1;
}
.session-list-action {
display: flex;
align-items: center;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.checkbox-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
cursor: pointer;
}
.checkbox-toggle input[type="checkbox"] {
cursor: pointer;
}
.btn-session-action {
background: transparent;
border: none;
border-left: 2px solid var(--color-border);
border-radius: 0;
padding: var(--space-xl);
cursor: pointer;
font-size: 2rem;
transition: var(--transition);
min-width: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-session-action.btn-archive:hover {
border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.1);
}
.btn-session-action.btn-delete:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
.btn-session-action.btn-unarchive:hover {
border-color: var(--color-primary);
background: rgba(0, 212, 255, 0.1);
}
.btn-session-action.btn-restore:hover {
border-color: var(--color-success);
background: rgba(0, 255, 136, 0.1);
}
.session-status-archived {
opacity: 1;
}
.session-status-archived .session-list-item-container {
border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.08);
}
.session-status-deleted {
opacity: 1;
}
.session-status-deleted .session-list-item-container {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.08);
}
/* ==================== FORM CONTAINER ==================== */ /* ==================== FORM CONTAINER ==================== */
.form-container { .form-container {
@@ -1113,6 +1245,23 @@ body {
font-size: 1.5rem; font-size: 1.5rem;
} }
/* ==================== SCAN SESSION ==================== */
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; align-items: center; flex-direction: row; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.btn-edit-header { background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); color: var(--color-text-muted); cursor: pointer; font-size: 0.75rem; text-decoration: none; transition: var(--transition); display: inline-flex; align-items: center; }
.btn-edit-header:hover { border-color: var(--color-primary); color: var(--color-primary); }
.cs-scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.cs-scan-row:hover { border-color: var(--color-primary); }
.cs-scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.cs-scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.cs-scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
/* ==================== LOCATION COUNTING ==================== */ /* ==================== LOCATION COUNTING ==================== */
.count-location-container { .count-location-container {
@@ -1130,7 +1279,7 @@ body {
} }
.location-label { .location-label {
font-size: 0.875rem; font-size: 1.5rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -1303,9 +1452,25 @@ body {
overflow-y: auto; overflow-y: auto;
} }
.scan-header-row {
display: grid;
grid-template-columns: repeat(var(--field-count), 1fr) auto;
gap: var(--space-md);
padding: var(--space-xs) var(--space-md);
max-height: none;
overflow-y: visible;
}
.scan-col-header {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.scan-row { .scan-row {
display: grid; display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr; grid-template-columns: repeat(var(--field-count), 1fr) 1fr;
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-md); padding: var(--space-md);
background: var(--color-bg); background: var(--color-bg);

View File

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

View File

@@ -29,8 +29,14 @@
<div class="settings-dropdown"> <div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button> <button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<div id="settingsMenu" class="settings-menu"> <div id="settingsMenu" class="settings-menu">
<a href="{{ url_for('admin_dashboard') }}" class="settings-item">
<span class="settings-icon"><i class="fas fa-gauge"></i></span> Admin Dashboard
</a>
<a href="{{ url_for('users.manage_users') }}" class="settings-item"> <a href="{{ url_for('users.manage_users') }}" class="settings-item">
<span class="settings-icon">👥</span> Manage Users <span class="settings-icon"><i class="fas fa-users"></i></span> Manage Users
</a>
<a href="{{ url_for('module_manager_ui') }}" class="settings-item">
<span class="settings-icon"><i class="fas fa-puzzle-piece"></i></span> Module Manager
</a> </a>
</div> </div>
</div> </div>

View File

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

View File

@@ -5,19 +5,12 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<!-- Admin Button (only for admins/owners) -->
{% if session.role in ['owner', 'admin'] %}
<div class="mode-selector">
<a href="{{ url_for('admin_dashboard') }}" class="mode-btn">
👔 Admin Console
</a>
</div>
{% endif %}
<div class="dashboard-header"> <div class="dashboard-header">
<h1 class="page-title">Welcome, {{ session.full_name }}</h1> <h1 class="page-title">Welcome, {{ session.full_name }}</h1>
<p class="page-subtitle">Select a module to get started</p>
</div> </div>
<div><p class="page-subtitle">Select a module to get started</p></div>
{% if modules %} {% if modules %}
<div class="module-grid"> <div class="module-grid">

View File

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

View File

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