26 Commits

Author SHA1 Message Date
Javier
907f805cca feat: updated to version 0.18.4 2026-02-13 11:52:55 -06:00
Javier
3d6421e0ed feat: add [title] prefix for session title field 2026-02-13 11:51:05 -06:00
Javier
90f30db915 fix: clear input field on scan error, version 0.18.3 - Fixes #1 2026-02-12 09:57:48 -06:00
Javier
4e7d88c3f9 feat: add edit session header, fix css conflicts with cs-scan-row prefix 2026-02-12 01:49:46 -06:00
Javier
0df35b015b feat: add archive/delete/restore sessions with header preview 2026-02-11 11:51:05 -06:00
Javier
b97424554c feat: add column headers to scan list 2026-02-11 10:04:39 -06:00
Javier
de72a1fb9e feat: silent success scan, error beep on fail and duplicate 2026-02-11 09:50:34 -06:00
Javier
caeefa5d61 bump: version 0.18.1 2026-02-11 09:25:02 -06:00
Javier
ea403934d3 feat: remove page reloads on scan, use DOM updates for speed 2026-02-11 09:24:44 -06:00
Javier
deb74fd971 Updated AI Prompt 2026-02-10 12:55:32 -06:00
Javier
363295762a feat: Implement Smart Router workflow with User Input and Duplicate Logic (v0.18.0)
Major update to the scanning engine to support "Pause & Resume" workflows.
The system can now halt execution to ask for user input (e.g. Weight) and resume
processing seamlessly.

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

Ver: 0.18.0
2026-02-09 00:34:41 -06:00
Javier
ea8551043f v0.17.3 - Updated the way Gunicorn restarts the server 2026-02-08 02:08:58 -06:00
Javier
b41e64be64 Bug: added gunicorn config file 2026-02-08 01:53:03 -06:00
Javier
3b9680455c v0.17.1 - added Gunicorn to Docker 2026-02-08 01:16:31 -06:00
Javier
56b0d6d398 feat: Complete modular plugin architecture v0.17.0
Major architectural refactor:
- Convert invcount and conssheets to self-contained modules
- Add Module Manager with upload/install/uninstall
- Implement auto-restart after module installation
- Add drag-and-drop module upload system
- Create triple-confirmation uninstall flow
- Redesign Module Manager UI with card layout
- Support dynamic module loading from /modules/

This enables easy distribution and installation of new modules
without code changes to core application.
2026-02-08 00:54:12 -06:00
Javier
22d7a349a2 Bug: Fixed Imports of Consumption Sheets Module 2026-02-07 12:15:47 -06:00
Javier
3afc096fd4 Bug: Fixed some small icon issues. 2026-02-07 10:00:43 -06:00
Javier
406219547d feat: Implement modular plugin architecture
- Convert invcount to self-contained module
- Add Module Manager for install/uninstall
- Create module_registry database table
- Support hot-reloading of modules
- Move data imports into invcount module
- Update all templates and routes to new structure

Version bumped to 0.16.0
2026-02-07 01:47:49 -06:00
Javier
2a649fdbcc V0.15.0 - Not done yet 2026-02-01 16:22:59 -06:00
Javier
89be88566f Excel Template working better, still not finished. 2026-02-01 01:35:02 -06:00
Javier
1359e036d5 Update 2026-01-31 22:20:10 -06:00
Javier
ad071438cc Merge branch 'Refractor--Changing-how-counting-works' 2026-01-31 20:32:18 -06:00
Javier
5604686630 update: added files to gitignore 2026-01-31 20:30:12 -06:00
Javier
2d333c16a3 v0.14.0 - Major Logic Overhaul & Real-Time Dashboard
Logic: Implemented "One User, One Bin" locking to prevent duplicate counting.

    Integrity: Standardized is_deleted = 0 and tightened "Matched" criteria to require zero weight variance.

    Refresh: Added silent 30-second dashboard polling for all 6 status categories and active counter list.

    Tracking: Built user-specific activity tracking to identify who is counting where in real-time.

    Stability: Resolved persistent 500 errors by finalizing the active-counters-fragment structure.
2026-01-31 19:17:36 -06:00
Javier
288b390618 Merge branch 'refactor/counts-dashboard' 2026-01-30 10:43:51 -06:00
Javier
fcdef6875e Stop tracking compiled python files 2026-01-30 10:41:34 -06:00
53 changed files with 7730 additions and 1080 deletions

View File

@@ -1,108 +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 web app for warehouse counting workflows.
- Admin creates a **Count Session** (e.g., “Jan 24 2026 - First Shift”) and uploads a **Master Inventory list**.
- Staff select the active Count Session, enter a **Location/BIN**, and the app shows the **Expected** lots/items/weights that should be there (Cycle Count mode).
- Staff **scan lot numbers**, enter **weights**, and each scan moves from **Expected → Scanned**.
- System flags:
- duplicates
- wrong location
- “ghost” lots (physically found but not in system/master list)
- Staff can **Finalize** a BIN; once finalized, it should clearly report **missing items/lots**.
- Admin sees live progress in an **Admin Dashboard**.
- Multiple Count Sessions can exist even on the same day (e.g., First Shift vs Second Shift) and must be completely isolated.
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.
Operating rules (must follow)
There are two types of counts:
1) **Cycle Count**: shows Expected list for the BIN.
2) **Physical Inventory**: same workflow but **blind** (does NOT show Expected list; only scanned results, then missing is determined after).
Be accurate, not fast. Double-check code, SQL, and commands before sending.
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable.
No assumptions about files/environment. If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
## Operating rules (must follow)
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.
No command dumps. Don't give long chains of commands. One command (or tiny set) per step.
## How you should respond
- Start by confirming which mode were working on: Cycle Count or Physical Inventory.
- Ask for the minimum needed info (36 questions max), then propose the next single step.
- 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.
Keep it to the point. Default to short answers. Only explain more if I ask.
## Scanlook (current product summary)
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
Verify safety. Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
**Current Version:** 0.13.0
Evidence-based debugging. Ask for exact error text/logs and versions before guessing.
**Tech Stack:**
- 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
CSS changes: Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
**Project Structure:**
- app.py (main Flask app, routes for auth + dashboard)
- /blueprints/ (modular routes: counting.py, sessions.py, users.py, data_imports.py, admin_locations.py)
- /templates/ (Jinja2 HTML templates)
- /static/css/ (style.css, mobile.css, scanner.css)
- /database/ (scanlook.db, init_db.py)
- db.py (database helper functions: query_db, execute_db)
- utils.py (decorators: login_required, role_required)
- migrations.py (database migration system)
Docker deployment: Production runs in Docker with Gunicorn on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
**Key Features (implemented):**
- Count Sessions with archive/activate functionality
- Master baseline upload (CSV)
- Current baseline upload (optional, for comparison)
- Staff scanning interface optimized for MC9300 Zebra scanners
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
- Location/BIN workflow with Expected → Scanned flow
- Session isolation (archived sessions blocked from access)
- Role-based access: owner, admin, staff
- Auto-initialize database on first run
- Consumption Sheets module (production lot tracking with Excel export)
- Database migration system (auto-applies schema changes on startup)
Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
**Two count types:**
1. Cycle Count: shows Expected list for the BIN
2. Physical Inventory: blind count (no Expected list shown)
Scanlook Architecture
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
Current Version: 0.18.0
**Module System (v0.13.0):**
- 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)
Tech Stack:
Backend: Python 3.13, Flask, Gunicorn (production WSGI server)
## Quick Reference
- Database: SQLite at /database/scanlook.db
- Scanner viewport: 320px wide (MC9300)
- Mobile breakpoint: 360-767px
- Desktop: 768px+
- Git remote: http://10.44.44.33:3000/stuff/ScanLook.git
- Docker registry: 10.44.44.33:3000/stuff/scanlook
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
WORKDIR /app
# Copy requirements first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
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"]

298
app.py
View File

@@ -1,7 +1,7 @@
"""
ScanLook - Inventory Management System
ScanLook - Modular Inventory Management System
Flask Application
Production-Ready Release
Production-Ready Release with Module System
"""
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
from werkzeug.security import check_password_hash
@@ -13,21 +13,11 @@ app = Flask(__name__)
# Now import your custom modules
from db import query_db, execute_db, get_db
from blueprints.data_imports import data_imports_bp
from blueprints.users import users_bp
from blueprints.sessions import sessions_bp
from blueprints.admin_locations import admin_locations_bp
from blueprints.counting import counting_bp
from blueprints.cons_sheets import cons_sheets_bp
from utils import login_required
# Register Blueprints
app.register_blueprint(data_imports_bp)
# Register Core Blueprints (non-modular)
app.register_blueprint(users_bp)
app.register_blueprint(sessions_bp)
app.register_blueprint(admin_locations_bp)
app.register_blueprint(counting_bp)
app.register_blueprint(cons_sheets_bp)
# V1.0: Use environment variable for production, fallback to demo key for development
app.secret_key = os.environ.get('SCANLOOK_SECRET_KEY', 'scanlook-demo-key-replace-for-production')
@@ -38,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version
APP_VERSION = '0.13.2'
APP_VERSION = '0.18.4'
# 2. Inject it into all templates automatically
@app.context_processor
@@ -53,11 +43,17 @@ if not os.path.exists(db_path):
init_database()
create_default_users()
print("Database initialized!")
print("📦 Install modules from /admin/modules")
# Run migrations to apply any pending database changes
from migrations import run_migrations
run_migrations()
# Load and register active modules
from module_manager import get_module_manager
module_manager = get_module_manager()
module_manager.load_active_modules(app)
# ==================== ROUTES: AUTHENTICATION ====================
@@ -119,50 +115,245 @@ def home():
return render_template('home.html', modules=modules)
# ==================== ROUTES: DASHBOARD ====================
# ==================== ROUTES: ADMIN DASHBOARD ====================
@app.route('/admin')
@login_required
def admin_dashboard():
"""Main dashboard - different views for admin vs staff"""
"""Admin dashboard - shows all available modules"""
role = session.get('role')
if role in ['owner', 'admin']:
# Admin dashboard
show_archived = request.args.get('show_archived', '0') == '1'
if role not in ['owner', 'admin']:
flash('Access denied. Admin role required.', 'danger')
return redirect(url_for('home'))
if show_archived:
# Show all sessions (active and archived)
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
FROM CountSessions s
LEFT JOIN Users u ON s.created_by = u.user_id
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
WHERE s.status IN ('active', 'archived')
GROUP BY s.session_id
ORDER BY s.status ASC, s.created_timestamp DESC
''')
# Get modules this user has access to
user_id = session.get('user_id')
modules = query_db('''
SELECT m.module_id, m.module_name, m.module_key, m.description, m.icon
FROM Modules m
JOIN UserModules um ON m.module_id = um.module_id
WHERE um.user_id = ? AND m.is_active = 1
ORDER BY m.display_order
''', [user_id])
return render_template('admin_dashboard.html', modules=modules)
# ==================== MODULE MANAGER UI ====================
@app.route('/admin/modules')
@login_required
def module_manager_ui():
"""Module manager interface for admins"""
if session.get('role') not in ['owner', 'admin']:
flash('Access denied. Admin role required.', 'danger')
return redirect(url_for('home'))
modules = module_manager.scan_available_modules()
return render_template('module_manager.html', modules=modules)
@app.route('/admin/modules/<module_key>/install', methods=['POST'])
@login_required
def install_module(module_key):
"""Install a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.install_module(module_key)
if result['success']:
result['restart_required'] = True
return jsonify(result)
@app.route('/admin/modules/<module_key>/uninstall', methods=['POST'])
@login_required
def uninstall_module(module_key):
"""Uninstall a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
# 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)
@app.route('/admin/modules/<module_key>/activate', methods=['POST'])
@login_required
def activate_module(module_key):
"""Activate a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.activate_module(module_key)
return jsonify(result)
@app.route('/admin/modules/<module_key>/deactivate', methods=['POST'])
@login_required
def deactivate_module(module_key):
"""Deactivate a module"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Access denied'}), 403
result = module_manager.deactivate_module(module_key)
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:
# Show only active sessions
sessions_list = query_db('''
SELECT s.*, u.full_name as created_by_name,
COUNT(DISTINCT lc.location_count_id) as total_locations,
SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations,
SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations
FROM CountSessions s
LEFT JOIN Users u ON s.created_by = u.user_id
LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id
WHERE s.status = 'active'
GROUP BY s.session_id
ORDER BY s.created_timestamp DESC
''')
# 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)
return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
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 ====================
@@ -204,6 +395,19 @@ def whatami():
"""
@app.route('/debug/routes')
@login_required
def list_routes():
"""Debug: List all registered routes"""
if session.get('role') not in ['owner', 'admin']:
return "Access denied", 403
routes = []
for rule in app.url_map.iter_rules():
routes.append(f"{rule.endpoint}: {rule.rule}")
return "<br>".join(sorted(routes))
# ==================== RUN APPLICATION ====================
if __name__ == '__main__':

View File

@@ -29,35 +29,6 @@ def reopen_location(location_count_id):
return jsonify({'success': True, 'message': 'Bin reopened for counting'})
@admin_locations_bp.route('/location/<int:location_count_id>/delete', methods=['POST'])
@login_required
def delete_location_count(location_count_id):
"""Delete all counts for a location (soft delete)"""
# Verify ownership
loc = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', [location_count_id], one=True)
if not loc:
return jsonify({'success': False, 'message': 'Location not found'})
if loc['counted_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Soft delete all scan entries for this location
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# Delete the location count record
execute_db('''
DELETE FROM LocationCounts
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({'success': True, 'message': 'Bin count deleted'})
@admin_locations_bp.route('/location/<int:location_count_id>/scans')
@login_required
def get_location_scans(location_count_id):
@@ -87,3 +58,39 @@ def get_location_scans(location_count_id):
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@admin_locations_bp.route('/location/<int:location_count_id>/delete', methods=['POST'])
@login_required
def soft_delete_location(location_count_id):
"""Admin-only: Soft delete a bin count and its associated data"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Admin role required'}), 403
# 1. Verify location exists
loc = query_db('SELECT session_id, location_name FROM LocationCounts WHERE location_count_id = ?',
[location_count_id], one=True)
if not loc:
return jsonify({'success': False, 'message': 'Location not found'})
# 2. Soft delete the bin count itself
execute_db('''
UPDATE LocationCounts
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# 3. Soft delete all scans in that bin
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# 4. Remove any MissingLots records generated for this bin
execute_db('''
DELETE FROM MissingLots
WHERE session_id = ? AND master_expected_location = ?
''', [loc['session_id'], loc['location_name']])
return jsonify({'success': True, 'message': 'Bin count and associated data soft-deleted'})

View File

@@ -8,18 +8,23 @@ cons_sheets_bp = Blueprint('cons_sheets', __name__)
@cons_sheets_bp.route('/admin/consumption-sheets')
@role_required('owner', 'admin')
def admin_processes():
"""List all consumption sheet process types"""
processes = query_db('''
SELECT cp.*, u.full_name as created_by_name,
(SELECT COUNT(*) FROM cons_process_fields
WHERE process_id = cp.id AND is_active = 1) as field_count
FROM cons_processes cp
LEFT JOIN Users u ON cp.created_by = u.user_id
WHERE cp.is_active = 1
ORDER BY cp.process_name
''')
"""List all consumption sheet process types (Active or Archived)"""
show_archived = request.args.get('archived') == '1'
is_active_val = 0 if show_archived else 1
return render_template('cons_sheets/admin_processes.html', processes=processes)
processes = query_db('''
SELECT cp.*,
u.full_name as created_by_name,
(SELECT COUNT(*) FROM cons_process_fields WHERE process_id = cp.id) as field_count
FROM cons_processes cp
LEFT JOIN users u ON cp.created_by = u.user_id
WHERE cp.is_active = ?
ORDER BY cp.process_name ASC
''', [is_active_val])
return render_template('cons_sheets/admin_processes.html',
processes=processes,
showing_archived=show_archived)
@cons_sheets_bp.route('/admin/consumption-sheets/create', methods=['GET', 'POST'])
@@ -144,6 +149,36 @@ def rename_column_in_detail_table(process_key, old_name, new_name):
conn.close()
@cons_sheets_bp.route('/admin/consumption-sheets/<int:process_id>/delete', methods=['POST'])
@role_required('owner', 'admin')
def delete_process(process_id):
"""Soft-delete a process type (Archive it)"""
# Check if process exists
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('cons_sheets.admin_processes'))
# Soft delete: Set is_active = 0
# The existing admin_processes route already filters for is_active=1,
# so this will effectively hide it from the list.
execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id])
flash(f'Process "{process["process_name"]}" has been deleted.', 'success')
return redirect(url_for('cons_sheets.admin_processes'))
@cons_sheets_bp.route('/admin/consumption-sheets/<int:process_id>/restore', methods=['POST'])
@role_required('owner', 'admin')
def restore_process(process_id):
"""Restore a soft-deleted process type"""
execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id])
flash('Process has been restored.', 'success')
return redirect(url_for('cons_sheets.admin_processes', archived=1))
@cons_sheets_bp.route('/admin/consumption-sheets/<int:process_id>')
@role_required('owner', 'admin')
def process_detail(process_id):
@@ -284,24 +319,35 @@ def update_template_settings(process_id):
rows_per_page = request.form.get('rows_per_page', 30)
detail_start_row = request.form.get('detail_start_row', 10)
page_height = request.form.get('page_height')
print_start_col = request.form.get('print_start_col', 'A').strip().upper()
print_end_col = request.form.get('print_end_col', '').strip().upper()
try:
rows_per_page = int(rows_per_page)
detail_start_row = int(detail_start_row)
# We enforce page_height is required now
page_height = int(page_height) if page_height and page_height.strip() else None
if not page_height:
flash('Page Height is required for the new strategy', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id))
except ValueError:
flash('Invalid number values', 'danger')
return redirect(url_for('cons_sheets.process_template', process_id=process_id))
# Update query - We ignore detail_end_row (leave it as is or null)
execute_db('''
UPDATE cons_processes
SET rows_per_page = ?, detail_start_row = ?
SET rows_per_page = ?, detail_start_row = ?, page_height = ?,
print_start_col = ?, print_end_col = ?
WHERE id = ?
''', [rows_per_page, detail_start_row, process_id])
''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id])
flash('Settings updated successfully!', 'success')
return redirect(url_for('cons_sheets.process_template', process_id=process_id))
@cons_sheets_bp.route('/admin/consumption-sheets/<int:process_id>/template/download')
@role_required('owner', 'admin')
def download_template(process_id):
@@ -905,22 +951,59 @@ def archive_session(session_id):
return jsonify({'success': True})
# --- BULK IMPORT ROUTES ---
@cons_sheets_bp.route('/cons-sheets/session/<int:session_id>/export')
@cons_sheets_bp.route('/cons-sheets/session/<int:session_id>/template')
@login_required
def export_session(session_id):
"""Export session to Excel using the process template"""
from flask import Response
def download_import_template(session_id):
"""Generate a blank Excel template for bulk import"""
from flask import Response # <--- ADDED THIS
from io import BytesIO
import openpyxl
from openpyxl.utils import get_column_letter, column_index_from_string
from copy import copy
from datetime import datetime
# Get session with process info
# Get Process ID
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'))
# Get Detail Fields
fields = query_db('''
SELECT field_name, field_label
FROM cons_process_fields
WHERE process_id = ? AND table_type = 'detail' AND is_active = 1
ORDER BY sort_order
''', [sess['process_id']])
# Create Workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Import Data"
# Write Header Row (Field Names)
headers = [f['field_name'] for f in fields]
ws.append(headers)
output = BytesIO()
wb.save(output)
output.seek(0)
return Response(
output.getvalue(),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'}
)
@cons_sheets_bp.route('/cons-sheets/session/<int:session_id>/import', methods=['POST'])
@login_required
def import_session_data(session_id):
"""Bulk import detail rows from Excel"""
# Import EVERYTHING locally to avoid NameErrors
import openpyxl
from datetime import datetime
from flask import request, flash, redirect, url_for, session
# 1. Get Session Info
sess = query_db('''
SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id,
cp.template_file, cp.template_filename, cp.rows_per_page, cp.detail_start_row
SELECT cs.*, cp.process_key
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.id = ?
@@ -930,11 +1013,124 @@ def export_session(session_id):
flash('Session not found', 'danger')
return redirect(url_for('cons_sheets.index'))
if not sess['template_file']:
flash('No template configured for this process', 'danger')
# 2. Check File
if 'file' not in request.files:
flash('No file uploaded', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id))
# Get header fields and values
file = request.files['file']
if file.filename == '':
flash('No file selected', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id))
try:
# 3. Read Excel
wb = openpyxl.load_workbook(file)
ws = wb.active
# Get headers from first row
headers = [cell.value for cell in ws[1]]
# Get valid field names for this process
valid_fields = query_db('''
SELECT field_name
FROM cons_process_fields
WHERE process_id = ? AND table_type = 'detail' AND is_active = 1
''', [sess['process_id']])
valid_field_names = [f['field_name'] for f in valid_fields]
# Map Excel Columns to DB Fields
col_mapping = {}
for idx, header in enumerate(headers):
if header and header in valid_field_names:
col_mapping[idx] = header
if not col_mapping:
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))
# 4. Process Rows
table_name = f"cons_proc_{sess['process_key']}_details"
rows_inserted = 0
# Get User ID safely from session
user_id = session.get('user_id')
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row): continue
data = {}
for col_idx, value in enumerate(row):
if col_idx in col_mapping:
data[col_mapping[col_idx]] = value
if not data: continue
# Add Metadata
data['session_id'] = session_id
data['scanned_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
data['scanned_by'] = user_id
# REMOVED: data['is_valid'] = 1 (This column does not exist)
data['is_deleted'] = 0
# Dynamic Insert SQL
columns = ', '.join(data.keys())
placeholders = ', '.join(['?'] * len(data))
values = list(data.values())
sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
execute_db(sql, values)
rows_inserted += 1
flash(f'Successfully imported {rows_inserted} records!', 'success')
except Exception as e:
# This will catch any other errors and show them to you
flash(f'Import Error: {str(e)}', 'danger')
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))
@cons_sheets_bp.route('/cons-sheets/session/<int:session_id>/export')
@login_required
def export_session(session_id):
"""Export session: Hide Rows Strategy + Manual Column Widths"""
from flask import Response
from io import BytesIO
import openpyxl
# Correct imports for newer openpyxl
from openpyxl.utils.cell import coordinate_from_string, get_column_letter
from openpyxl.worksheet.pagebreak import Break
from datetime import datetime
import math
# --- FIX 1: Update SQL to fetch the new columns ---
sess = query_db('''
SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id,
cp.template_file, cp.template_filename,
cp.rows_per_page, cp.detail_start_row, cp.page_height,
cp.print_start_col, cp.print_end_col
FROM cons_sessions cs
JOIN cons_processes cp ON cs.process_id = cp.id
WHERE cs.id = ?
''', [session_id], one=True)
if not sess or not sess['template_file']:
flash('Session or Template not found', 'danger')
return redirect(url_for('cons_sheets.index'))
# Validation
page_height = sess['page_height']
rows_per_page = sess['rows_per_page'] or 30
detail_start_row = sess['detail_start_row'] or 10
if not page_height:
flash('Configuration Error: Page Height is not set.', 'danger')
return redirect(url_for('cons_sheets.scan_session', session_id=session_id))
# Get Data
header_fields = query_db('''
SELECT cpf.field_name, cpf.excel_cell, cshv.field_value
FROM cons_process_fields cpf
@@ -942,7 +1138,6 @@ def export_session(session_id):
WHERE cpf.process_id = ? AND cpf.table_type = 'header' AND cpf.is_active = 1 AND cpf.excel_cell IS NOT NULL
''', [session_id, sess['process_id']])
# Get detail fields with their column mappings
detail_fields = query_db('''
SELECT field_name, excel_cell, field_type
FROM cons_process_fields
@@ -950,169 +1145,94 @@ def export_session(session_id):
ORDER BY sort_order, id
''', [sess['process_id']])
# Get all scanned details
table_name = get_detail_table_name(sess['process_key'])
table_name = f'cons_proc_{sess["process_key"]}_details'
scans = query_db(f'''
SELECT * FROM {table_name}
WHERE session_id = ? AND is_deleted = 0
ORDER BY scanned_at ASC
''', [session_id])
# Load the template
template_bytes = BytesIO(sess['template_file'])
wb = openpyxl.load_workbook(template_bytes)
# Setup Excel
wb = openpyxl.load_workbook(BytesIO(sess['template_file']))
ws = wb.active
rows_per_page = sess['rows_per_page'] or 30
detail_start_row = sess['detail_start_row'] or 11
# Clear existing breaks
ws.row_breaks.brk = []
ws.col_breaks.brk = []
# Calculate how many pages we need
total_scans = len(scans) if scans else 0
num_pages = max(1, (total_scans + rows_per_page - 1) // rows_per_page) if total_scans > 0 else 1
# Calculate Pages Needed
total_items = len(scans)
total_pages = math.ceil(total_items / rows_per_page) if total_items > 0 else 1
# Helper function to fill header values on a sheet
def fill_header(worksheet, header_fields):
# --- MAIN LOOP ---
for page_idx in range(total_pages):
# 1. Fill Header
for field in header_fields:
if field['excel_cell'] and field['field_value']:
try:
worksheet[field['excel_cell']] = field['field_value']
except:
pass # Skip invalid cell references
col_letter, row_str = coordinate_from_string(field['excel_cell'])
base_row = int(row_str)
target_row = base_row + (page_idx * page_height)
ws[f"{col_letter}{target_row}"] = field['field_value']
except: pass
# Helper function to clear detail rows on a sheet
def clear_details(worksheet, detail_fields, start_row, num_rows):
for i in range(num_rows):
row_num = start_row + i
for field in detail_fields:
if field['excel_cell']:
try:
col_letter = field['excel_cell'].upper().strip()
cell_ref = f"{col_letter}{row_num}"
worksheet[cell_ref] = None
except:
pass
# Helper function to fill detail rows on a sheet
def fill_details(worksheet, scans_subset, detail_fields, start_row):
for i, scan in enumerate(scans_subset):
row_num = start_row + i
for field in detail_fields:
if field['excel_cell']:
try:
col_letter = field['excel_cell'].upper().strip()
cell_ref = f"{col_letter}{row_num}"
value = scan[field['field_name']]
# Convert to appropriate type
if field['field_type'] == 'REAL' and value:
value = float(value)
elif field['field_type'] == 'INTEGER' and value:
value = int(value)
worksheet[cell_ref] = value
except Exception as e:
print(f"Error filling cell: {e}")
# Fill the first page
fill_header(ws, header_fields)
first_page_scans = scans[:rows_per_page] if scans else []
fill_details(ws, first_page_scans, detail_fields, detail_start_row)
# Create additional pages if needed
for page_num in range(2, num_pages + 1):
# Copy the worksheet within the same workbook
new_ws = wb.copy_worksheet(ws)
new_ws.title = f"Page {page_num}"
# Clear detail rows (they have Page 1 data)
clear_details(new_ws, detail_fields, detail_start_row, rows_per_page)
# Fill details for this page
start_idx = (page_num - 1) * rows_per_page
# 2. Fill Details
start_idx = page_idx * rows_per_page
end_idx = start_idx + rows_per_page
page_scans = scans[start_idx:end_idx]
fill_details(new_ws, page_scans, detail_fields, detail_start_row)
# Rename first sheet if we have multiple pages
if num_pages > 1:
ws.title = "Page 1"
for i, scan in enumerate(page_scans):
target_row = detail_start_row + (page_idx * page_height) + i
for field in detail_fields:
if field['excel_cell']:
try:
col_letter = field['excel_cell'].upper().strip()
cell_ref = f"{col_letter}{target_row}"
value = scan[field['field_name']]
if field['field_type'] == 'REAL' and value: value = float(value)
elif field['field_type'] == 'INTEGER' and value: value = int(value)
ws[cell_ref] = value
except: pass
# Save to BytesIO
# 3. Force Page Break (BEFORE the new header)
if page_idx < total_pages - 1:
next_page_start_row = ((page_idx + 1) * page_height) # No +1 here!
ws.row_breaks.append(Break(id=next_page_start_row))
# --- STEP 3: CLEANUP (Hide Unused Rows) ---
last_used_row = (total_pages * page_height)
SAFE_MAX_ROW = 5000
for row_num in range(last_used_row + 1, SAFE_MAX_ROW):
ws.row_dimensions[row_num].hidden = True
# --- FINAL POLISH (Manual Widths) ---
# --- FIX 2: Use bracket notation (sess['col']) instead of .get() ---
# We use 'or' to provide defaults if the DB value is None
start_col = sess['print_start_col'] or 'A'
if sess['print_end_col']:
end_col = sess['print_end_col']
else:
# Fallback to auto-detection if user left it blank
end_col = get_column_letter(ws.max_column)
# Set Print Area
ws.print_area = f"{start_col}1:{end_col}{last_used_row}"
if ws.sheet_properties.pageSetUpPr:
ws.sheet_properties.pageSetUpPr.fitToPage = False
# Save
output = BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
base_filename = f"{sess['process_key']}_{session_id}_{timestamp}"
# Check if PDF export is requested
export_format = request.args.get('format', 'xlsx')
print(f"DEBUG: Export format requested: {export_format}")
if export_format == 'pdf':
# Use win32com to convert to PDF (requires Excel installed)
try:
import tempfile
import pythoncom
import win32com.client as win32
print("DEBUG: pywin32 imported successfully")
# Save Excel to temp file
temp_xlsx = tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False)
temp_xlsx.write(output.getvalue())
temp_xlsx.close()
print(f"DEBUG: Temp Excel saved to: {temp_xlsx.name}")
temp_pdf = temp_xlsx.name.replace('.xlsx', '.pdf')
# Initialize COM for this thread
pythoncom.CoInitialize()
print("DEBUG: COM initialized")
try:
excel = win32.Dispatch('Excel.Application')
excel.Visible = False
excel.DisplayAlerts = False
print("DEBUG: Excel application started")
workbook = excel.Workbooks.Open(temp_xlsx.name)
print("DEBUG: Workbook opened")
workbook.ExportAsFixedFormat(0, temp_pdf) # 0 = PDF format
print(f"DEBUG: Exported to PDF: {temp_pdf}")
workbook.Close(False)
excel.Quit()
print("DEBUG: Excel closed")
finally:
pythoncom.CoUninitialize()
# Read the PDF
with open(temp_pdf, 'rb') as f:
pdf_data = f.read()
print(f"DEBUG: PDF read, size: {len(pdf_data)} bytes")
# Clean up temp files
import os
os.unlink(temp_xlsx.name)
os.unlink(temp_pdf)
print("DEBUG: Temp files cleaned up")
return Response(
pdf_data,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename={base_filename}.pdf'}
)
except ImportError as e:
print(f"ERROR: Import failed - {e}")
# Fall back to Excel export
except Exception as e:
print(f"ERROR: PDF export failed - {e}")
import traceback
traceback.print_exc()
# Fall back to Excel export
# Default: return Excel file
print("DEBUG: Returning Excel file")
return Response(
output.getvalue(),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',

View File

@@ -116,11 +116,18 @@ def my_counts(session_id):
FROM LocationCounts lc
LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0
WHERE lc.session_id = ?
AND lc.counted_by = ?
AND lc.status = 'in_progress'
AND lc.is_deleted = 0
AND (
lc.counted_by = ?
OR lc.location_count_id IN (
SELECT location_count_id FROM ScanEntries
WHERE scanned_by = ? AND is_deleted = 0
)
)
GROUP BY lc.location_count_id
ORDER BY lc.start_timestamp DESC
''', [session_id, session['user_id']])
''', [session_id, session['user_id'], session['user_id']])
# Get this user's completed bins
completed_bins = query_db('''
@@ -129,11 +136,17 @@ def my_counts(session_id):
FROM LocationCounts lc
LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0
WHERE lc.session_id = ?
AND lc.counted_by = ?
AND lc.status = 'completed'
AND (
lc.counted_by = ?
OR lc.location_count_id IN (
SELECT location_count_id FROM ScanEntries
WHERE scanned_by = ? AND is_deleted = 0
)
)
GROUP BY lc.location_count_id
ORDER BY lc.end_timestamp DESC
''', [session_id, session['user_id']])
ORDER BY lc.start_timestamp DESC
''', [session_id, session['user_id'], session['user_id']])
return render_template('counts/my_counts.html',
count_session=sess,
@@ -144,7 +157,7 @@ def my_counts(session_id):
@counting_bp.route('/session/<int:session_id>/start-bin', methods=['POST'])
@login_required
def start_bin_count(session_id):
"""Start counting a new bin"""
"""Start counting a new bin or resume an existing in-progress one"""
sess = get_active_session(session_id)
if not sess:
flash('Session not found or archived', 'warning')
@@ -159,6 +172,20 @@ def start_bin_count(session_id):
flash('Bin number is required', 'danger')
return redirect(url_for('counting.my_counts', session_id=session_id))
# --- NEW LOGIC: Check for existing in-progress bin ---
existing_bin = query_db('''
SELECT location_count_id
FROM LocationCounts
WHERE session_id = ? AND location_name = ? AND status = 'in_progress'
''', [session_id, location_name], one=True)
if existing_bin:
flash(f'Resuming bin: {location_name}', 'info')
return redirect(url_for('counting.count_location',
session_id=session_id,
location_count_id=existing_bin['location_count_id']))
# --- END NEW LOGIC ---
# Count expected lots from MASTER baseline for this location
expected_lots = query_db('''
SELECT COUNT(DISTINCT lot_number) as count
@@ -168,7 +195,7 @@ def start_bin_count(session_id):
expected_count = expected_lots['count'] if expected_lots else 0
# Create new location count
# Create new location count if none existed
conn = get_db()
cursor = conn.cursor()
@@ -184,7 +211,6 @@ def start_bin_count(session_id):
flash(f'Started counting bin: {location_name}', 'success')
return redirect(url_for('counting.count_location', session_id=session_id, location_count_id=location_count_id))
@counting_bp.route('/location/<int:location_count_id>/complete', methods=['POST'])
@login_required
def complete_location(location_count_id):
@@ -512,7 +538,7 @@ def scan_lot(session_id, location_count_id):
def delete_scan(entry_id):
"""Soft delete a scan and recalculate duplicate statuses"""
# Get the scan being deleted
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ?', [entry_id], one=True)
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True)
if not scan:
return jsonify({'success': False, 'message': 'Scan not found'})
@@ -572,7 +598,7 @@ def update_scan(entry_id):
comment = data.get('comment', '')
# Get the scan
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ?', [entry_id], one=True)
scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True)
if not scan:
return jsonify({'success': False, 'message': 'Scan not found'})
@@ -593,7 +619,7 @@ def update_scan(entry_id):
actual_weight = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [item, weight, comment, entry_id])
return jsonify({'success': True, 'message': 'Scan updated'})
@@ -625,7 +651,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = NULL,
comment = NULL,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [scan['entry_id']])
updated_entries.append({
'entry_id': scan['entry_id'],
@@ -670,7 +696,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, scan['entry_id']])
# Update our tracking list
@@ -689,7 +715,7 @@ def recalculate_duplicate_status(session_id, lot_number, current_location):
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, prev_scan['entry_id']])
# Update tracking for previous scans
@@ -755,3 +781,56 @@ def finish_location(session_id, location_count_id):
'success': True,
'redirect': url_for('counting.count_session', session_id=session_id)
})
@counting_bp.route('/session/<int:session_id>/finalize-all', methods=['POST'])
@login_required
def finalize_all_locations(session_id):
"""Finalize all 'in_progress' locations in a session"""
if session.get('role') not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'}), 403
# 1. Get all in_progress locations for this session
locations = query_db('''
SELECT location_count_id, location_name
FROM LocationCounts
WHERE session_id = ?
AND status = 'in_progress'
AND is_deleted = 0
''', [session_id])
if not locations:
return jsonify({'success': True, 'message': 'No open bins to finalize.'})
# 2. Loop through and run the finalize logic for each
for loc in locations:
# We reuse the logic from your existing finish_location route
execute_db('''
UPDATE LocationCounts
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
WHERE location_count_id = ?
''', [loc['location_count_id']])
# Identify missing lots from MASTER baseline
expected_lots = query_db('''
SELECT lot_number, item, description, system_quantity
FROM BaselineInventory_Master
WHERE session_id = ? AND system_bin = ?
''', [session_id, loc['location_name']])
scanned_lots = query_db('''
SELECT DISTINCT lot_number
FROM ScanEntries
WHERE location_count_id = ? AND is_deleted = 0
''', [loc['location_count_id']])
scanned_lot_numbers = {s['lot_number'] for s in scanned_lots}
for expected in expected_lots:
if expected['lot_number'] not in scanned_lot_numbers:
execute_db('''
INSERT INTO MissingLots (session_id, lot_number, master_expected_location, item, master_expected_quantity, marked_by)
VALUES (?, ?, ?, ?, ?, ?)
''', [session_id, expected['lot_number'], loc['location_name'],
expected['item'], expected['system_quantity'], session['user_id']])
return jsonify({'success': True, 'message': f'Successfully finalized {len(locations)} bins.'})

View File

@@ -24,7 +24,7 @@ def create_session():
flash(f'Session "{session_name}" created successfully!', 'success')
return redirect(url_for('sessions.session_detail', session_id=session_id))
return render_template('create_session.html')
return render_template('/counts/create_session.html')
@sessions_bp.route('/session/<int:session_id>')
@@ -54,24 +54,38 @@ def session_detail(session_id):
''', [session_id], one=True)
# Get location progress
# We add a subquery to count the actual missing lots for each bin
locations = query_db('''
SELECT lc.*, u.full_name as counter_name
SELECT
lc.*,
u.full_name as counter_name,
(SELECT COUNT(*) FROM MissingLots ml
WHERE ml.session_id = lc.session_id
AND ml.master_expected_location = lc.location_name) as lots_missing_calc
FROM LocationCounts lc
LEFT JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ?
AND lc.is_deleted = 0
ORDER BY lc.status DESC, lc.location_name
''', [session_id])
# Get active counters
active_counters = query_db('''
SELECT DISTINCT u.full_name, lc.location_name, lc.start_timestamp
SELECT
u.full_name,
u.user_id,
MAX(lc.start_timestamp) AS start_timestamp, -- Add the alias here!
lc.location_name
FROM LocationCounts lc
JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ? AND lc.status = 'in_progress'
ORDER BY lc.start_timestamp DESC
WHERE lc.session_id = ?
AND lc.status = 'in_progress'
AND lc.is_deleted = 0
GROUP BY u.user_id
ORDER BY start_timestamp DESC
''', [session_id])
return render_template('session_detail.html',
return render_template('/counts/session_detail.html',
count_session=sess,
stats=stats,
locations=locations,
@@ -98,6 +112,7 @@ def get_status_details(session_id, status):
WHERE se.session_id = ?
AND se.master_status = 'match'
AND se.duplicate_status = '00'
AND se.master_variance_lbs = 0
AND se.is_deleted = 0
ORDER BY se.scan_timestamp DESC
''', [session_id])
@@ -184,20 +199,21 @@ def get_status_details(session_id, status):
# Missing lots (in master but not scanned)
items = query_db('''
SELECT
bim.lot_number,
bim.item,
ml.lot_number,
ml.item,
bim.description,
bim.system_bin,
bim.system_quantity
FROM BaselineInventory_Master bim
WHERE bim.session_id = ?
AND bim.lot_number NOT IN (
SELECT lot_number
FROM ScanEntries
WHERE session_id = ? AND is_deleted = 0
)
ORDER BY bim.system_bin, bim.lot_number
''', [session_id, session_id])
ml.master_expected_location as system_bin,
ml.master_expected_quantity as system_quantity
FROM MissingLots ml
LEFT JOIN BaselineInventory_Master bim ON
ml.lot_number = bim.lot_number AND
ml.item = bim.item AND
ml.master_expected_location = bim.system_bin AND
ml.session_id = bim.session_id
WHERE ml.session_id = ?
GROUP BY ml.lot_number, ml.item, ml.master_expected_location
ORDER BY ml.master_expected_location, ml.lot_number
''', [session_id])
else:
return jsonify({'success': False, 'message': 'Invalid status'})
@@ -242,3 +258,39 @@ def activate_session(session_id):
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['active', session_id])
return jsonify({'success': True, 'message': 'Session activated successfully'})
@sessions_bp.route('/session/<int:session_id>/get_stats')
@role_required('owner', 'admin')
def get_session_stats(session_id):
stats = query_db('''
SELECT
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.master_variance_lbs = 0 AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) < 0.01) as matched,
COUNT(DISTINCT se.lot_number) FILTER (WHERE se.duplicate_status IN ('01', '03', '04') AND se.is_deleted = 0) as duplicates,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01) as discrepancy,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'wrong_location' AND se.is_deleted = 0) as wrong_location,
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'ghost_lot' AND se.is_deleted = 0) as ghost_lots,
COUNT(DISTINCT ml.missing_id) as missing
FROM CountSessions cs
LEFT JOIN ScanEntries se ON cs.session_id = se.session_id
LEFT JOIN MissingLots ml ON cs.session_id = ml.session_id
WHERE cs.session_id = ?
''', [session_id], one=True)
return jsonify(success=True, stats=dict(stats))
@sessions_bp.route('/session/<int:session_id>/active-counters-fragment')
@role_required('owner', 'admin')
def active_counters_fragment(session_id):
# Use that unique-user query we just built together
active_counters = query_db('''
SELECT
u.full_name,
MAX(lc.start_timestamp) AS start_timestamp,
lc.location_name
FROM LocationCounts lc
JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ? AND lc.status = 'in_progress' AND lc.is_deleted = 0
GROUP BY u.user_id
''', [session_id])
# This renders JUST the list part, not the whole page
return render_template('counts/partials/_active_counters.html', active_counters=active_counters)

View File

@@ -1,7 +1,6 @@
"""
ScanLook Database Initialization
Creates all tables and indexes for the inventory management system
UPDATED: Reflects post-migration schema (CURRENT baseline is now global)
ScanLook Database Initialization - CORE ONLY
Creates only core system tables. Module tables are created when modules are installed.
"""
import sqlite3
@@ -13,10 +12,14 @@ DB_PATH = os.path.join(os.path.dirname(__file__), 'scanlook.db')
def init_database():
"""Initialize the database with all tables and indexes"""
"""Initialize the database with core system tables only"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# ============================================
# CORE SYSTEM TABLES
# ============================================
# Users Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS Users (
@@ -32,145 +35,7 @@ def init_database():
)
''')
# CountSessions Table
# NOTE: current_baseline_version removed - CURRENT is now global
cursor.execute('''
CREATE TABLE IF NOT EXISTS CountSessions (
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')),
created_by INTEGER NOT NULL,
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_baseline_timestamp DATETIME,
current_baseline_timestamp DATETIME,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
branch TEXT DEFAULT 'Main',
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# BaselineInventory_Master Table (Session-specific, immutable)
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Master (
baseline_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT NOT NULL,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
uploaded_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id)
)
''')
# BaselineInventory_Current Table (GLOBAL - shared across all sessions)
# MIGRATION CHANGE: No session_id, no baseline_version, no is_deleted
# This table is replaced entirely on each upload
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Current (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
)
''')
# LocationCounts Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS LocationCounts (
location_count_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_name TEXT NOT NULL,
counted_by INTEGER NOT NULL,
start_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
end_timestamp DATETIME,
status TEXT DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed')),
expected_lots_master INTEGER DEFAULT 0,
lots_found INTEGER DEFAULT 0,
lots_missing INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (counted_by) REFERENCES Users(user_id)
)
''')
# ScanEntries Table
# MIGRATION CHANGE: Removed current_* columns - now fetched via JOIN
cursor.execute('''
CREATE TABLE IF NOT EXISTS ScanEntries (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT,
description TEXT,
scanned_location TEXT NOT NULL,
actual_weight REAL NOT NULL,
scanned_by INTEGER NOT NULL,
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
-- MASTER baseline comparison (immutable, set at scan time)
master_status TEXT CHECK(master_status IN ('match', 'wrong_location', 'ghost_lot', 'missing')),
master_expected_location TEXT,
master_expected_weight REAL,
master_variance_lbs REAL,
master_variance_pct REAL,
-- Duplicate detection
duplicate_status TEXT DEFAULT '00' CHECK(duplicate_status IN ('00', '01', '03', '04')),
duplicate_info TEXT,
-- CURRENT baseline comparison removed - now via JOIN in queries
-- Removed: current_status, current_system_location, current_system_weight,
-- current_variance_lbs, current_variance_pct, current_baseline_version
-- Metadata
comment TEXT,
is_deleted INTEGER DEFAULT 0,
deleted_by INTEGER,
deleted_timestamp DATETIME,
modified_timestamp DATETIME,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (scanned_by) REFERENCES Users(user_id),
FOREIGN KEY (deleted_by) REFERENCES Users(user_id)
)
''')
# MissingLots Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS MissingLots (
missing_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER,
lot_number TEXT NOT NULL,
item TEXT,
master_expected_location TEXT NOT NULL,
master_expected_quantity REAL NOT NULL,
current_system_location TEXT,
current_system_quantity REAL,
marked_by INTEGER NOT NULL,
marked_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
found_later TEXT DEFAULT 'N' CHECK(found_later IN ('Y', 'N')),
found_location TEXT,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (marked_by) REFERENCES Users(user_id)
)
''')
# ============================================
# MODULE SYSTEM TABLES
# ============================================
# Modules Table - Available feature modules
# Modules Table (legacy - for user permissions)
cursor.execute('''
CREATE TABLE IF NOT EXISTS Modules (
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -183,7 +48,7 @@ def init_database():
)
''')
# UserModules Table - Module access per user
# UserModules Table (module access per user)
cursor.execute('''
CREATE TABLE IF NOT EXISTS UserModules (
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -198,104 +63,35 @@ def init_database():
)
''')
# ============================================
# CONSUMPTION SHEETS MODULE TABLES
# ============================================
# cons_processes - Master list of consumption sheet process types
# Module Registry Table (new module manager system)
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_processes (
CREATE TABLE IF NOT EXISTS module_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_key TEXT UNIQUE NOT NULL,
process_name TEXT NOT NULL,
template_file BLOB,
template_filename TEXT,
rows_per_page INTEGER DEFAULT 30,
detail_start_row INTEGER DEFAULT 10,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER NOT NULL,
is_active INTEGER DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES Users(user_id)
module_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
author TEXT,
description TEXT,
is_installed INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 0,
installed_at TEXT,
config_json TEXT
)
''')
# cons_process_fields - Custom field definitions for each process
# Schema Migrations Table (for core migrations only)
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_process_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')),
field_name TEXT NOT NULL,
field_label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')),
max_length INTEGER,
is_required INTEGER DEFAULT 0,
is_duplicate_key INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
excel_cell TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (process_id) REFERENCES cons_processes(id)
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# cons_sessions - Staff scanning sessions
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_sessions (
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')),
FOREIGN KEY (process_id) REFERENCES cons_processes(id),
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# Note: Header values still use flexible key-value storage
# cons_session_header_values - Flexible storage for header field values
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_session_header_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
field_id INTEGER NOT NULL,
field_value TEXT,
FOREIGN KEY (session_id) REFERENCES cons_sessions(id),
FOREIGN KEY (field_id) REFERENCES cons_process_fields(id)
)
''')
# Note: Detail tables are created dynamically per process as cons_proc_{process_key}_details
# They include system columns (id, session_id, scanned_by, scanned_at, duplicate_status,
# duplicate_info, comment, is_deleted) plus custom fields defined in cons_process_fields
# Create Indexes
# MASTER baseline indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_lot ON BaselineInventory_Master(session_id, lot_number)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_loc ON BaselineInventory_Master(session_id, system_location)')
# ScanEntries indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_session ON ScanEntries(session_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_location ON ScanEntries(location_count_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_lot ON ScanEntries(lot_number)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_deleted ON ScanEntries(is_deleted)')
# LocationCounts indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_location_counts ON LocationCounts(session_id, status)')
# Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups
# Consumption Sheets indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active)')
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)')
# Note: Detail table indexes are created dynamically when process tables are created
conn.commit()
conn.close()
print(f"Database initialized at: {DB_PATH}")
print("📝 Schema version: Post-migration (CURRENT baseline is global)")
print(f"Core database initialized at: {DB_PATH}")
print("📦 Module tables will be created when modules are installed")
def create_default_users():
@@ -327,52 +123,7 @@ def create_default_users():
conn.close()
def create_default_modules():
"""Create default modules and assign to admin users"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Define default modules
default_modules = [
('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1),
('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2),
]
# Insert modules (ignore if already exist)
for module in default_modules:
try:
cursor.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES (?, ?, ?, ?, ?, ?)
''', module)
except sqlite3.IntegrityError:
pass # Module already exists
conn.commit()
# Auto-assign all modules to owner and admin users
cursor.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")')
admin_users = cursor.fetchall()
cursor.execute('SELECT module_id FROM Modules')
all_modules = cursor.fetchall()
for user in admin_users:
for module in all_modules:
try:
cursor.execute('''
INSERT INTO UserModules (user_id, module_id)
VALUES (?, ?)
''', (user[0], module[0]))
except sqlite3.IntegrityError:
pass # Assignment already exists
conn.commit()
conn.close()
print("✅ Default modules created and assigned to admin users")
if __name__ == '__main__':
init_database()
create_default_users()
create_default_modules()

Binary file not shown.

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

@@ -1,12 +1,8 @@
"""
ScanLook Database Migration System
ScanLook Core Database Migration System
Simple migration system that tracks and applies database changes.
Each migration has a version number and an up() function.
Usage:
from migrations import run_migrations
run_migrations() # Call on app startup
IMPORTANT: This file only contains CORE system migrations.
Module-specific migrations are in each module's migrations.py file.
"""
import sqlite3
@@ -75,19 +71,13 @@ def table_exists(table):
# ============================================
# MIGRATIONS
# CORE SYSTEM MIGRATIONS ONLY
# ============================================
# Add new migrations to this list.
# Each migration is a tuple: (version, name, up_function)
#
# RULES:
# - Never modify an existing migration
# - Always add new migrations at the end with the next version number
# - Check if changes are needed before applying (idempotent)
# Module-specific migrations are handled by each module's migrations.py
# ============================================
def migration_001_add_modules_tables():
"""Add Modules and UserModules tables"""
"""Add Modules and UserModules tables (if not created by init_db)"""
conn = get_db()
if not table_exists('Modules'):
@@ -141,77 +131,42 @@ def migration_002_add_usermodules_granted_columns():
conn.close()
def migration_003_add_default_modules():
"""Add default modules if they don't exist"""
def migration_003_add_module_registry():
"""Add module_registry table for new module manager system"""
conn = get_db()
# Check if modules exist
existing = conn.execute('SELECT COUNT(*) as cnt FROM Modules').fetchone()
if existing['cnt'] == 0:
if not table_exists('module_registry'):
conn.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1)
CREATE TABLE module_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
author TEXT,
description TEXT,
is_installed INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 0,
installed_at TEXT,
config_json TEXT
)
''')
conn.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2)
''')
print(" Added default modules")
print(" Created module_registry table")
conn.commit()
conn.close()
def migration_004_assign_modules_to_admins():
"""Auto-assign all modules to owner and admin users"""
conn = get_db()
# Get admin users
admins = conn.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")').fetchall()
modules = conn.execute('SELECT module_id FROM Modules').fetchall()
for user in admins:
for module in modules:
try:
conn.execute('''
INSERT INTO UserModules (user_id, module_id)
VALUES (?, ?)
''', [user['user_id'], module['module_id']])
except sqlite3.IntegrityError:
pass # Already assigned
conn.commit()
conn.close()
print(" Assigned modules to admin users")
def migration_005_add_cons_process_fields_duplicate_key():
"""Add is_duplicate_key column to cons_process_fields if missing"""
conn = get_db()
if table_exists('cons_process_fields'):
if not column_exists('cons_process_fields', 'is_duplicate_key'):
conn.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0')
print(" Added is_duplicate_key column to cons_process_fields")
conn.commit()
conn.close()
# List of all migrations in order
# List of CORE migrations only
MIGRATIONS = [
(1, 'add_modules_tables', migration_001_add_modules_tables),
(2, 'add_usermodules_granted_columns', migration_002_add_usermodules_granted_columns),
(3, 'add_default_modules', migration_003_add_default_modules),
(4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins),
(5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key),
(3, 'add_module_registry', migration_003_add_module_registry),
]
def run_migrations():
"""Run all pending migrations"""
print("🔄 Checking database migrations...")
"""Run all pending core migrations"""
print("🔄 Checking core database migrations...")
# Make sure migrations table exists
init_migrations_table()
@@ -223,10 +178,10 @@ def run_migrations():
pending = [(v, n, f) for v, n, f in MIGRATIONS if v not in applied]
if not pending:
print("Database is up to date")
print("Core database is up to date")
return
print(f"📦 Running {len(pending)} migration(s)...")
print(f"📦 Running {len(pending)} core migration(s)...")
for version, name, func in pending:
print(f"\n Migration {version}: {name}")
@@ -238,7 +193,7 @@ def run_migrations():
print(f" ❌ Migration {version} failed: {e}")
raise
print("\n✅ All migrations complete")
print("\n✅ All core migrations complete")
if __name__ == '__main__':

388
module_manager.py Normal file
View File

@@ -0,0 +1,388 @@
"""
ScanLook Module Manager
Handles module discovery, installation, uninstallation, and activation
"""
import os
import json
import sqlite3
import importlib.util
from pathlib import Path
from typing import List, Dict, Optional
MODULES_DIR = Path(__file__).parent / 'modules'
DB_PATH = Path(__file__).parent / 'database' / 'scanlook.db'
def get_db():
"""Get database connection (standalone, no Flask context needed)"""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def query_db(query, args=(), one=False):
"""Query database and return results"""
conn = get_db()
cur = conn.execute(query, args)
rv = cur.fetchall()
conn.close()
return (rv[0] if rv else None) if one else rv
def execute_db(query, args=()):
"""Execute database command and return lastrowid"""
conn = get_db()
cur = conn.execute(query, args)
conn.commit()
last_id = cur.lastrowid
conn.close()
return last_id
class ModuleManager:
"""Manages ScanLook modules"""
def __init__(self):
self.modules_dir = MODULES_DIR
self._ensure_modules_table()
def _ensure_modules_table(self):
"""Ensure the module_registry table exists in the database"""
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS module_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
author TEXT,
description TEXT,
is_installed INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 0,
installed_at TEXT,
config_json TEXT
)
''')
conn.commit()
conn.close()
def scan_available_modules(self) -> List[Dict]:
"""
Scan the /modules directory for available modules.
Returns list of module info dicts from manifest.json files.
"""
available = []
if not self.modules_dir.exists():
return available
for item in self.modules_dir.iterdir():
if not item.is_dir():
continue
manifest_path = item / 'manifest.json'
if not manifest_path.exists():
continue
try:
with open(manifest_path, 'r') as f:
manifest = json.load(f)
# Validate required fields
required = ['module_key', 'name', 'version', 'author', 'description']
if not all(field in manifest for field in required):
print(f"⚠️ Invalid manifest in {item.name}: missing required fields")
continue
# Check installation status from database
db_module = query_db(
'SELECT is_installed, is_active FROM module_registry WHERE module_key = ?',
[manifest['module_key']],
one=True
)
manifest['is_installed'] = db_module['is_installed'] if db_module else False
manifest['is_active'] = db_module['is_active'] if db_module else False
manifest['path'] = str(item)
available.append(manifest)
except json.JSONDecodeError as e:
print(f"⚠️ Invalid JSON in {manifest_path}: {e}")
continue
except Exception as e:
print(f"⚠️ Error reading manifest from {item.name}: {e}")
continue
return sorted(available, key=lambda x: x['name'])
def get_module_by_key(self, module_key: str) -> Optional[Dict]:
"""Get module info by module_key"""
modules = self.scan_available_modules()
for module in modules:
if module['module_key'] == module_key:
return module
return None
def install_module(self, module_key: str) -> Dict:
"""
Install a module:
1. Load manifest
2. Run migrations (create tables)
3. Register in database
4. Set is_installed=1, is_active=1
Returns: {'success': bool, 'message': str}
"""
try:
# Get module info
module = self.get_module_by_key(module_key)
if not module:
return {'success': False, 'message': f'Module {module_key} not found'}
# Check if already installed
if module['is_installed']:
return {'success': False, 'message': f'Module {module_key} is already installed'}
# Load module's migrations
migrations_path = Path(module['path']) / 'migrations.py'
if not migrations_path.exists():
return {'success': False, 'message': 'Module is missing migrations.py'}
# Import migrations module
spec = importlib.util.spec_from_file_location(f"{module_key}_migrations", migrations_path)
migrations_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migrations_module)
# Run schema installation
print(f"\n📦 Installing module: {module['name']}")
conn = get_db()
# Execute schema SQL
if hasattr(migrations_module, 'get_schema'):
schema_sql = migrations_module.get_schema()
conn.executescript(schema_sql)
print(f" ✅ Database schema created")
# Run module-specific migrations
if hasattr(migrations_module, 'get_migrations'):
migrations = migrations_module.get_migrations()
for version, name, func in migrations:
print(f" Running migration {version}: {name}")
func(conn)
conn.commit()
# Register module in database
existing = query_db('SELECT id FROM module_registry WHERE module_key = ?', [module_key], one=True)
if existing:
execute_db('''
UPDATE module_registry
SET name = ?, version = ?, author = ?, description = ?,
is_installed = 1, is_active = 1, installed_at = CURRENT_TIMESTAMP
WHERE module_key = ?
''', [module['name'], module['version'], module['author'],
module['description'], module_key])
else:
execute_db('''
INSERT INTO module_registry (module_key, name, version, author, description,
is_installed, is_active, installed_at)
VALUES (?, ?, ?, ?, ?, 1, 1, CURRENT_TIMESTAMP)
''', [module_key, module['name'], module['version'],
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
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, is_active)
VALUES (?, ?, ?, 1)
''', [module['name'], module_key, module['description']])
conn.close()
print(f"✅ Module {module['name']} installed successfully")
return {'success': True, 'message': f'Module {module["name"]} installed successfully'}
except Exception as e:
print(f"❌ Installation failed: {e}")
import traceback
traceback.print_exc()
return {'success': False, 'message': f'Installation failed: {str(e)}'}
def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict:
"""
Uninstall a module:
1. Set is_installed=0, is_active=0 in database
2. Optionally drop all module tables
3. Remove from old Modules table
Returns: {'success': bool, 'message': str}
"""
try:
module = self.get_module_by_key(module_key)
if not module:
return {'success': False, 'message': f'Module {module_key} not found'}
if not module['is_installed']:
return {'success': False, 'message': f'Module {module_key} is not installed'}
print(f"\n🗑️ Uninstalling module: {module['name']}")
conn = get_db()
# Drop tables if requested
if drop_tables:
print(f" Dropping database tables...")
# Load migrations to get table names
migrations_path = Path(module['path']) / 'migrations.py'
if migrations_path.exists():
spec = importlib.util.spec_from_file_location(f"{module_key}_migrations", migrations_path)
migrations_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migrations_module)
# Get schema and extract table names
if hasattr(migrations_module, 'get_schema'):
schema = migrations_module.get_schema()
# Simple regex to find CREATE TABLE statements
import re
tables = re.findall(r'CREATE TABLE IF NOT EXISTS (\w+)', schema)
for table in tables:
try:
conn.execute(f'DROP TABLE IF EXISTS {table}')
print(f" Dropped table: {table}")
except Exception as e:
print(f" Warning: Could not drop {table}: {e}")
# Update module_registry table
execute_db('''
UPDATE module_registry
SET is_installed = 0, is_active = 0
WHERE module_key = ?
''', [module_key])
# Remove from old Modules table
execute_db('DELETE FROM Modules WHERE module_key = ?', [module_key])
# Remove user module assignments
old_module_id = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True)
if old_module_id:
execute_db('DELETE FROM UserModules WHERE module_id = ?', [old_module_id['module_id']])
conn.commit()
conn.close()
print(f"✅ Module {module['name']} uninstalled successfully")
return {'success': True, 'message': f'Module {module["name"]} uninstalled successfully'}
except Exception as e:
print(f"❌ Uninstallation failed: {e}")
return {'success': False, 'message': f'Uninstallation failed: {str(e)}'}
def activate_module(self, module_key: str) -> Dict:
"""Activate an installed module"""
module = self.get_module_by_key(module_key)
if not module:
return {'success': False, 'message': f'Module {module_key} not found'}
if not module['is_installed']:
return {'success': False, 'message': 'Module must be installed first'}
execute_db('UPDATE module_registry SET is_active = 1 WHERE module_key = ?', [module_key])
execute_db('UPDATE Modules SET is_active = 1 WHERE module_key = ?', [module_key])
return {'success': True, 'message': f'Module {module["name"]} activated'}
def deactivate_module(self, module_key: str) -> Dict:
"""Deactivate a module (keeps it installed)"""
module = self.get_module_by_key(module_key)
if not module:
return {'success': False, 'message': f'Module {module_key} not found'}
execute_db('UPDATE module_registry SET is_active = 0 WHERE module_key = ?', [module_key])
execute_db('UPDATE Modules SET is_active = 0 WHERE module_key = ?', [module_key])
return {'success': True, 'message': f'Module {module["name"]} deactivated'}
def load_active_modules(self, app):
"""
Load all active modules, run their migrations, and register blueprints.
Called during app startup.
"""
modules = self.scan_available_modules()
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:
# --- NEW: Run Migrations on Startup ---
migrations_path = Path(module['path']) / 'migrations.py'
if migrations_path.exists():
# 1. Dynamically load the migrations.py file
spec_mig = importlib.util.spec_from_file_location(f"{module['module_key']}_mig", migrations_path)
mig_mod = importlib.util.module_from_spec(spec_mig)
spec_mig.loader.exec_module(mig_mod)
# 2. Run the migrations
if hasattr(mig_mod, 'get_migrations'):
conn = get_db()
for version, name, func in mig_mod.get_migrations():
# Your migrations are written safely (checking IF EXISTS),
# so running them on every boot is the correct Dev workflow.
func(conn)
conn.commit() # <--- CRITICAL: Saves the changes to the DB
conn.close()
# --------------------------------------
# Import module's __init__.py
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
manager = ModuleManager()
def get_module_manager() -> ModuleManager:
"""Get the global module manager instance"""
return manager

View File

@@ -0,0 +1,20 @@
"""
Consumption Sheets Module
Handles production lot tracking and consumption reporting
"""
from flask import Blueprint
def create_blueprint():
"""Create and return the conssheets blueprint"""
bp = Blueprint(
'conssheets',
__name__,
template_folder='templates',
url_prefix='/conssheets'
)
# Import and register routes
from .routes import register_routes
register_routes(bp)
return bp

View File

@@ -0,0 +1,12 @@
{
"module_key": "conssheets",
"name": "Consumption Sheets",
"version": "1.1.0",
"author": "STUFF",
"description": "Production lot tracking and consumption reporting with Excel export",
"icon": "fa-clipboard-list",
"requires_roles": ["owner", "admin", "staff"],
"routes_prefix": "/conssheets",
"has_migrations": true,
"dependencies": []
}

View File

@@ -0,0 +1,193 @@
"""
Consumption Sheets Module - Database Migrations
Contains schema for all consumption tracking tables
"""
def get_schema():
"""
Returns the complete schema SQL for this module.
This is used when the module is installed.
"""
return """
-- cons_processes - Master list of consumption sheet process types
CREATE TABLE IF NOT EXISTS cons_processes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_key TEXT UNIQUE NOT NULL,
process_name TEXT NOT NULL,
template_file BLOB,
template_filename TEXT,
rows_per_page INTEGER DEFAULT 30,
detail_start_row INTEGER DEFAULT 10,
detail_end_row INTEGER,
page_height INTEGER,
print_start_col TEXT DEFAULT 'A',
print_end_col TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER NOT NULL,
is_active INTEGER DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES Users(user_id)
);
-- cons_process_fields - Custom field definitions for each process
CREATE TABLE IF NOT EXISTS cons_process_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')),
field_name TEXT NOT NULL,
field_label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')),
max_length INTEGER,
is_required INTEGER DEFAULT 0,
is_duplicate_key INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
excel_cell TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (process_id) REFERENCES cons_processes(id)
);
-- cons_sessions - Staff scanning sessions
CREATE TABLE IF NOT EXISTS cons_sessions (
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')),
FOREIGN KEY (process_id) REFERENCES cons_processes(id),
FOREIGN KEY (created_by) REFERENCES Users(user_id)
);
-- cons_session_header_values - Flexible storage for header field values
CREATE TABLE IF NOT EXISTS cons_session_header_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
field_id INTEGER NOT NULL,
field_value TEXT,
FOREIGN KEY (session_id) REFERENCES cons_sessions(id),
FOREIGN KEY (field_id) REFERENCES cons_process_fields(id)
);
-- 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_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_user ON cons_sessions(created_by, status);
"""
def get_migrations():
"""
Returns list of migrations specific to this module.
Format: [(version, name, up_function), ...]
"""
def migration_001_add_is_duplicate_key(conn):
"""Add is_duplicate_key column to cons_process_fields"""
cursor = conn.cursor()
# Check if column exists
cursor.execute('PRAGMA table_info(cons_process_fields)')
columns = [row[1] for row in cursor.fetchall()]
if 'is_duplicate_key' not in columns:
cursor.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0')
print(" Added is_duplicate_key column to cons_process_fields")
def migration_002_add_detail_end_row(conn):
"""Add detail_end_row column to cons_processes"""
cursor = conn.cursor()
cursor.execute('PRAGMA table_info(cons_processes)')
columns = [row[1] for row in cursor.fetchall()]
if 'detail_end_row' not in columns:
cursor.execute('ALTER TABLE cons_processes ADD COLUMN detail_end_row INTEGER')
print(" Added detail_end_row column to cons_processes")
def migration_003_add_page_height(conn):
"""Add page_height column to cons_processes"""
cursor = conn.cursor()
cursor.execute('PRAGMA table_info(cons_processes)')
columns = [row[1] for row in cursor.fetchall()]
if 'page_height' not in columns:
cursor.execute('ALTER TABLE cons_processes ADD COLUMN page_height INTEGER')
print(" Added page_height column to cons_processes")
def migration_004_add_print_columns(conn):
"""Add print_start_col and print_end_col to cons_processes"""
cursor = conn.cursor()
cursor.execute('PRAGMA table_info(cons_processes)')
columns = [row[1] for row in cursor.fetchall()]
if 'print_start_col' not in columns:
cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_start_col TEXT DEFAULT "A"')
print(" Added print_start_col column to cons_processes")
if 'print_end_col' not in columns:
cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT')
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 [
(1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key),
(2, 'add_detail_end_row', migration_002_add_detail_end_row),
(3, 'add_page_height', migration_003_add_page_height),
(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),
]

1522
modules/conssheets/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="dashboard-container">
<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
</a>
</div>
@@ -72,7 +72,7 @@
</div>
<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>
</div>
</form>

View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Consumption Sheets - Admin - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-header" style="margin-top: var(--space-lg);">
<div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Admin
</a>
<div>
<h1 class="page-title" style="margin-bottom: 0;{% if showing_archived %} color: var(--color-danger);{% endif %}">
{{ 'Archived Processes' if showing_archived else 'Consumption Sheets' }}
</h1>
<p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p>
{% if showing_archived %}
<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
</a>
{% else %}
<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
</a>
{% endif %}
</div>
</div>
<a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Process
</a>
</div>
{% if processes %}
<div class="sessions-grid">
{% for process in processes %}
<div class="session-card">
<div class="session-card-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h3 class="session-name">{{ process.process_name }}</h3>
<span class="session-type-badge">
{{ process.field_count or 0 }} fields
</span>
</div>
{% if showing_archived %}
<form method="POST"
action="{{ url_for('conssheets.restore_process', process_id=process.id) }}"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);">
<i class="fa-solid fa-trash-arrow-up"></i>
</button>
</form>
{% else %}
<form method="POST"
action="{{ url_for('conssheets.delete_process', process_id=process.id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Delete Process">
<i class="fa-solid fa-trash"></i>
</button>
</form>
{% endif %}
</div>
<div class="session-meta">
<div class="meta-item">
<span class="meta-label">Key:</span>
<span class="meta-value" style="font-family: var(--font-mono);">{{ process.process_key }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Created:</span>
<span class="meta-value">{{ process.created_at[:16] if process.created_at else 'N/A' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">By:</span>
<span class="meta-value">{{ process.created_by_name or 'Unknown' }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Template:</span>
<span class="meta-value">{{ '✅ Uploaded' if process.template_file else '❌ None' }}</span>
</div>
</div>
<div class="session-actions">
<a href="{{ url_for('conssheets.process_detail', process_id=process.id) }}" class="btn btn-secondary btn-block">
Configure
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h2 class="empty-title">No Processes Defined</h2>
<p class="empty-text">Create a process type to get started (e.g., "AD WIP")</p>
<a href="{{ url_for('conssheets.create_process') }}" class="btn btn-primary">
Create First Process
</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="dashboard-container">
<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
</a>
</div>
@@ -27,7 +27,7 @@
</div>
<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>
</div>
</form>

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="dashboard-container">
<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
</a>
</div>
@@ -71,7 +71,7 @@
</div>
<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>
</div>
</form>

View File

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

View File

@@ -5,16 +5,16 @@
{% block content %}
<div class="dashboard-container">
<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
</a>
</div>
<div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center;">New {{ process.process_name }} Session</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">Enter header information to begin scanning</p>
<h1 class="page-title" style="text-align: center;">{% if edit_mode %}Edit{% else %}New{% endif %} {{ process.process_name }} Session</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">{% if edit_mode %}Update header information{% else %}Enter header information to begin scanning{% endif %}</p>
<form method="POST" class="form-card">
<form method="POST" action="{% if edit_mode %}{{ url_for('conssheets.edit_session_header', session_id=session_id) }}{% endif %}" class="form-card">
{% for field in header_fields %}
<div class="form-group">
<label for="{{ field.field_name }}" class="form-label">
@@ -76,9 +76,9 @@
{% endif %}
<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 %}>
Start Scanning
{% if edit_mode %}Save Changes{% else %}Start Scanning{% endif %}
</button>
</div>
</form>

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="dashboard-container">
<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
</a>
</div>
@@ -38,7 +38,7 @@
</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
</a>
</div>
@@ -67,10 +67,29 @@
</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
</a>
</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>
<!-- Quick Field Preview -->

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="dashboard-container">
<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 }}
</a>
</div>
@@ -21,7 +21,7 @@
<div class="fields-section">
<div class="section-header">
<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
</a>
</div>
@@ -48,7 +48,7 @@
<td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</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)"
data-id="{{ field.id }}"
data-label="{{ field.field_label }}"
@@ -73,7 +73,7 @@
<div class="fields-section">
<div class="section-header">
<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
</a>
</div>
@@ -100,7 +100,7 @@
<td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</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)"
data-id="{{ field.id }}"
data-label="{{ field.field_label }}"
@@ -156,7 +156,7 @@ function confirmDelete(btn) {
const fieldLabel = btn.dataset.label;
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'
})
.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 %}
<div class="dashboard-container">
<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 }}
</a>
</div>
@@ -27,14 +27,14 @@
<div class="template-info">
<span class="template-icon">📄</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>
{% else %}
<p class="no-template">No template uploaded yet</p>
{% endif %}
</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">
<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>
@@ -48,18 +48,42 @@
<div class="config-section">
<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">
<label for="rows_per_page" class="form-label">Rows Per Page</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"
value="{{ process.rows_per_page or 30 }}" min="1" max="500" class="form-input">
<p class="form-hint">Max detail rows before starting a new page</p>
value="{{ process.rows_per_page or 30 }}" min="1" max="5000" class="form-input">
<p class="form-hint">How many items fit in the grid before we need a new page?</p>
</div>
<div class="form-group" style="flex: 1;">
<label for="print_start_col" class="form-label">Print Start Column</label>
<input type="text" id="print_start_col" name="print_start_col"
value="{{ process.print_start_col or 'A' }}" class="form-input"
placeholder="e.g. A" pattern="[A-Za-z]+" title="Letters only">
<p class="form-hint">First column to print.</p>
</div>
<div class="form-group" style="flex: 1;">
<label for="print_end_col" class="form-label">Print End Column</label>
<input type="text" id="print_end_col" name="print_end_col"
value="{{ process.print_end_col or '' }}" class="form-input"
placeholder="e.g. K" pattern="[A-Za-z]+" title="Letters only">
<p class="form-hint">Last column to print (defines width).</p>
</div>
<div class="form-group">
<label for="page_height" class="form-label">Page Height (Total Rows)</label>
<input type="number" id="page_height" name="page_height"
value="{{ process.page_height or '' }}" min="1" class="form-input">
<p class="form-hint">The exact distance (in Excel rows) from the top of Page 1 to the top of Page 2.</p>
</div>
<div class="form-group">
<label for="detail_start_row" class="form-label">Detail Start Row</label>
<input type="number" id="detail_start_row" name="detail_start_row"
value="{{ process.detail_start_row or 10 }}" min="1" max="500" class="form-input">
value="{{ process.detail_start_row or 10 }}" min="1" max="5000" class="form-input">
<p class="form-hint">Excel row number where detail data begins</p>
</div>
@@ -133,12 +157,12 @@
{% endif %}
</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
</a>
{% else %}
<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>
{% endif %}
</div>

View File

@@ -6,7 +6,13 @@
<div class="count-location-container">
<div class="location-header">
<div class="location-info">
<a href="{{ url_for('cons_sheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<div style="display: flex; justify-content: space-between; align-items: center;">
<a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<a href="{{ url_for('conssheets.edit_session_header', session_id=session.id) }}"
class="btn-edit-header" title="Edit header fields">
<i class="fa-solid fa-pencil"></i>
</a>
</div>
<div class="location-label">{{ session.process_name }}</div>
<div class="header-values">
{% for hv in header_values %}
@@ -26,20 +32,23 @@
</div>
{% endif %}
<div class="scan-card scan-card-active">
<div class="scan-header">
<h2 class="scan-title">Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}</h2>
<div class="scan-card" style="border: 2px solid var(--color-primary); margin-bottom: 20px;">
<div class="scan-header" >
<h2 class="scan-title" style="color: var(--color-primary);">🚀 Smart Router Scan</h2>
</div>
<form id="lotScanForm" class="scan-form">
<div class="scan-form">
<div class="scan-input-group">
<input type="text" name="dup_key_input" id="dupKeyInput" inputmode="none"
class="scan-input" placeholder="Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus
{% if not dup_key_field %}disabled{% endif %}>
<button type="submit" style="display: none;"></button>
<input type="text" id="smartScanner" class="scan-input"
placeholder="Scan Raw Barcode Here..."
autocomplete="off" autofocus>
<button type="button" id="btnSmartScan" class="btn btn-primary">Go</button>
</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 id="duplicateSameModal" class="modal">
<div class="modal-content modal-duplicate">
@@ -91,7 +100,7 @@
{% endfor %}
<div class="modal-actions">
<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>
</form>
</div>
@@ -101,15 +110,26 @@
<div class="scans-header">
<h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3>
</div>
<div style="margin-top: 10px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('importModal').style.display='flex'">
<i class="fa-solid fa-file-import"></i> Bulk Import Excel
</button>
</div>
<div class="scans-grid scan-header-row" style="--field-count: {{ detail_fields|length }};">
{% for field in detail_fields %}
<div class="cs-scan-row-cell scan-col-header">{{ field.field_label }}</div>
{% endfor %}
<div class="cs-scan-row-cell scan-col-header">Status</div>
</div>
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% for scan in scans %}
<div class="scan-row scan-row-{{ scan.duplicate_status }}"
<div class="cs-scan-row cs-scan-row-{{ scan.duplicate_status }}"
data-detail-id="{{ scan.id }}"
onclick="openScanDetail(this.dataset.detailId)">
{% for field in detail_fields %}
<div class="scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
<div class="cs-scan-row-cell">{% if field.field_type == 'REAL' %}{{ '%.1f'|format(scan[field.field_name]|float) if scan[field.field_name] else '-' }}{% else %}{{ scan[field.field_name] or '-' }}{% endif %}</div>
{% endfor %}
<div class="scan-row-status">
<div class="cs-scan-row-status">
{% if scan.duplicate_status == 'dup_same_session' %}<span class="status-dot status-dot-blue"></span> Dup
{% elif scan.duplicate_status == 'dup_other_session' %}<span class="status-dot status-dot-orange"></span> Warn
{% else %}<span class="status-dot status-dot-green"></span> OK{% endif %}
@@ -131,27 +151,46 @@
<div class="finish-section">
<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>
</div>
</div>
</div>
<style>
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.scan-row:hover { border-color: var(--color-primary); }
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
</style>
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header-bar">
<h3 class="modal-title">Bulk Import Data</h3>
<button type="button" class="btn-close-modal" onclick="document.getElementById('importModal').style.display='none'">&times;</button>
</div>
<div class="modal-body" style="text-align: center;">
<p style="color: var(--color-text-muted); margin-bottom: 20px;">
Upload an Excel file (.xlsx) to automatically populate this session.
<br><strong>Warning:</strong> This bypasses all validation checks.
</p>
<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>
<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
</a>
</div>
<form action="{{ url_for('conssheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 20px;">
<input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;">
</div>
<button type="submit" class="btn btn-primary btn-block">
<i class="fa-solid fa-upload"></i> Upload & Process
</button>
</form>
</div>
</div>
</div>
<!-- Styles moved to static/css/style.css -->
<script id="session-data" type="application/json">
{
@@ -168,11 +207,202 @@ const detailFields = sessionData.detailFields;
const dupKeyFieldName = sessionData.dupKeyFieldName;
const sessionId = sessionData.sessionId;
// --- SMART SCANNER LOGIC ---
const smartInput = document.getElementById('smartScanner');
const feedbackArea = document.getElementById('routerFeedback');
const feedbackText = document.getElementById('routerOutput');
// Handle Enter Key
smartInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
processSmartScan(this.value);
}
});
document.getElementById('btnSmartScan').addEventListener('click', function() {
processSmartScan(smartInput.value);
});
function processSmartScan(barcode, confirm = false) {
if (!barcode.trim()) return;
// LOCK UI to prevent double-scans
if(smartInput) smartInput.disabled = true;
if (!confirm) feedbackArea.style.display = 'none';
fetch(`/conssheets/session/${sessionId}/scan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
barcode: barcode,
confirm_duplicate: confirm
})
})
.then(response => response.json())
.then(data => {
if(smartInput) smartInput.disabled = false;
// --- 1. HANDLE DUPLICATE CONFIRMATION ---
if (data.needs_confirmation) {
playErrorBeep();
isSmartScan = true;
currentDupKeyValue = barcode;
if (data.duplicate_status === 'dup_same_session') {
// Try to fill display element, fallback to simple ID
let el = document.getElementById('dupSameLotNumber');
if(el) el.textContent = barcode;
document.getElementById('duplicateSameModal').style.display = 'flex';
} else {
let el = document.getElementById('dupOtherLotNumber');
if(el) el.textContent = barcode;
if(document.getElementById('dupOtherInfo')) document.getElementById('dupOtherInfo').textContent = data.message;
document.getElementById('duplicateOtherModal').style.display = 'flex';
}
// IMPORTANT: Refocus away from scanner to prevent phantom enters
document.activeElement.blur();
return;
}
// --- 2. HANDLE "OPEN DETAILS FORM" ---
if (data.needs_input) {
// CRITICAL FIX: Save the barcode globally so the 'Save' button can use it
currentDupKeyValue = barcode;
const parsedData = data.data;
// Populate fields
for (const [key, value] of Object.entries(parsedData)) {
let el = document.getElementById(key);
if (!el) el = document.querySelector(`[name="${key}"]`);
if (el) {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
el.value = value;
} else {
el.textContent = value;
}
}
let displayEl = document.getElementById(key + '_display') || document.getElementById('display_' + key);
if (displayEl) displayEl.textContent = value;
}
showFieldsModal();
smartInput.value = '';
return;
}
// --- 3. STANDARD SUCCESS/FAIL ---
if (data.success) {
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
feedbackArea.style.display = 'none';
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
} else {
playErrorBeep();
feedbackArea.style.display = 'block';
feedbackArea.style.background = 'rgba(220, 53, 69, 0.2)';
feedbackArea.style.border = '1px solid #dc3545';
feedbackText.style.color = '#dc3545';
feedbackText.textContent = data.message;
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
}
})
.catch(err => {
if(smartInput) smartInput.disabled = false;
console.error(err); // Log it, don't popup alert to annoy user
});
}
// Function to handle the "Save" click from the Details Modal
function saveSmartScanData() {
// 1. Validate we have the original barcode
if (!currentDupKeyValue) {
alert("Error: Original barcode lost. Please scan again.");
return;
}
// 2. Collect inputs using NAME ONLY (Fixes the database error)
const form = document.getElementById('fieldsForm');
if (!form) return console.error("Form not found!");
let formData = {};
// Grab all inputs, but ONLY use the 'name' attribute
form.querySelectorAll('input, select').forEach(el => {
if (el.name) {
formData[el.name] = el.value;
}
});
// 3. Send the payload
fetch(`/conssheets/session/${sessionId}/scan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
barcode: currentDupKeyValue, // The original scan (TEST10)
field_values: formData, // The Clean Data (weight: 164)
confirm_duplicate: true, // Bypass duplicate check
extra_data: formData
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('fieldsModal').style.display = 'none';
if (data.detail_id && data.data) {
addScanToList(data.detail_id, data.data, data.data.duplicate_status);
}
currentDupKeyValue = '';
smartInput.value = '';
smartInput.focus();
} else if (data.needs_input) {
alert("Error: Please fill in all required fields.");
} else {
alert("System Message: " + data.message);
}
})
.catch(err => console.error(err));
}
function resetSmartScan() {
document.getElementById('smartScanner').value = '';
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
}
// --- ERROR BEEP ---
function playErrorBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'square';
oscillator.frequency.setValueAtTime(520, ctx.currentTime);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
} catch(e) {
console.warn('Audio not available:', e);
}
}
// Standard variables
let currentDupKeyValue = '';
let currentDuplicateStatus = '';
let isDuplicateConfirmed = false;
let isProcessing = false;
let isSmartScan = false;
document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault();
@@ -188,7 +418,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
function checkDuplicate() {
const fieldValues = {};
fieldValues[dupKeyFieldName] = currentDupKeyValue;
fetch(`/cons-sheets/session/${sessionId}/scan`, {
fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, check_only: true })
@@ -216,13 +446,32 @@ function confirmDuplicate() {
isDuplicateConfirmed = true;
document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none';
// NEW LOGIC: If this was a Smart Scan, re-run it with confirmation
if (isSmartScan) {
processSmartScan(currentDupKeyValue, true);
isSmartScan = false; // Reset flag
} else {
// Old Logic (Manual Form)
showFieldsModal();
}
}
function cancelDuplicate() {
document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none';
// NEW LOGIC: Reset Smart Scanner if that's where we came from
if (isSmartScan) {
document.getElementById('smartScanner').value = '';
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
isSmartScan = false;
} else {
// Old Logic (Manual Form)
document.getElementById('dupKeyInput').focus();
}
isDuplicateConfirmed = false;
isProcessing = false;
currentDuplicateStatus = '';
@@ -262,7 +511,7 @@ function submitScan() {
if (input) fieldValues[field.field_name] = input.value;
}
});
fetch(`/cons-sheets/session/${sessionId}/scan`, {
fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed })
@@ -275,8 +524,8 @@ function submitScan() {
data.updated_entry_ids.forEach(id => {
const row = document.querySelector(`[data-detail-id="${id}"]`);
if (row) {
row.className = 'scan-row scan-row-dup_same_session';
row.querySelector('.scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup';
row.className = 'cs-scan-row cs-scan-row-dup_same_session';
row.querySelector('.cs-scan-row-status').innerHTML = '<span class="status-dot status-dot-blue"></span> Dup';
}
});
}
@@ -301,16 +550,16 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
if (duplicateStatus === 'dup_same_session') { statusDot = 'blue'; statusText = 'Dup'; }
else if (duplicateStatus === 'dup_other_session') { statusDot = 'orange'; statusText = 'Warn'; }
const scanRow = document.createElement('div');
scanRow.className = 'scan-row scan-row-' + statusClass;
scanRow.className = 'cs-scan-row cs-scan-row-' + statusClass;
scanRow.setAttribute('data-detail-id', detailId);
scanRow.onclick = function() { openScanDetail(detailId); };
let cellsHtml = '';
detailFields.forEach(field => {
let value = fieldValues[field.field_name] || '-';
if (field.field_type === 'REAL' && value !== '-') value = parseFloat(value).toFixed(1);
cellsHtml += `<div class="scan-row-cell">${value}</div>`;
cellsHtml += `<div class="cs-scan-row-cell">${value}</div>`;
});
cellsHtml += `<div class="scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`;
cellsHtml += `<div class="cs-scan-row-status"><span class="status-dot status-dot-${statusDot}"></span> ${statusText}</div>`;
scanRow.innerHTML = cellsHtml;
scansList.insertBefore(scanRow, scansList.firstChild);
const countSpan = document.getElementById('scanListCount');
@@ -321,7 +570,7 @@ function addScanToList(detailId, fieldValues, duplicateStatus) {
}
function openScanDetail(detailId) {
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}`)
fetch(`/conssheets/session/${sessionId}/detail/${detailId}`)
.then(r => r.json())
.then(data => {
if (data.success) displayScanDetail(data.detail);
@@ -375,7 +624,7 @@ function saveDetail(detailId) {
if (input) fieldValues[field.field_name] = input.value;
});
const comment = document.getElementById('editComment').value;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/update`, {
fetch(`/conssheets/session/${sessionId}/detail/${detailId}/update`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, comment: comment })
@@ -389,7 +638,7 @@ function saveDetail(detailId) {
function deleteDetail(detailId) {
if (!confirm('Delete this scan?')) return;
fetch(`/cons-sheets/session/${sessionId}/detail/${detailId}/delete`, {
fetch(`/conssheets/session/${sessionId}/detail/${detailId}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
@@ -401,7 +650,7 @@ function deleteDetail(detailId) {
}
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) {

View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}Consumption Sheets - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="page-header">
<div>
<a href="{{ url_for('home') }}" class="breadcrumb">← Back to Home</a>
<h1 class="page-title">Consumption Sheets</h1>
<p class="page-subtitle">Scan and record lot consumption</p>
</div>
</div>
<!-- New Session Button -->
<div class="new-session-section">
<h2 class="section-title">Start New Session</h2>
<div class="process-buttons">
{% for p in processes %}
<a href="{{ url_for('conssheets.new_session', process_id=p.id) }}" class="btn btn-primary">
<span class="btn-icon">+</span> {{ p.process_name }}
</a>
{% endfor %}
{% if not processes %}
<p class="empty-text">No process types configured yet. Contact your administrator.</p>
{% endif %}
</div>
</div>
<!-- Active Sessions -->
{% if sessions %}
<div class="section-card">
<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">
{% for s in sessions %}
<div class="session-list-item-container session-status-{{ s.status }}">
<a href="{{ url_for('conssheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.session_title }}</h3>
<div class="session-list-meta">
<span>Started: {{ s.created_at[:16] }}</span>
<span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span>
</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 class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
{% if s.status == 'active' %}
<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>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Start a new session by selecting a process type above</p>
</div>
{% endif %}
</div>
<!-- Styles moved to static/css/style.css for maintainability -->
<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) {
if (!confirm(`Archive this ${processName} session?\n\nYou can restore it later.`)) {
return;
}
fetch(`/conssheets/session/${sessionId}/archive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error archiving session');
}
});
}
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>
{% endblock %}

View File

@@ -0,0 +1,20 @@
"""
Inventory Counts Module
Handles cycle counting and physical inventory workflows
"""
from flask import Blueprint
def create_blueprint():
"""Create and return the invcount blueprint"""
bp = Blueprint(
'invcount',
__name__,
template_folder='templates',
url_prefix='/invcount'
)
# Import and register routes
from .routes import register_routes
register_routes(bp)
return bp

View File

@@ -0,0 +1,12 @@
{
"module_key": "invcount",
"name": "Inventory Counts",
"version": "1.0.0",
"author": "STUFF",
"description": "Cycle Counts and Physical Inventory",
"icon": "fa-clipboard-check",
"requires_roles": ["owner", "admin", "staff"],
"routes_prefix": "/invcount",
"has_migrations": true,
"dependencies": []
}

View File

@@ -0,0 +1,158 @@
"""
Inventory Counts Module - Database Migrations
Contains schema for all inventory counting tables
"""
def get_schema():
"""
Returns the complete schema SQL for this module.
This is used when the module is installed.
"""
return """
-- CountSessions Table
CREATE TABLE IF NOT EXISTS CountSessions (
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')),
created_by INTEGER NOT NULL,
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_baseline_timestamp DATETIME,
current_baseline_timestamp DATETIME,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
branch TEXT DEFAULT 'Main',
FOREIGN KEY (created_by) REFERENCES Users(user_id)
);
-- BaselineInventory_Master Table (Session-specific, immutable)
CREATE TABLE IF NOT EXISTS BaselineInventory_Master (
baseline_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT NOT NULL,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
uploaded_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id)
);
-- BaselineInventory_Current Table (GLOBAL - shared across all sessions)
CREATE TABLE IF NOT EXISTS BaselineInventory_Current (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
);
-- LocationCounts Table
CREATE TABLE IF NOT EXISTS LocationCounts (
location_count_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_name TEXT NOT NULL,
counted_by INTEGER NOT NULL,
start_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
end_timestamp DATETIME,
status TEXT DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed')),
expected_lots_master INTEGER DEFAULT 0,
lots_found INTEGER DEFAULT 0,
lots_missing INTEGER DEFAULT 0,
is_deleted INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (counted_by) REFERENCES Users(user_id)
);
-- ScanEntries Table
CREATE TABLE IF NOT EXISTS ScanEntries (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT,
description TEXT,
scanned_location TEXT NOT NULL,
actual_weight REAL NOT NULL,
scanned_by INTEGER NOT NULL,
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
-- MASTER baseline comparison
master_status TEXT CHECK(master_status IN ('match', 'wrong_location', 'ghost_lot', 'missing')),
master_expected_location TEXT,
master_expected_weight REAL,
master_variance_lbs REAL,
master_variance_pct REAL,
-- Duplicate detection
duplicate_status TEXT DEFAULT '00' CHECK(duplicate_status IN ('00', '01', '03', '04')),
duplicate_info TEXT,
-- Metadata
comment TEXT,
is_deleted INTEGER DEFAULT 0,
deleted_by INTEGER,
deleted_timestamp DATETIME,
modified_timestamp DATETIME,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (scanned_by) REFERENCES Users(user_id),
FOREIGN KEY (deleted_by) REFERENCES Users(user_id)
);
-- MissingLots Table
CREATE TABLE IF NOT EXISTS MissingLots (
missing_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER,
lot_number TEXT NOT NULL,
item TEXT,
master_expected_location TEXT NOT NULL,
master_expected_quantity REAL NOT NULL,
current_system_location TEXT,
current_system_quantity REAL,
marked_by INTEGER NOT NULL,
marked_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
found_later TEXT DEFAULT 'N' CHECK(found_later IN ('Y', 'N')),
found_location TEXT,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (marked_by) REFERENCES Users(user_id)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_baseline_master_lot ON BaselineInventory_Master(session_id, lot_number);
CREATE INDEX IF NOT EXISTS idx_baseline_master_loc ON BaselineInventory_Master(session_id, system_location);
CREATE INDEX IF NOT EXISTS idx_scanentries_session ON ScanEntries(session_id);
CREATE INDEX IF NOT EXISTS idx_scanentries_location ON ScanEntries(location_count_id);
CREATE INDEX IF NOT EXISTS idx_scanentries_lot ON ScanEntries(lot_number);
CREATE INDEX IF NOT EXISTS idx_scanentries_deleted ON ScanEntries(is_deleted);
CREATE INDEX IF NOT EXISTS idx_location_counts ON LocationCounts(session_id, status);
"""
def get_migrations():
"""
Returns list of migrations specific to this module.
Format: [(version, name, up_function), ...]
"""
def migration_001_add_is_deleted_to_locationcounts(conn):
"""Add is_deleted column to LocationCounts table"""
cursor = conn.cursor()
# Check if column exists
cursor.execute('PRAGMA table_info(LocationCounts)')
columns = [row[1] for row in cursor.fetchall()]
if 'is_deleted' not in columns:
cursor.execute('ALTER TABLE LocationCounts ADD COLUMN is_deleted INTEGER DEFAULT 0')
print(" Added is_deleted column to LocationCounts")
return [
(1, 'add_is_deleted_to_locationcounts', migration_001_add_is_deleted_to_locationcounts),
]

1391
modules/invcount/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
<span class="filter-label">Show Archived</span>
</label>
<a href="{{ url_for('sessions.create_session') }}" class="btn btn-primary">
<a href="{{ url_for('invcount.create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session
</a>
</div>
@@ -66,7 +66,7 @@
</div>
<div class="session-actions">
<a href="{{ url_for('sessions.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
<a href="{{ url_for('invcount.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
View Details
</a>
</div>
@@ -85,7 +85,7 @@
<script>
function toggleArchived() {
const checked = document.getElementById('showArchived').checked;
window.location.href = '{{ url_for("counting.admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
window.location.href = '{{ url_for("invcount.admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
}
</script>

View File

@@ -160,12 +160,10 @@
<div class="finish-section">
<div class="action-buttons-row">
<a href="{{ url_for('counting.my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
<a href="{{ url_for('invcount.my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
← Back to My Counts
</a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
✓ Finish Location
</button>
{# Finish button moved to Admin Dashboard #}
</div>
</div>
<button class="scroll-to-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
@@ -218,7 +216,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
});
function checkDuplicate() {
fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
fetch('{{ url_for("invcount.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@@ -287,7 +285,7 @@ function submitScan(weight) {
return;
}
fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
fetch('{{ url_for("invcount.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
@@ -587,7 +585,7 @@ function deleteFromDetail(entryId) {
function finishLocation() {
if (!confirm('Are you finished counting this location?')) return;
fetch('{{ url_for("counting.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
fetch('{{ url_for("invcount.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})

View File

@@ -6,7 +6,7 @@
<div class="dashboard-container">
<div class="page-header">
<div>
<a href="{{ url_for('counting.index') }}" class="breadcrumb">← Back to Sessions</a>
<a href="{{ url_for('invcount.index') }}" class="breadcrumb">← Back to Sessions</a>
<h1 class="page-title">My Active Counts</h1>
<p class="page-subtitle">{{ count_session.session_name }}</p>
{% if not count_session.master_baseline_timestamp %}
@@ -46,17 +46,9 @@
</div>
</div>
<div class="bin-actions">
<a href="{{ url_for('counting.count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
<a href="{{ url_for('invcount.count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
Resume Counting
</a>
<div class="bin-actions-row">
<button class="btn btn-secondary" onclick="markComplete('{{ bin.location_count_id }}')">
✓ Mark Complete
</button>
<button class="btn btn-danger" onclick="deleteBinCount('{{ bin.location_count_id }}', '{{ bin.location_name }}')">
🗑️ Delete
</button>
</div>
</div>
</div>
{% endfor %}
@@ -114,7 +106,7 @@
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button>
</div>
<form id="startBinForm" action="{{ url_for('counting.start_bin_count', session_id=count_session.session_id) }}" method="POST">
<form id="startBinForm" action="{{ url_for('invcount.start_bin_count', session_id=count_session.session_id) }}" method="POST">
<div class="form-group">
<label class="form-label">Bin Number *</label>
<input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number">

View File

@@ -0,0 +1,12 @@
<div class="counter-list">
{% for counter in active_counters %}
<div class="counter-item">
<div class="counter-avatar">{{ counter.full_name[0] }}</div>
<div class="counter-info">
<div class="counter-name">{{ counter.full_name }}</div>
<div class="counter-location">📍 {{ counter.location_name }}</div>
</div>
<div class="counter-time">{{ counter.start_timestamp[11:16] }}</div>
</div>
{% endfor %}
</div>

View File

@@ -41,7 +41,7 @@
</div>
{% if not count_session.master_baseline_timestamp %}
<!-- Note: Using data_imports blueprint URL -->
<form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<form method="POST" action="{{ url_for('invcount.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button>
</form>
@@ -59,7 +59,7 @@
{% endif %}
</div>
{% if count_session.master_baseline_timestamp %}
<form method="POST" action="{{ url_for('data_imports.upload_current', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<form method="POST" action="{{ url_for('invcount.upload_current', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="baseline_type" value="current">
<input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-secondary btn-sm">
@@ -72,42 +72,42 @@
</div>
<!-- Statistics Section -->
<div class="section-card">
<div class="section-card">
<h2 class="section-title">Real-Time Statistics</h2>
<div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match')">
<div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-number" id="count-matched">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div>
</div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates')">
<div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-number" id="count-duplicates">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div>
</div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy')">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-number" id="count-discrepancy">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div>
</div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location')">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-number" id="count-wrong">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div>
</div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot')">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-number" id="count-ghost">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div>
</div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing')">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-number" id="count-missing">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div>
</div>
</div>
</div>
</div>
<!-- Active Counters Section -->
{% if active_counters %}
<div class="section-card">
<h2 class="section-title">Active Counters</h2>
<div class="counter-list">
<div id="active-counters-container"> <div class="counter-list">
{% for counter in active_counters %}
<div class="counter-item">
<div class="counter-avatar">{{ counter.full_name[0] }}</div>
@@ -125,7 +125,12 @@
<!-- Location Progress Section -->
{% if locations %}
<div class="section-card">
<h2 class="section-title">Location Progress</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md);">
<h2 class="section-title" style="margin-bottom: 0;">Location Progress</h2>
<button class="btn btn-danger btn-sm" onclick="showFinalizeAllConfirm()">
⚠️ Finalize All Bins
</button>
</div>
<div class="location-table">
<table>
<thead>
@@ -157,7 +162,7 @@
<td>{{ loc.counter_name or '-' }}</td>
<td>{{ loc.expected_lots_master }}</td>
<td>{{ loc.lots_found }}</td>
<td>{{ loc.lots_missing }}</td>
<td>{{ loc.lots_missing_calc }}</td>
</tr>
{% endfor %}
</tbody>
@@ -198,6 +203,9 @@
<button id="reopenLocationBtn" class="btn btn-warning btn-sm" style="display: none;" onclick="showReopenConfirm()">
🔓 Reopen Location
</button>
<button id="deleteLocationBtn" class="btn btn-danger btn-sm" style="display: none;" onclick="showDeleteBinConfirm()">
🗑️ Delete Bin
</button>
<button class="btn btn-secondary btn-sm" onclick="exportLocationToCSV()">
📥 Export CSV
</button>
@@ -281,7 +289,7 @@ function showStatusDetails(status) {
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details using the blueprint URL structure
fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
fetch(`/invcount/session/${CURRENT_SESSION_ID}/status-details/${status}`)
.then(response => response.json())
.then(data => {
if (data.success) {
@@ -460,6 +468,9 @@ function showLocationDetails(locationCountId, locationName, status) {
// Show finalize or reopen button based on status
const finalizeBtn = document.getElementById('finalizeLocationBtn');
const reopenBtn = document.getElementById('reopenLocationBtn');
const deleteBtn = document.getElementById('deleteLocationBtn'); // ADD THIS LINE
deleteBtn.style.display = 'block';
if (status === 'in_progress') {
finalizeBtn.style.display = 'block';
@@ -473,7 +484,7 @@ function showLocationDetails(locationCountId, locationName, status) {
}
// Fetch all scans for this location
fetch(`/location/${locationCountId}/scans`)
fetch(`/invcount/location/${locationCountId}/scans`)
.then(response => response.json())
.then(data => {
if (data.success) {
@@ -621,8 +632,8 @@ function closeFinalizeConfirm() {
}
function confirmFinalize() {
// Note: The /complete endpoint is handled by blueprints/counting.py
fetch(`/location/${currentLocationId}/complete`, {
// Correctly points to the /finish route to trigger Missing Lot calculations
fetch(`/invcount/count/${CURRENT_SESSION_ID}/location/${currentLocationId}/finish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -633,16 +644,18 @@ function confirmFinalize() {
if (data.success) {
closeFinalizeConfirm();
closeLocationModal();
location.reload(); // Reload to show updated status
location.reload(); // Reload to show updated status and Missing counts
} else {
alert(data.message || 'Error finalizing location');
}
})
.catch(error => {
console.error('Finalize Error:', error);
alert('Error: ' + error.message);
});
}
function showReopenConfirm() {
document.getElementById('reopenBinName').textContent = currentLocationName;
document.getElementById('reopenConfirmModal').style.display = 'flex';
@@ -654,7 +667,7 @@ function closeReopenConfirm() {
function confirmReopen() {
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
fetch(`/location/${currentLocationId}/reopen`, {
fetch(`/invcount/location/${currentLocationId}/reopen`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -687,7 +700,7 @@ document.addEventListener('keydown', function(e) {
function archiveSession() {
if (!confirm('Archive this session? It will be hidden from the main dashboard but can be reactivated later.')) return;
fetch('{{ url_for("sessions.archive_session", session_id=count_session.session_id) }}', {
fetch('{{ url_for("invcount.archive_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
@@ -704,7 +717,7 @@ function archiveSession() {
function activateSession() {
if (!confirm('Reactivate this session? It will appear on the main dashboard again.')) return;
fetch('{{ url_for("sessions.activate_session", session_id=count_session.session_id) }}', {
fetch('{{ url_for("invcount.activate_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
@@ -717,5 +730,78 @@ function activateSession() {
}
});
}
function showFinalizeAllConfirm() {
if (confirm("⚠️ WARNING: This will finalize ALL open bins in this session and calculate missing items. This cannot be undone. Are you sure?")) {
fetch(`/invcount/session/${CURRENT_SESSION_ID}/finalize-all`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert(data.message);
location.reload();
} else {
alert("Error: " + data.message);
}
});
}
}
function showDeleteBinConfirm() {
if (confirm(`⚠️ DANGER: Are you sure you want to delete ALL data for ${currentLocationName}? This will hide the bin from staff and wipe any missing lot flags.`)) {
fetch(`/invcount/location/${currentLocationId}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeLocationModal();
location.reload();
} else {
alert(data.message || 'Error deleting bin');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
}
function refreshDashboardStats() {
const sessionId = CURRENT_SESSION_ID;
fetch(`/invcount/session/${sessionId}/get_stats`)
.then(response => response.json())
.then(data => {
if (data.success) {
const s = data.stats;
// These IDs must match your HTML and the keys must match sessions.py
if (document.getElementById('count-matched')) document.getElementById('count-matched').innerText = s.matched;
if (document.getElementById('count-duplicates')) document.getElementById('count-duplicates').innerText = s.duplicates;
if (document.getElementById('count-discrepancy')) document.getElementById('count-discrepancy').innerText = s.discrepancy;
if (document.getElementById('count-wrong')) document.getElementById('count-wrong').innerText = s.wrong_location; // Fixed
if (document.getElementById('count-ghost')) document.getElementById('count-ghost').innerText = s.ghost_lots; // Fixed
if (document.getElementById('count-missing')) document.getElementById('count-missing').innerText = s.missing;
}
})
.catch(err => console.error('Error refreshing stats:', err));
fetch(`/invcount/session/${sessionId}/active-counters-fragment`)
.then(response => response.text())
.then(html => {
const container = document.getElementById('active-counters-container');
if (container) container.innerHTML = html;
})
.catch(err => console.error('Error refreshing counters:', err));
}
// This tells the browser: "Run the refresh function every 30 seconds"
setInterval(refreshDashboardStats, 30000);
// This runs it IMMEDIATELY once so you don't wait 30 seconds for the first update
refreshDashboardStats();
</script>
{% endblock %}

View File

@@ -26,7 +26,7 @@
{% if sessions %}
<div class="sessions-list">
{% for s in sessions %}
<a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
<a href="{{ url_for('invcount.count_session', session_id=s.session_id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>

View File

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

View File

@@ -707,6 +707,138 @@ body {
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 {
@@ -1113,6 +1245,23 @@ body {
font-size: 1.5rem;
}
/* ==================== SCAN SESSION ==================== */
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; align-items: center; flex-direction: row; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.btn-edit-header { background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); color: var(--color-text-muted); cursor: pointer; font-size: 0.75rem; text-decoration: none; transition: var(--transition); display: inline-flex; align-items: center; }
.btn-edit-header:hover { border-color: var(--color-primary); color: var(--color-primary); }
.cs-scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.cs-scan-row:hover { border-color: var(--color-primary); }
.cs-scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.cs-scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.cs-scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
/* ==================== LOCATION COUNTING ==================== */
.count-location-container {
@@ -1130,7 +1279,7 @@ body {
}
.location-label {
font-size: 0.875rem;
font-size: 1.5rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-text-muted);
@@ -1303,9 +1452,25 @@ body {
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 {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr;
grid-template-columns: repeat(var(--field-count), 1fr) 1fr;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg);
@@ -2283,3 +2448,22 @@ body {
background: var(--color-primary);
color: var(--color-bg);
}
/* ==================== ICON BUTTONS ==================== */
.btn-icon-only {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 8px;
transition: var(--transition);
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon-only:hover {
color: var(--color-danger);
transform: scale(1.1);
}

View File

@@ -4,28 +4,41 @@
{% block content %}
<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);">
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-arrow-left"></i> Back to Home
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm" title="Back to Home">
<i class="fa-solid fa-house"></i>
</a>
<h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1>
</div>
</div>
<div class="modules-section">
<h2 class="section-title">Modules</h2>
<div class="modules-grid">
<a href="{{ url_for('counting.admin_dashboard') }}" class="module-card">
<div class="module-icon">📊</div> <h3 class="module-name">Counts</h3>
<p class="module-desc">Cycle counts & physical inventory</p>
</a>
<a href="{{ url_for('cons_sheets.admin_processes') }}" class="module-card module-card-link">
<div class="module-icon">📝</div> <h3 class="module-name">Consumption Sheets</h3>
<p class="module-desc">Production consumption tracking</p>
</a>
{% if modules %}
<div class="modules-grid">
{% for module in modules %}
<a href="/{{ module.module_key }}/admin" class="module-card module-card-link">
<div class="module-icon">
{% if module.icon %}
<i class="fa-solid {{ module.icon }}"></i>
{% else %}
📦
{% endif %}
</div>
<h3 class="module-name">{{ module.module_name }}</h3>
<p class="module-desc">{{ module.description }}</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<i class="fa-solid fa-info-circle"></i> No modules installed yet.
<a href="{{ url_for('module_manager_ui') }}">Install modules</a> to get started.
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -29,8 +29,14 @@
<div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<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">
<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>
</div>
</div>

View File

@@ -11,12 +11,25 @@
</a>
<div>
<h1 class="page-title" style="margin-bottom: 0;">Consumption Sheets</h1>
<p class="page-subtitle" style="margin-bottom: 0;">Manage process types and templates</p>
<h1 class="page-title" style="margin-bottom: 0; {{ 'color: var(--color-danger);' if showing_archived else '' }}">
{{ 'Archived Processes' if showing_archived else 'Consumption Sheets' }}
</h1>
<p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p>
{% if showing_archived %}
<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
</a>
{% else %}
<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
</a>
{% endif %}
</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
</a>
</div>
@@ -25,13 +38,34 @@
<div class="sessions-grid">
{% for process in processes %}
<div class="session-card">
<div class="session-card-header">
<div class="session-card-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h3 class="session-name">{{ process.process_name }}</h3>
<span class="session-type-badge">
{{ process.field_count or 0 }} fields
</span>
</div>
{% if showing_archived %}
<form method="POST"
action="{{ url_for('conssheets.restore_process', process_id=process.id) }}"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);">
<i class="fa-solid fa-trash-arrow-up"></i>
</button>
</form>
{% else %}
<form method="POST"
action="{{ url_for('conssheets.delete_process', process_id=process.id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Delete Process">
<i class="fa-solid fa-trash"></i>
</button>
</form>
{% endif %}
</div>
<div class="session-meta">
<div class="meta-item">
<span class="meta-label">Key:</span>
@@ -52,11 +86,13 @@
</div>
<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
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
@@ -64,7 +100,7 @@
<div class="empty-icon">📝</div>
<h2 class="empty-title">No Processes Defined</h2>
<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
</a>
</div>

View File

@@ -1,166 +0,0 @@
{% extends "base.html" %}
{% block title %}Consumption Sheets - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="page-header">
<div>
<a href="{{ url_for('home') }}" class="breadcrumb">← Back to Home</a>
<h1 class="page-title">Consumption Sheets</h1>
<p class="page-subtitle">Scan and record lot consumption</p>
</div>
</div>
<!-- New Session Button -->
<div class="new-session-section">
<h2 class="section-title">Start New Session</h2>
<div class="process-buttons">
{% for p in processes %}
<a href="{{ url_for('cons_sheets.new_session', process_id=p.id) }}" class="btn btn-primary">
<span class="btn-icon">+</span> {{ p.process_name }}
</a>
{% endfor %}
{% if not processes %}
<p class="empty-text">No process types configured yet. Contact your administrator.</p>
{% endif %}
</div>
</div>
<!-- Active Sessions -->
{% if sessions %}
<div class="section-card">
<h2 class="section-title">📋 My Active Sessions</h2>
<div class="sessions-list">
{% for s in sessions %}
<div class="session-list-item-container">
<a href="{{ url_for('cons_sheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3>
<div class="session-list-meta">
<span>Started: {{ s.created_at[:16] }}</span>
<span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span>
</div>
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
<button class="btn-archive" onclick="archiveSession(this)" data-id="{{ s.id }}" data-name="{{ s.process_name }}" title="Archive this session">
🗑️
</button>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Start a new session by selecting a process type above</p>
</div>
{% endif %}
</div>
<style>
.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>
function archiveSession(sessionId, processName) {
if (!confirm(`Archive this ${processName} session?\n\nYou can still view it later from the admin panel.`)) {
return;
}
fetch(`/cons-sheets/session/${sessionId}/archive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error archiving session');
}
});
}
</script>
{% endblock %}

View File

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

View File

@@ -0,0 +1,866 @@
{% extends "base.html" %}
{% block title %}Module Manager - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h1 class="page-title"><i class="fas fa-puzzle-piece"></i> Module Manager</h1>
<p class="page-subtitle">Install, manage, and configure ScanLook modules</p>
</div>
<button class="btn btn-primary" onclick="openUploadModal()" style="margin-top: 10px;">
<i class="fas fa-upload"></i> Upload
</button>
</div>
{% if modules %}
<div class="module-grid">
{% for module in modules %}
<div class="module-card {% if module.is_active %}module-card-active{% endif %}">
<!-- Module Icon -->
<div class="module-icon">
{% if module.icon %}
<i class="fa-solid {{ module.icon }}"></i>
{% else %}
<i class="fa-solid fa-cube"></i>
{% endif %}
</div>
<!-- Module Info -->
<h3 class="module-name">{{ module.name }}</h3>
<p class="module-desc">{{ module.description }}</p>
<!-- Module Metadata -->
<div class="module-metadata">
<div class="metadata-row">
<span class="metadata-label">Version:</span>
<span class="metadata-value">{{ module.version }}</span>
</div>
<div class="metadata-row">
<span class="metadata-label">Author:</span>
<span class="metadata-value">{{ module.author }}</span>
</div>
</div>
<!-- Status Badge -->
<div class="module-status">
{% if module.is_installed and module.is_active %}
<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>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-puzzle-piece"></i></div>
<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>
{% endif %}
</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>
// Keep all your existing JavaScript functions exactly as they are
function installModule(moduleKey) {
if (!confirm(`Install module "${moduleKey}"?\n\nThis will create database tables and activate the module.`)) {
return;
}
fetch(`/admin/modules/${moduleKey}/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.restart_required) {
alert(`${data.message}\n\nServer will restart automatically...`);
restartServerSilent();
} else {
alert(`${data.message}`);
location.reload();
}
} else {
alert(`${data.message}`);
}
})
.catch(error => {
alert(`❌ Error: ${error}`);
});
}
function restartServerSilent() {
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;
}
document.getElementById('uninstall-modal-stage3').remove();
fetch(`/admin/modules/${moduleKey}/uninstall`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`${data.message}\n\nAll data has been permanently deleted.\n\nPlease reload the page.`);
location.reload();
} else {
alert(`${data.message}`);
}
})
.catch(error => {
alert(`❌ Error: ${error}`);
});
}
function activateModule(moduleKey) {
fetch(`/admin/modules/${moduleKey}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`${data.message}\n\nPlease reload the page.`);
location.reload();
} else {
alert(`${data.message}`);
}
})
.catch(error => {
alert(`❌ Error: ${error}`);
});
}
function deactivateModule(moduleKey) {
if (!confirm(`Deactivate module "${moduleKey}"?\n\nUsers will lose access until reactivated.`)) {
return;
}
fetch(`/admin/modules/${moduleKey}/deactivate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`${data.message}\n\nPlease reload the page.`);
location.reload();
} else {
alert(`${data.message}`);
}
})
.catch(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>
<!-- 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 %}

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>