13 Commits

Author SHA1 Message Date
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 3193 additions and 605 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.2'
# 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),
] ]

View File

@@ -1,12 +1,16 @@
""" """
Consumption Sheets Module - Routes Consumption Sheets Module - Routes
Converted from cons_sheets.py Converted from conssheets.py
""" """
from flask import render_template, request, redirect, url_for, flash, jsonify, session, send_file from flask import render_template, request, redirect, url_for, flash, jsonify, session, send_file
from db import query_db, execute_db from db import query_db, execute_db
from utils import login_required, role_required from utils import login_required, role_required
from datetime import datetime from datetime import datetime
from global_actions import execute_pipeline
import sqlite3
import io import io
import os
def register_routes(bp): def register_routes(bp):
@@ -16,7 +20,7 @@ def register_routes(bp):
# CONSUMPTION SHEETS ROUTES # CONSUMPTION SHEETS ROUTES
# ========================================================================= # =========================================================================
@bp.route('/admin/consumption-sheets') @bp.route('/admin')
@role_required('owner', 'admin') @role_required('owner', 'admin')
def admin_processes(): def admin_processes():
"""List all consumption sheet process types (Active or Archived)""" """List all consumption sheet process types (Active or Archived)"""
@@ -33,7 +37,7 @@ def register_routes(bp):
ORDER BY cp.process_name ASC ORDER BY cp.process_name ASC
''', [is_active_val]) ''', [is_active_val])
return render_template('cons_sheets/admin_processes.html', return render_template('conssheets/admin_processes.html',
processes=processes, processes=processes,
showing_archived=show_archived) showing_archived=show_archived)
@@ -47,7 +51,7 @@ def register_routes(bp):
if not process_name: if not process_name:
flash('Process name is required', 'danger') flash('Process name is required', 'danger')
return redirect(url_for('cons_sheets.create_process')) return redirect(url_for('conssheets.create_process'))
# Generate process_key from name (lowercase, underscores) # Generate process_key from name (lowercase, underscores)
process_key = process_name.lower().replace(' ', '_').replace('-', '_') process_key = process_name.lower().replace(' ', '_').replace('-', '_')
@@ -58,7 +62,7 @@ def register_routes(bp):
existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True) existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True)
if existing: if existing:
flash(f'A process with key "{process_key}" already exists', 'danger') flash(f'A process with key "{process_key}" already exists', 'danger')
return redirect(url_for('cons_sheets.create_process')) return redirect(url_for('conssheets.create_process'))
process_id = execute_db(''' process_id = execute_db('''
INSERT INTO cons_processes (process_key, process_name, created_by) INSERT INTO cons_processes (process_key, process_name, created_by)
@@ -69,15 +73,15 @@ def register_routes(bp):
create_process_detail_table(process_key) create_process_detail_table(process_key)
flash(f'Process "{process_name}" created successfully!', 'success') flash(f'Process "{process_name}" created successfully!', 'success')
return redirect(url_for('cons_sheets.process_detail', process_id=process_id)) return redirect(url_for('conssheets.process_detail', process_id=process_id))
return render_template('cons_sheets/create_process.html') return render_template('conssheets/create_process.html')
def get_db_path(): def get_db_path():
"""Get the database path""" """Get the database path"""
db_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'database', 'scanlook.db') db_path = 'database/scanlook.db'
print(f"DEBUG: Database path is: {db_path}") print(f"DEBUG: Database path is: {db_path}")
print(f"DEBUG: Path exists: {os.path.exists(db_path)}") print(f"DEBUG: Path exists: {os.path.exists(db_path)}")
return db_path return db_path
@@ -167,7 +171,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Soft delete: Set is_active = 0 # Soft delete: Set is_active = 0
# The existing admin_processes route already filters for is_active=1, # The existing admin_processes route already filters for is_active=1,
@@ -175,7 +179,7 @@ def register_routes(bp):
execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id]) execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id])
flash(f'Process "{process["process_name"]}" has been deleted.', 'success') flash(f'Process "{process["process_name"]}" has been deleted.', 'success')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
@bp.route('/admin/consumption-sheets/<int:process_id>/restore', methods=['POST']) @bp.route('/admin/consumption-sheets/<int:process_id>/restore', methods=['POST'])
@@ -184,7 +188,7 @@ def register_routes(bp):
"""Restore a soft-deleted process type""" """Restore a soft-deleted process type"""
execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id]) execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id])
flash('Process has been restored.', 'success') flash('Process has been restored.', 'success')
return redirect(url_for('cons_sheets.admin_processes', archived=1)) return redirect(url_for('conssheets.admin_processes', archived=1))
@@ -196,7 +200,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get header fields # Get header fields
header_fields = query_db(''' header_fields = query_db('''
@@ -212,12 +216,108 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_detail.html', return render_template('conssheets/process_detail.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@bp.route('/admin/consumption-sheets/<int:process_id>/router')
@role_required('owner', 'admin')
def process_router(process_id):
"""Configure IFTTT routing rules for a process"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process:
flash('Process not found', 'danger')
return redirect(url_for('conssheets.admin_processes'))
# Get existing rules sorted by line number (10, 20...)
rules = query_db('''
SELECT * FROM cons_process_router
WHERE process_id = ?
ORDER BY line_number ASC
''', [process_id])
return render_template('conssheets/process_router.html',
process=process,
rules=rules)
@bp.route('/admin/consumption-sheets/<int:process_id>/router/add', methods=['POST'])
@role_required('owner', 'admin')
def add_router_rule(process_id):
"""Add a new routing rule"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process:
flash('Process not found', 'danger')
return redirect(url_for('conssheets.admin_processes'))
line_number = request.form.get('line_number')
rule_name = request.form.get('rule_name')
match_pattern = request.form.get('match_pattern')
# Basic validation
if not line_number or not rule_name or not match_pattern:
flash('All fields are required', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
try:
execute_db('''
INSERT INTO cons_process_router
(process_id, line_number, rule_name, match_pattern, actions_json, is_active)
VALUES (?, ?, ?, ?, '[]', 1)
''', [process_id, line_number, rule_name, match_pattern])
flash(f'Rule {line_number} created successfully!', 'success')
except Exception as e:
flash(f'Error creating rule: {str(e)}', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/router/<int:rule_id>/edit', methods=['GET', 'POST'])
@role_required('owner', 'admin')
def edit_router_rule(process_id, rule_id):
"""Edit a specific routing rule and its logic actions"""
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
rule = query_db('SELECT * FROM cons_process_router WHERE id = ?', [rule_id], one=True)
if not process or not rule:
flash('Rule not found', 'danger')
return redirect(url_for('conssheets.process_router', process_id=process_id))
# NEW: Fetch all active fields so we can use them in the Logic Editor dropdowns
fields = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND is_active = 1
ORDER BY table_type, sort_order
''', [process_id])
if request.method == 'POST':
# 1. Update Basic Info
line_number = request.form.get('line_number')
rule_name = request.form.get('rule_name')
match_pattern = request.form.get('match_pattern')
# 2. Update the Logic Chain (JSON)
# We get the raw JSON string from a hidden input we'll build next
actions_json = request.form.get('actions_json', '[]')
try:
execute_db('''
UPDATE cons_process_router
SET line_number = ?, rule_name = ?, match_pattern = ?, actions_json = ?
WHERE id = ?
''', [line_number, rule_name, match_pattern, actions_json, rule_id])
flash('Rule configuration saved!', 'success')
except Exception as e:
flash(f'Error saving rule: {str(e)}', 'danger')
return redirect(url_for('conssheets.edit_router_rule', process_id=process_id, rule_id=rule_id))
return render_template('conssheets/edit_rule.html', process=process, rule=rule, fields=fields)
@bp.route('/admin/consumption-sheets/<int:process_id>/fields') @bp.route('/admin/consumption-sheets/<int:process_id>/fields')
@role_required('owner', 'admin') @role_required('owner', 'admin')
def process_fields(process_id): def process_fields(process_id):
@@ -226,7 +326,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get header fields # Get header fields
header_fields = query_db(''' header_fields = query_db('''
@@ -242,7 +342,7 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_fields.html', return render_template('conssheets/process_fields.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@@ -256,7 +356,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
# Get all active fields for mapping display # Get all active fields for mapping display
header_fields = query_db(''' header_fields = query_db('''
@@ -271,7 +371,7 @@ def register_routes(bp):
ORDER BY sort_order, id ORDER BY sort_order, id
''', [process_id]) ''', [process_id])
return render_template('cons_sheets/process_template.html', return render_template('conssheets/process_template.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
detail_fields=detail_fields) detail_fields=detail_fields)
@@ -285,21 +385,21 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if 'template_file' not in request.files: if 'template_file' not in request.files:
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
file = request.files['template_file'] file = request.files['template_file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
if not file.filename.endswith('.xlsx'): if not file.filename.endswith('.xlsx'):
flash('Only .xlsx files are allowed', 'danger') flash('Only .xlsx files are allowed', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
# Read file as binary # Read file as binary
template_data = file.read() template_data = file.read()
@@ -313,7 +413,7 @@ def register_routes(bp):
''', [template_data, filename, process_id]) ''', [template_data, filename, process_id])
flash(f'Template "{filename}" uploaded successfully!', 'success') flash(f'Template "{filename}" uploaded successfully!', 'success')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/template/settings', methods=['POST']) @bp.route('/admin/consumption-sheets/<int:process_id>/template/settings', methods=['POST'])
@@ -324,7 +424,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
rows_per_page = request.form.get('rows_per_page', 30) rows_per_page = request.form.get('rows_per_page', 30)
detail_start_row = request.form.get('detail_start_row', 10) detail_start_row = request.form.get('detail_start_row', 10)
@@ -340,11 +440,11 @@ def register_routes(bp):
if not page_height: if not page_height:
flash('Page Height is required for the new strategy', 'danger') flash('Page Height is required for the new strategy', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
except ValueError: except ValueError:
flash('Invalid number values', 'danger') flash('Invalid number values', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
# Update query - We ignore detail_end_row (leave it as is or null) # Update query - We ignore detail_end_row (leave it as is or null)
execute_db(''' execute_db('''
@@ -355,7 +455,7 @@ def register_routes(bp):
''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id]) ''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id])
flash('Settings updated successfully!', 'success') flash('Settings updated successfully!', 'success')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
@bp.route('/admin/consumption-sheets/<int:process_id>/template/download') @bp.route('/admin/consumption-sheets/<int:process_id>/template/download')
@role_required('owner', 'admin') @role_required('owner', 'admin')
@@ -367,7 +467,7 @@ def register_routes(bp):
if not process or not process['template_file']: if not process or not process['template_file']:
flash('No template found', 'danger') flash('No template found', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id)) return redirect(url_for('conssheets.process_template', process_id=process_id))
return Response( return Response(
process['template_file'], process['template_file'],
@@ -382,13 +482,13 @@ def register_routes(bp):
"""Add a new field to a process""" """Add a new field to a process"""
if table_type not in ['header', 'detail']: if table_type not in ['header', 'detail']:
flash('Invalid table type', 'danger') flash('Invalid table type', 'danger')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True)
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if request.method == 'POST': if request.method == 'POST':
field_label = request.form.get('field_label', '').strip() field_label = request.form.get('field_label', '').strip()
@@ -399,7 +499,7 @@ def register_routes(bp):
if not field_label: if not field_label:
flash('Field label is required', 'danger') flash('Field label is required', 'danger')
return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) return redirect(url_for('conssheets.add_field', process_id=process_id, table_type=table_type))
# Generate field_name from label (lowercase, underscores) # Generate field_name from label (lowercase, underscores)
field_name = field_label.lower().replace(' ', '_').replace('-', '_') field_name = field_label.lower().replace(' ', '_').replace('-', '_')
@@ -413,7 +513,7 @@ def register_routes(bp):
if existing: if existing:
flash(f'A field with name "{field_name}" already exists', 'danger') flash(f'A field with name "{field_name}" already exists', 'danger')
return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) return redirect(url_for('conssheets.add_field', process_id=process_id, table_type=table_type))
# Get next sort_order # Get next sort_order
max_sort = query_db(''' max_sort = query_db('''
@@ -438,9 +538,9 @@ def register_routes(bp):
add_column_to_detail_table(process['process_key'], field_name, field_type) add_column_to_detail_table(process['process_key'], field_name, field_type)
flash(f'Field "{field_label}" added successfully!', 'success') flash(f'Field "{field_label}" added successfully!', 'success')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
return render_template('cons_sheets/add_field.html', return render_template('conssheets/add_field.html',
process=process, process=process,
table_type=table_type) table_type=table_type)
@@ -454,7 +554,7 @@ def register_routes(bp):
if not process or not field: if not process or not field:
flash('Process or field not found', 'danger') flash('Process or field not found', 'danger')
return redirect(url_for('cons_sheets.admin_processes')) return redirect(url_for('conssheets.admin_processes'))
if request.method == 'POST': if request.method == 'POST':
field_label = request.form.get('field_label', '').strip() field_label = request.form.get('field_label', '').strip()
@@ -466,7 +566,7 @@ def register_routes(bp):
if not field_label: if not field_label:
flash('Field label is required', 'danger') flash('Field label is required', 'danger')
return redirect(url_for('cons_sheets.edit_field', process_id=process_id, field_id=field_id)) return redirect(url_for('conssheets.edit_field', process_id=process_id, field_id=field_id))
execute_db(''' execute_db('''
UPDATE cons_process_fields UPDATE cons_process_fields
@@ -475,9 +575,9 @@ def register_routes(bp):
''', [field_label, field_type, int(max_length) if max_length else None, is_required, is_duplicate_key, excel_cell or None, field_id]) ''', [field_label, field_type, int(max_length) if max_length else None, is_required, is_duplicate_key, excel_cell or None, field_id])
flash(f'Field "{field_label}" updated successfully!', 'success') flash(f'Field "{field_label}" updated successfully!', 'success')
return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) return redirect(url_for('conssheets.process_fields', process_id=process_id))
return render_template('cons_sheets/edit_field.html', return render_template('conssheets/edit_field.html',
process=process, process=process,
field=field) field=field)
@@ -520,7 +620,7 @@ def register_routes(bp):
''', [process_id], one=True) ''', [process_id], one=True)
@bp.route('/cons-sheets') @bp.route('/')
@login_required @login_required
def index(): def index():
"""Consumption Sheets module landing - show user's sessions""" """Consumption Sheets module landing - show user's sessions"""
@@ -530,36 +630,80 @@ def register_routes(bp):
has_access = query_db(''' has_access = query_db('''
SELECT 1 FROM UserModules um SELECT 1 FROM UserModules um
JOIN Modules m ON um.module_id = m.module_id JOIN Modules m ON um.module_id = m.module_id
WHERE um.user_id = ? AND m.module_key = 'cons_sheets' AND m.is_active = 1 WHERE um.user_id = ? AND m.module_key = 'conssheets' AND m.is_active = 1
''', [user_id], one=True) ''', [user_id], one=True)
if not has_access: if not has_access:
flash('You do not have access to this module', 'danger') flash('You do not have access to this module', 'danger')
return redirect(url_for('home')) return redirect(url_for('home'))
# Get user's active sessions with process info and scan counts # Check if user wants to see archived/deleted sessions
active_sessions = query_db(''' show_archived = request.args.get('show_archived') == '1'
SELECT cs.*, cp.process_name, cp.process_key
FROM cons_sessions cs # Get sessions based on filter
JOIN cons_processes cp ON cs.process_id = cp.id if show_archived:
WHERE cs.created_by = ? AND cs.status = 'active' # Show active + archived + last 20 deleted
ORDER BY cs.created_at DESC all_sessions = query_db('''
''', [user_id]) SELECT cs.*, cp.process_name, cp.process_key
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.created_by = ?
ORDER BY
CASE cs.status
WHEN 'active' THEN 1
WHEN 'archived' THEN 2
WHEN 'deleted' THEN 3
END,
cs.created_at DESC
''', [user_id])
# Separate active, archived, and deleted (limit deleted to 20)
active_sessions = [s for s in all_sessions if s['status'] == 'active']
archived_sessions = [s for s in all_sessions if s['status'] == 'archived']
deleted_sessions = [s for s in all_sessions if s['status'] == 'deleted'][:20]
combined_sessions = active_sessions + archived_sessions + deleted_sessions
else:
# Show only active sessions
combined_sessions = query_db('''
SELECT cs.*, cp.process_name, cp.process_key
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.created_by = ? AND cs.status = 'active'
ORDER BY cs.created_at DESC
''', [user_id])
# Get scan counts for each session from their dynamic tables # Get scan counts and header values for each session
sessions_with_counts = [] sessions_with_counts = []
for sess in active_sessions: for sess in combined_sessions:
sess_dict = dict(sess)
# Get scan count
table_name = get_detail_table_name(sess['process_key']) table_name = get_detail_table_name(sess['process_key'])
try: try:
count_result = query_db(f''' count_result = query_db(f'''
SELECT COUNT(*) as scan_count FROM {table_name} SELECT COUNT(*) as scan_count FROM {table_name}
WHERE session_id = ? AND is_deleted = 0 WHERE session_id = ? AND is_deleted = 0
''', [sess['id']], one=True) ''', [sess['id']], one=True)
sess_dict = dict(sess)
sess_dict['scan_count'] = count_result['scan_count'] if count_result else 0 sess_dict['scan_count'] = count_result['scan_count'] if count_result else 0
except: except:
sess_dict = dict(sess)
sess_dict['scan_count'] = 0 sess_dict['scan_count'] = 0
# Get first 5 required header fields with their values
header_fields = query_db('''
SELECT cpf.field_label, cshv.field_value
FROM cons_process_fields cpf
LEFT JOIN cons_session_header_values cshv
ON cpf.id = cshv.field_id AND cshv.session_id = ?
WHERE cpf.process_id = ?
AND cpf.table_type = 'header'
AND cpf.is_required = 1
AND cpf.is_active = 1
ORDER BY cpf.sort_order, cpf.id
LIMIT 5
''', [sess['id'], sess['process_id']])
sess_dict['header_preview'] = header_fields
sessions_with_counts.append(sess_dict) sessions_with_counts.append(sess_dict)
# Get available process types for creating new sessions # Get available process types for creating new sessions
@@ -567,12 +711,13 @@ def register_routes(bp):
SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name
''') ''')
return render_template('cons_sheets/staff_index.html', return render_template('conssheets/staff_index.html',
sessions=sessions_with_counts, sessions=sessions_with_counts,
processes=processes) processes=processes,
show_archived=show_archived)
@bp.route('/cons-sheets/new/<int:process_id>', methods=['GET', 'POST']) @bp.route('/new/<int:process_id>', methods=['GET', 'POST'])
@login_required @login_required
def new_session(process_id): def new_session(process_id):
"""Create a new scanning session - enter header info""" """Create a new scanning session - enter header info"""
@@ -580,7 +725,7 @@ def register_routes(bp):
if not process: if not process:
flash('Process not found', 'danger') flash('Process not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Get header fields for this process # Get header fields for this process
header_fields = query_db(''' header_fields = query_db('''
@@ -600,7 +745,7 @@ def register_routes(bp):
if missing_required: if missing_required:
flash(f'Required fields missing: {", ".join(missing_required)}', 'danger') flash(f'Required fields missing: {", ".join(missing_required)}', 'danger')
return render_template('cons_sheets/new_session.html', return render_template('conssheets/new_session.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
form_data=request.form) form_data=request.form)
@@ -621,15 +766,15 @@ def register_routes(bp):
''', [session_id, field['id'], value]) ''', [session_id, field['id'], value])
flash('Session created! Start scanning lots.', 'success') flash('Session created! Start scanning lots.', 'success')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
return render_template('cons_sheets/new_session.html', return render_template('conssheets/new_session.html',
process=process, process=process,
header_fields=header_fields, header_fields=header_fields,
form_data={}) form_data={})
@bp.route('/cons-sheets/session/<int:session_id>') @bp.route('/session/<int:session_id>')
@login_required @login_required
def scan_session(session_id): def scan_session(session_id):
"""Main scanning interface for a session""" """Main scanning interface for a session"""
@@ -643,11 +788,11 @@ def register_routes(bp):
if not sess: if not sess:
flash('Session not found', 'danger') flash('Session not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
if sess['status'] == 'archived': if sess['status'] == 'archived':
flash('This session has been archived', 'warning') flash('This session has been archived', 'warning')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Get header values for display # Get header values for display
header_values = query_db(''' header_values = query_db('''
@@ -680,7 +825,7 @@ def register_routes(bp):
dup_key_field_row = get_duplicate_key_field(sess['process_id']) dup_key_field_row = get_duplicate_key_field(sess['process_id'])
dup_key_field = dict(dup_key_field_row) if dup_key_field_row else None dup_key_field = dict(dup_key_field_row) if dup_key_field_row else None
return render_template('cons_sheets/scan_session.html', return render_template('conssheets/scan_session.html',
session=sess, session=sess,
header_values=header_values, header_values=header_values,
scans=scans, scans=scans,
@@ -688,142 +833,68 @@ def register_routes(bp):
dup_key_field=dup_key_field) dup_key_field=dup_key_field)
@bp.route('/cons-sheets/session/<int:session_id>/scan', methods=['POST']) @bp.route('/session/<int:session_id>/scan', methods=['POST'])
@login_required @login_required
def scan_lot(session_id): def scan_lot(session_id):
"""Process a scan with duplicate detection using dynamic tables""" from global_actions import execute_pipeline
import re
import json
# 1. Setup Context & Get Session
# We need the process_key to know which table to save to
sess = query_db(''' sess = query_db('''
SELECT cs.*, cp.process_key, cp.id as process_id SELECT cs.*, cp.process_key, cp.id as process_id
FROM cons_sessions cs FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.id = ? AND cs.status = 'active' WHERE cs.id = ?
''', [session_id], one=True) ''', [session_id], one=True)
if not sess: if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'}) return jsonify({'success': False, 'message': 'Session invalid'})
# 2. Get Data from Frontend
data = request.get_json() data = request.get_json()
field_values = data.get('field_values', {}) # Dict of field_name: value barcode = data.get('barcode', '').strip()
confirm_duplicate = data.get('confirm_duplicate', False)
check_only = data.get('check_only', False)
# Get the duplicate key field # 3. Find Matching Rule (The Routing)
dup_key_field = get_duplicate_key_field(sess['process_id']) matched_rule = None
if barcode:
rules = query_db('SELECT * FROM cons_process_router WHERE process_id = ? AND is_active = 1 ORDER BY line_number ASC', [sess['process_id']])
for rule in rules:
try:
if re.search(rule['match_pattern'], barcode):
matched_rule = rule
break
except: continue
if not dup_key_field: if not matched_rule:
return jsonify({'success': False, 'message': 'No duplicate key field configured for this process'}) return jsonify({'success': False, 'message': f"❌ No rule matched: {barcode}"})
# 4. Execute Pipeline (The Processing)
context = {
'table_name': f"cons_proc_{sess['process_key']}_details",
'session_id': session_id,
'user_id': session.get('user_id'),
# CRITICAL FIXES:
# Pass the "Yes" flag so it doesn't ask about duplicates again
'confirm_duplicate': data.get('confirm_duplicate', False),
# Pass the "Weight" (or other inputs) so it doesn't open the form again
'extra_data': data.get('field_values') or data.get('extra_data')
}
dup_key_value = field_values.get(dup_key_field['field_name'], '').strip() try:
# The global engine handles Map, Clean, Duplicate, Input, and Save!
if not dup_key_value: actions = json.loads(matched_rule['actions_json'])
return jsonify({'success': False, 'message': f'{dup_key_field["field_label"]} is required'}) result = execute_pipeline(actions, barcode, context)
return jsonify(result)
table_name = get_detail_table_name(sess['process_key'])
except Exception as e:
# Check for duplicates in SAME session return jsonify({'success': False, 'message': f"System Error: {str(e)}"})
same_session_dup = query_db(f'''
SELECT * FROM {table_name}
WHERE session_id = ? AND {dup_key_field['field_name']} = ? AND is_deleted = 0
''', [session_id, dup_key_value], one=True)
# Check for duplicates in OTHER sessions (need to check all sessions of same process type)
other_session_dup = query_db(f'''
SELECT t.*, cs.id as other_session_id, cs.created_at as other_session_date,
u.full_name as other_user,
(SELECT field_value FROM cons_session_header_values
WHERE session_id = cs.id AND field_id = (
SELECT id FROM cons_process_fields
WHERE process_id = cs.process_id AND field_name LIKE '%wo%' AND is_active = 1 LIMIT 1
)) as other_wo
FROM {table_name} t
JOIN cons_sessions cs ON t.session_id = cs.id
JOIN Users u ON t.scanned_by = u.user_id
WHERE t.{dup_key_field['field_name']} = ? AND t.session_id != ? AND t.is_deleted = 0
ORDER BY t.scanned_at DESC
LIMIT 1
''', [dup_key_value, session_id], one=True)
duplicate_status = 'normal'
duplicate_info = None
needs_confirmation = False
if same_session_dup:
duplicate_status = 'dup_same_session'
duplicate_info = 'Already scanned in this session'
needs_confirmation = True
elif other_session_dup:
duplicate_status = 'dup_other_session'
dup_date = other_session_dup['other_session_date'][:10] if other_session_dup['other_session_date'] else 'Unknown'
dup_user = other_session_dup['other_user'] or 'Unknown'
dup_wo = other_session_dup['other_wo'] or 'N/A'
duplicate_info = f"Previously scanned on {dup_date} by {dup_user} on WO {dup_wo}"
needs_confirmation = True
# If just checking, return early
if check_only:
if needs_confirmation:
return jsonify({
'success': False,
'needs_confirmation': True,
'duplicate_status': duplicate_status,
'duplicate_info': duplicate_info,
'message': duplicate_info
})
return jsonify({'success': True, 'needs_confirmation': False})
# If needs confirmation and not confirmed, ask user
if needs_confirmation and not confirm_duplicate:
return jsonify({
'success': False,
'needs_confirmation': True,
'duplicate_status': duplicate_status,
'duplicate_info': duplicate_info,
'message': duplicate_info
})
# Get all active detail fields for this process
detail_fields = query_db('''
SELECT * FROM cons_process_fields
WHERE process_id = ? AND table_type = 'detail' AND is_active = 1
ORDER BY sort_order, id
''', [sess['process_id']])
# Build dynamic INSERT statement
field_names = ['session_id', 'scanned_by', 'duplicate_status', 'duplicate_info']
field_placeholders = ['?', '?', '?', '?']
values = [session_id, session['user_id'], duplicate_status, duplicate_info]
for field in detail_fields:
field_names.append(field['field_name'])
field_placeholders.append('?')
values.append(field_values.get(field['field_name'], ''))
insert_sql = f'''
INSERT INTO {table_name} ({', '.join(field_names)})
VALUES ({', '.join(field_placeholders)})
'''
detail_id = execute_db(insert_sql, values)
# If this is a same-session duplicate, update the original scan too
updated_entry_ids = []
if duplicate_status == 'dup_same_session' and same_session_dup:
execute_db(f'''
UPDATE {table_name}
SET duplicate_status = 'dup_same_session', duplicate_info = 'Duplicate'
WHERE id = ?
''', [same_session_dup['id']])
updated_entry_ids.append(same_session_dup['id'])
return jsonify({
'success': True,
'detail_id': detail_id,
'duplicate_status': duplicate_status,
'updated_entry_ids': updated_entry_ids
})
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>') @bp.route('/session/<int:session_id>/detail/<int:detail_id>')
@login_required @login_required
def get_detail(session_id, detail_id): def get_detail(session_id, detail_id):
"""Get detail info for editing""" """Get detail info for editing"""
@@ -852,7 +923,7 @@ def register_routes(bp):
return jsonify({'success': True, 'detail': dict(detail)}) return jsonify({'success': True, 'detail': dict(detail)})
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>/update', methods=['POST']) @bp.route('/session/<int:session_id>/detail/<int:detail_id>/update', methods=['POST'])
@login_required @login_required
def update_detail(session_id, detail_id): def update_detail(session_id, detail_id):
"""Update a scanned detail""" """Update a scanned detail"""
@@ -909,7 +980,7 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/detail/<int:detail_id>/delete', methods=['POST']) @bp.route('/session/<int:session_id>/detail/<int:detail_id>/delete', methods=['POST'])
@login_required @login_required
def delete_detail(session_id, detail_id): def delete_detail(session_id, detail_id):
"""Soft-delete a scanned detail""" """Soft-delete a scanned detail"""
@@ -939,7 +1010,7 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/archive', methods=['POST']) @bp.route('/session/<int:session_id>/archive', methods=['POST'])
@login_required @login_required
def archive_session(session_id): def archive_session(session_id):
"""Archive (soft-delete) a session""" """Archive (soft-delete) a session"""
@@ -956,8 +1027,62 @@ def register_routes(bp):
return jsonify({'success': True}) return jsonify({'success': True})
@bp.route('/session/<int:session_id>/unarchive', methods=['POST'])
@login_required
def unarchive_session(session_id):
"""Unarchive a session back to active"""
sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Check permission
if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/cons-sheets/session/<int:session_id>/template')
@bp.route('/session/<int:session_id>/delete', methods=['POST'])
@role_required('owner', 'admin')
def delete_session(session_id):
"""Delete a session (admin only) - soft delete session and all detail rows"""
sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Update session status
execute_db('UPDATE cons_sessions SET status = "deleted" WHERE id = ?', [session_id])
# Mark all detail rows as deleted
table_name = get_detail_table_name(sess['process_key'])
execute_db(f'UPDATE {table_name} SET is_deleted = 1 WHERE session_id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/restore', methods=['POST'])
@role_required('owner', 'admin')
def restore_session(session_id):
"""Restore a deleted session (admin only) - restore session and all detail rows"""
sess = query_db('SELECT cs.*, cp.process_key FROM cons_sessions cs JOIN cons_processes cp ON cs.process_id = cp.id WHERE cs.id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
# Update session status to active
execute_db('UPDATE cons_sessions SET status = "active" WHERE id = ?', [session_id])
# Restore all detail rows
table_name = get_detail_table_name(sess['process_key'])
execute_db(f'UPDATE {table_name} SET is_deleted = 0 WHERE session_id = ?', [session_id])
return jsonify({'success': True})
@bp.route('/session/<int:session_id>/template')
@login_required @login_required
def download_import_template(session_id): def download_import_template(session_id):
"""Generate a blank Excel template for bulk import""" """Generate a blank Excel template for bulk import"""
@@ -967,7 +1092,7 @@ def register_routes(bp):
# Get Process ID # Get Process ID
sess = query_db('SELECT process_id FROM cons_sessions WHERE id = ?', [session_id], one=True) sess = query_db('SELECT process_id FROM cons_sessions WHERE id = ?', [session_id], one=True)
if not sess: return redirect(url_for('cons_sheets.index')) if not sess: return redirect(url_for('conssheets.index'))
# Get Detail Fields # Get Detail Fields
fields = query_db(''' fields = query_db('''
@@ -996,7 +1121,7 @@ def register_routes(bp):
headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'} headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'}
) )
@bp.route('/cons-sheets/session/<int:session_id>/import', methods=['POST']) @bp.route('/session/<int:session_id>/import', methods=['POST'])
@login_required @login_required
def import_session_data(session_id): def import_session_data(session_id):
"""Bulk import detail rows from Excel""" """Bulk import detail rows from Excel"""
@@ -1015,17 +1140,17 @@ def register_routes(bp):
if not sess: if not sess:
flash('Session not found', 'danger') flash('Session not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# 2. Check File # 2. Check File
if 'file' not in request.files: if 'file' not in request.files:
flash('No file uploaded', 'danger') flash('No file uploaded', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
file = request.files['file'] file = request.files['file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
try: try:
# 3. Read Excel # 3. Read Excel
@@ -1051,7 +1176,7 @@ def register_routes(bp):
if not col_mapping: if not col_mapping:
flash('Error: No matching columns found in Excel. Please use the template.', 'danger') flash('Error: No matching columns found in Excel. Please use the template.', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
# 4. Process Rows # 4. Process Rows
table_name = f"cons_proc_{sess['process_key']}_details" table_name = f"cons_proc_{sess['process_key']}_details"
@@ -1095,9 +1220,9 @@ def register_routes(bp):
flash(f'Import Error: {str(e)}', 'danger') flash(f'Import Error: {str(e)}', 'danger')
print(f"DEBUG IMPORT ERROR: {str(e)}") # Print to console for good measure print(f"DEBUG IMPORT ERROR: {str(e)}") # Print to console for good measure
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
@bp.route('/cons-sheets/session/<int:session_id>/export') @bp.route('/session/<int:session_id>/export')
@login_required @login_required
def export_session(session_id): def export_session(session_id):
"""Export session: Hide Rows Strategy + Manual Column Widths""" """Export session: Hide Rows Strategy + Manual Column Widths"""
@@ -1123,7 +1248,7 @@ def register_routes(bp):
if not sess or not sess['template_file']: if not sess or not sess['template_file']:
flash('Session or Template not found', 'danger') flash('Session or Template not found', 'danger')
return redirect(url_for('cons_sheets.index')) return redirect(url_for('conssheets.index'))
# Validation # Validation
page_height = sess['page_height'] page_height = sess['page_height']
@@ -1132,7 +1257,7 @@ def register_routes(bp):
if not page_height: if not page_height:
flash('Configuration Error: Page Height is not set.', 'danger') flash('Configuration Error: Page Height is not set.', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) return redirect(url_for('conssheets.scan_session', session_id=session_id))
# Get Data # Get Data
header_fields = query_db(''' header_fields = query_db('''

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<div class="mode-selector"> <div class="mode-selector">
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('conssheets.admin_processes') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Processes <i class="fa-solid fa-arrow-left"></i> Back to Processes
</a> </a>
</div> </div>
@@ -38,7 +38,7 @@
</div> </div>
</div> </div>
<a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-primary btn-block"> <a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Fields Configure Fields
</a> </a>
</div> </div>
@@ -67,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,7 @@
<div class="count-location-container"> <div class="count-location-container">
<div class="location-header"> <div class="location-header">
<div class="location-info"> <div class="location-info">
<a href="{{ url_for('cons_sheets.index') }}" class="breadcrumb">← Back to Sessions</a> <a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<div class="location-label">{{ session.process_name }}</div> <div class="location-label">{{ session.process_name }}</div>
<div class="header-values"> <div class="header-values">
{% for hv in header_values %} {% for hv in header_values %}
@@ -26,20 +26,23 @@
</div> </div>
{% endif %} {% endif %}
<div class="scan-card scan-card-active"> <div class="scan-card" style="border: 2px solid var(--color-primary); margin-bottom: 20px;">
<div class="scan-header"> <div class="scan-header" style="background: rgba(0, 123, 255, 0.1);">
<h2 class="scan-title">Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}</h2> <h2 class="scan-title" style="color: var(--color-primary);">🚀 Smart Router Scan</h2>
</div> </div>
<form id="lotScanForm" class="scan-form"> <div class="scan-form">
<div class="scan-input-group"> <div class="scan-input-group">
<input type="text" name="dup_key_input" id="dupKeyInput" inputmode="none" <input type="text" id="smartScanner" class="scan-input"
class="scan-input" placeholder="Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}" placeholder="Scan Raw Barcode Here..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus autocomplete="off" autofocus>
{% if not dup_key_field %}disabled{% endif %}> <button type="button" id="btnSmartScan" class="btn btn-primary">Go</button>
<button type="submit" style="display: none;"></button>
</div> </div>
</form> <div id="routerFeedback" style="display:none; padding: 10px; margin-top: 10px; border-radius: 4px;">
<pre id="routerOutput" style="margin: 0; font-family: monospace; white-space: pre-wrap;"></pre>
</div>
</div>
</div> </div>
<div id="duplicateSameModal" class="modal"> <div id="duplicateSameModal" class="modal">
<div class="modal-content modal-duplicate"> <div class="modal-content modal-duplicate">
@@ -91,7 +94,7 @@
{% endfor %} {% endfor %}
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="cancelFields()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="cancelFields()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="button" class="btn btn-primary" onclick="saveSmartScanData()">Save</button>
</div> </div>
</form> </form>
</div> </div>
@@ -106,6 +109,12 @@
<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="scan-row-cell scan-col-header">{{ field.field_label }}</div>
{% endfor %}
<div class="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="scan-row scan-row-{{ scan.duplicate_status }}"
@@ -136,7 +145,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 +166,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>
@@ -206,11 +215,199 @@ 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.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 +423,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 +451,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 +516,7 @@ function submitScan() {
if (input) fieldValues[field.field_name] = input.value; if (input) fieldValues[field.field_name] = input.value;
} }
}); });
fetch(`/cons-sheets/session/${sessionId}/scan`, { fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed }) body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed })
@@ -359,7 +575,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 +629,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 +643,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 +655,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,11 +30,17 @@
<!-- 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.process_name }}</h3>
<div class="session-list-meta"> <div class="session-list-meta">
@@ -42,14 +48,52 @@
<span></span> <span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span> <span>{{ s.scan_count or 0 }} lots 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 {
@@ -1303,9 +1435,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>