38 Commits

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

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

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

This enables easy distribution and installation of new modules
without code changes to core application.
2026-02-08 00:54:12 -06:00
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
Javier
a6d8767c28 Refactor Admin Dashboard and organize Counts module 2026-01-30 10:30:17 -06:00
Javier
d6e9f20757 Update: Fixed some vscode "Problems" that weren't really problems but it was complaining about jinja code. 2026-01-30 00:39:26 -06:00
Javier
674a8f8a0c updated ai prompt 2026-01-30 00:19:54 -06:00
Javier
5097ceb82f v0.13.0 - Add Consumption Sheets module and database migration system 2026-01-29 22:12:55 -06:00
Javier
3ec00613ca Updated Requirements 2026-01-29 12:40:25 -06:00
Javier
d955a13f3d v0.13.0 - Add Consumption Sheets module
New module for tracking production consumption with lot scanning:
- Admin configuration for process types (AD WIP, etc.)
- Dynamic table creation per process
- Flexible header/detail field definitions with Excel cell mapping
- Duplicate detection with configurable key field
- Staff scanning interface with duplicate warnings (same session/cross session)
- Excel export using uploaded templates with multi-page support
- Template settings for rows per page and detail start row
2026-01-29 12:33:34 -06:00
Javier
b11421a8f5 Completed:
 Admin: Process creation, field configuration, template upload
 Staff: Session list, new session (header form), scanning interface
 Duplicate detection (same session = blue, other session = orange)
 Weight entry popup, edit/delete scans
2026-01-28 12:53:59 -06:00
Javier
ac73045ef2 v0.12.1 - Updated the Readme.md file 2026-01-26 11:49:27 -06:00
Javier
21671d6bee v0.12.0 - Add modular system architecture with user-based module access
- Add Modules and UserModules database tables
- Create home page with module selection grid
- Implement per-user module assignment in user management
- Add route guards for module access control
- Refactor navigation: login -> home -> modules, admin console via button
- Add Font Awesome icons
2026-01-26 11:35:29 -06:00
Javier
cbd7e535e6 v0.11.3 - Forgot to change version number in app.py, fixed. 2026-01-25 21:18:25 -06:00
Javier
38212653f4 v0.11.2 - Added code to initialize DB if it's not there. 2026-01-25 21:17:18 -06:00
Javier
136fe03e07 v0.11.1 - Fixed weight Discrepancy bug 2026-01-25 20:40:51 -06:00
Javier
ff577a6cbf v0.11.0 - Added a block so no bins can be counted until a master file has been uploaded. 2026-01-25 20:17:37 -06:00
Javier
f7e25c8d1f v0.10.0.1 - Removing db from Git 2026-01-25 02:32:50 -06:00
Javier
672591c736 v0.10.0 - Add session archive/activate feature with access controls 2026-01-25 02:23:18 -06:00
Javier
ca368cbbfb v0.9.0.1 - fixed dockerfile 2026-01-24 18:23:29 -06:00
Javier
360d440217 v0.9.0 - Creating Docker 2026-01-24 18:19:37 -06:00
Javier
1a5168d155 v0.9.0 - CSS refactor: split into device files, scanner UI improvements, scroll buttons 2026-01-24 16:01:40 -06:00
Javier
53158e76e4 V0.8.6 - Frefractor: Counting.py 2026-01-23 11:45:35 -06:00
Javier
6789f0899a V1.0.0.5 - Refactor app.py: Split User, Session, and Admin logic into blueprints 2026-01-23 09:14:20 -06:00
Javier
2f705b1e22 V1.0.0.4 - Refactor: Sessions.py 2026-01-23 01:35:11 -06:00
Javier
518c9478dc V1.0.0.2 - Refactor: Moved user Management out of App.py and into users.py 2026-01-23 01:06:30 -06:00
60 changed files with 12214 additions and 1889 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
*.pyc
venv
.git
*.db
```

110
AI Prompt.txt Normal file
View File

@@ -0,0 +1,110 @@
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**.
## Scanlook (current product summary)
Scanlook is a modular inventory management platform for warehouse operations.
Long-term goal: evolve into a full WMS, but right now focus on making workflows reliable and the module system robust.
## Operating rules (must follow)
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.** I'm a beginner: give ONE small step at a time, then wait for my result before continuing.
4) **No command dumps.** Don't give long chains of commands. One command (or tiny set) per step.
5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
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 with Gunicorn on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
10) **Database changes:** Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
## How you should respond
- Ask for the minimum needed info (36 questions max), then propose the next single step.
- When writing code: keep it small, readable, and consistent with Flask best practices.
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
## Scanlook Architecture
**Current Version:** 0.17.1
**Tech Stack:**
- Backend: Python 3.13, Flask, Gunicorn (production WSGI server)
- 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)
- /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)
- docker-compose.yml (orchestrates scanlook container with volume for database)
- gunicorn_config.py (Gunicorn hooks for module loading in workers)
**Module System (v0.17.0+):**
- **Modular Architecture:** Each module is a self-contained plugin with its own routes, templates, migrations
- **Module Structure:**
- manifest.json (metadata: name, version, author, icon, description)
- __init__.py (creates blueprint via create_blueprint())
- routes.py (defines register_routes(bp) function)
- migrations.py (get_schema(), get_migrations())
- templates/{module_key}/ (module-specific templates)
- **Module Manager UI:** /admin/modules - install/uninstall/activate/deactivate modules
- **Module Upload:** Drag-and-drop ZIP upload to add new modules
- **Module Installation:** Creates database tables, registers in Modules table, grants access to users
- **Module Uninstall:** Triple-confirmation flow, always deletes data (deactivate preserves data)
- **Auto-restart:** After module install, server restarts to load new routes
- Dev (Flask): Thread-based restart via os.execv()
- Production (Gunicorn): HUP signal to master for graceful worker reload
- **Database Tables:**
- Modules (module_id, name, module_key, version, author, description, icon, is_active, is_installed)
- UserModules (user_id, module_id) - grants access per user
**Current Modules:**
1. **Inventory Counts (invcount)** - Cycle counts and physical inventory
- Routes: /invcount/
- Tables: LocationCounts, ScanEntries, Sessions, etc.
2. **Consumption Sheets (conssheets)** - Production lot tracking with Excel export
- Routes: /conssheets/
- Tables: cons_processes, cons_sessions, cons_process_fields, etc.
**Key Features:**
- Modular plugin architecture with hot-reload capability
- Module Manager with drag-and-drop upload
- Session-based counting workflows with archive/activate
- Master/current baseline upload (CSV)
- Staff scanning interface optimized for MC9300 Zebra scanners
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
- Role-based access: owner, admin, staff
- Auto-initialize database on first run
- Database migration system (auto-applies schema changes on startup)
- Production-ready with Gunicorn multi-worker support
**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 folders: /modules/{module_key}/
- Module manifest required fields: module_key, name, version, author, description, icon

15
Dockerfile Normal file
View File

@@ -0,0 +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
# Run with Gunicorn
CMD ["gunicorn", "--config", "gunicorn_config.py", "--bind", "0.0.0.0:5000", "--workers", "4", "app:app"]

219
README.md
View File

@@ -17,7 +17,7 @@ pip install flask werkzeug --break-system-packages
### 2. Initialize Database
```bash
cd /home/claude/scanlook
cd /path/to/scanlook
python database/init_db.py
```
@@ -39,7 +39,7 @@ Access at: **http://localhost:5000**
## 📱 Accessing from Scanner Devices
For Zebra Android scanners or tablets on the same network:
For Zebra MC9300 scanners or tablets on the same network:
1. Find your server's IP address:
```bash
@@ -55,11 +55,32 @@ For Zebra Android scanners or tablets on the same network:
---
## 🏗️ Modular Architecture
ScanLook uses a modular system designed to grow into a full WMS. Modules can be enabled/disabled per user.
### Current Modules:
- **Counts** - Cycle Counts and Physical Inventory
### Planned Modules:
- Shipping
- Receiving
- Transfers
- Production
### Module Access
- Admins assign modules to users via User Management
- Users only see modules they have access to on the home screen
- Route guards prevent URL bypassing
---
## 👥 User Roles
### Owner (Level 1)
- Super admin privileges
- Manage all admins
- Manage all users (admins and staff)
- Access to all modules
- View all sessions across branches
### Admin (Level 2)
@@ -68,9 +89,10 @@ For Zebra Android scanners or tablets on the same network:
- Upload CURRENT baseline (refresh anytime)
- View real-time dashboard
- Export variance reports
- Manage staff users
- Manage staff users and module assignments
### Staff/Counter (Level 3)
- Access assigned modules only
- Select active count session
- Scan locations and lots
- Enter weights
@@ -79,36 +101,36 @@ For Zebra Android scanners or tablets on the same network:
---
## 🔄 Core Workflow
## 🔄 Core Workflow (Counts Module)
### Phase 1: Session Setup (Admin)
1. **Log in as admin**
2. **Create new count session**:
2. **Navigate to Admin Console**
3. **Create new count session**:
- Session name: "January 17, 2026 - Daily Cycle Count"
- Type: Cycle Count OR Full Physical Inventory
3. **Upload MASTER baseline** CSV from NetSuite:
4. **Upload MASTER baseline** CSV from NetSuite:
- Format: `Item, Description, Lot Number, Location, Bin Number, On Hand`
- Creates baseline version 1
4. Session status: **Active**
5. Counters can now begin
5. Session status: **Active**
6. Counters can now begin
### Phase 2: Counting (Staff)
1. **Log in as staff**
2. **Select active session**
3. **Scan location barcode** (e.g., R903B)
- System marks location "in progress"
- Shows: "Counting R903B"
4. **Scan lot barcode**
5. System checks lot against MASTER baseline:
2. **Select Counts module** from home screen
3. **Select active session**
4. **Start a new bin** - scan or enter bin number
5. **Scan lot barcode**
6. System checks lot against MASTER baseline:
- ✅ **MATCH**: Expected here, location correct
- ⚠️ **WRONG LOCATION**: Lot exists but system says different location
- ❌ **GHOST LOT**: Not in system at all
6. **Enter weight**
7. Save → returns to scan input (autofocus)
8. Repeat for all lots in location
9. Click **"Finish Location"**
- 🔵 **DUPLICATE**: Lot already scanned (same or different location)
7. **Enter weight**
8. Save → returns to scan input (autofocus)
9. Repeat for all lots in bin
10. Click **"Finish Location"** to finalize
### Phase 3: Admin Monitoring
@@ -155,8 +177,9 @@ Item,Description,Lot Number,Location,Bin Number,On Hand
- What counters validate against
- Represents "what should have been there when we started"
**CURRENT Baseline**:
**CURRENT Baseline** (Global):
- Admin can refresh unlimited times
- Shared across all sessions
- Only affects admin dashboard/reporting
- Counters never see it
- Shows "where is it in the system NOW"
@@ -170,16 +193,29 @@ Item,Description,Lot Number,Location,Bin Number,On Hand
- **Ghost Lot**: Scanned but not in baseline at all
- **Missing/Phantom**: In baseline but not found during count
### Blind Counting
### Duplicate Detection
- Counters don't see expected quantities
- Prevents bias
- Ensures honest count
- **Status 00**: First scan of lot (no duplicate)
- **Status 01**: Duplicate in same location by same user
- **Status 03**: Found in different location
- **Status 04**: Both same location duplicate AND different location
### Count Types
**Cycle Count:**
- Shows expected lots for the bin
- Staff can see what should be there
- Good for regular inventory checks
**Full Physical Inventory:**
- Blind count (no expected list shown)
- Staff only sees what they've scanned
- Missing lots determined after finalization
### Real-Time Dashboard
- Active counters with current location
- Progress by location
- Active sessions with progress stats
- Location completion tracking
- Variance counts updating live
- Dual view (MASTER vs CURRENT) after refresh
@@ -188,32 +224,38 @@ Item,Description,Lot Number,Location,Bin Number,On Hand
- Every scan timestamped with user
- Soft deletes tracked
- Edits tracked with modified timestamp
- Baseline version tracking
- Session archival (archived sessions are read-only)
---
## 🗂️ Database Schema
### Tables:
### Core Tables:
- **Users**: Authentication and role management
- **Modules**: Available system modules
- **UserModules**: Per-user module access
- **CountSessions**: Count session metadata
- **BaselineInventory_Master**: Morning baseline (never changes)
- **BaselineInventory_Current**: Refreshable baseline (soft delete)
- **LocationCounts**: Location-by-location progress
- **ScanEntries**: Every lot scan with dual status tracking
- **MissingLots**: Expected lots not found
- **BaselineInventory_Master**: Session-specific baseline (immutable)
- **BaselineInventory_Current**: Global refreshable baseline
- **LocationCounts**: Bin-by-bin progress tracking
- **ScanEntries**: Every lot scan with status tracking
- **MissingLots**: Expected lots not found after finalization
### Indexes:
Optimized for fast lookups on lot numbers, locations, and statuses.
### Key Relationships:
- Users → UserModules → Modules (many-to-many)
- CountSessions → BaselineInventory_Master (one-to-many)
- CountSessions → LocationCounts → ScanEntries (hierarchical)
---
## 🔧 Technology Stack
- **Backend**: Python 3.13 + Flask
- **Database**: SQLite
- **Database**: SQLite (auto-initializes on first run)
- **Frontend**: HTML5 + CSS3 + Vanilla JavaScript
- **Templates**: Jinja2
- **Icons**: Font Awesome 6.5
- **Deployment**: Docker (Linux)
---
@@ -221,23 +263,37 @@ Optimized for fast lookups on lot numbers, locations, and statuses.
```
scanlook/
├── app.py # Main Flask application
├── app.py # Main Flask application & routes
├── db.py # Database helper functions
├── utils.py # Decorators (login_required, role_required)
├── blueprints/
│ ├── counting.py # Counts module routes
│ ├── sessions.py # Session management routes
│ ├── users.py # User management routes
│ ├── data_imports.py # CSV upload routes
│ └── admin_locations.py # Admin location management
├── database/
│ ├── init_db.py # Database initialization
│ └── scanlook.db # SQLite database (created)
│ ├── init_db.py # Database initialization & schema
│ └── scanlook.db # SQLite database (created at runtime)
├── static/
│ ├── css/
│ │ ── style.css # Main stylesheet
└── js/
└── main.js # Client-side JavaScript
│ │ ── style.css # Base/desktop styles
│ ├── mobile.css # Mobile overrides (360-767px)
└── scanner.css # MC9300 scanner overrides (<360px)
│ ├── js/
│ │ └── main.js # Client-side JavaScript
│ ├── manifest.json # PWA manifest
│ └── sw.js # Service worker
└── templates/
├── base.html # Base template
├── base.html # Base template with navbar
├── login.html # Login page
├── home.html # Module selection (landing page)
├── admin_dashboard.html
├── staff_dashboard.html
├── manage_users.html
├── create_session.html
├── session_detail.html
├── count_session.html
├── my_counts.html
└── count_location.html
```
@@ -245,7 +301,7 @@ scanlook/
## 🎨 Design Philosophy
**Mobile-First, Warehouse-Optimized:**
**Desktop-First, Multi-Device Optimized:**
- High contrast dark theme for warehouse lighting
- Large touch targets (44px minimum)
- Autofocus on scan inputs
@@ -253,52 +309,47 @@ scanlook/
- Visual feedback with color-coded statuses
- Minimal typing required
**CSS Architecture:**
- `style.css` - Base/desktop styles (768px+)
- `mobile.css` - Phone overrides (360-767px)
- `scanner.css` - MC9300 scanner overrides (<360px)
**Color Coding:**
- ✅ Green: Perfect match
- ⚠️ Yellow: Wrong location
- ❌ Red: Missing or ghost lot
- 🔵 Blue: Weight variance
- ✅ Green (`--color-success`): Perfect match
- ⚠️ Yellow (`--color-warning`): Wrong location / Expected
- ❌ Red (`--color-danger`): Missing or ghost lot
- 🔵 Blue (`--color-duplicate`): Duplicate scan
- 🟠 Orange (`--color-orange`): Weight variance
---
## 🚧 Current Status: Phase 1 Complete (MVP)
## 🐳 Docker Deployment
### ✅ Implemented:
- User authentication (3 roles)
- Session creation
- MASTER baseline upload
- Location scanning
- Lot scanning with MASTER status
- Weight entry
- Basic dashboard
- Real-time progress tracking
- Mobile-optimized UI
```bash
# Build
docker build -t scanlook .
### 🔜 Next Phases:
- **Phase 2**: CURRENT baseline refresh & dual status recalculation
- **Phase 3**: Missing lot detection, enhanced dashboard
- **Phase 4**: Excel export with multiple tabs
- **Phase 5**: Data retention automation, user management
- **Phase 6**: Docker deployment
# Run (preserves database between updates)
docker run -d \
-p 5000:5000 \
-v /path/to/data:/app/database \
-e SCANLOOK_SECRET_KEY=your-secret-key \
scanlook
```
**Important:** Only `/app/database` is volume-mounted to preserve data between container updates.
---
## 📝 Development Notes
## 🔐 Security Considerations
### Default Accounts (Testing)
All passwords should be changed in production!
### Security Considerations
- Passwords are hashed with Werkzeug
- Passwords hashed with Werkzeug
- Session management via Flask sessions
- SQL injection prevention (parameterized queries)
- Role-based access control
- Module-based access control with route guards
- Soft deletes preserve audit trail
### Data Retention
- Plan: Keep data for 30 days
- Nightly cleanup job (to be implemented)
- Archive old sessions
---
## 🎯 Success Metrics
@@ -319,6 +370,7 @@ All passwords should be changed in production!
## 💡 Future Enhancements
- Additional WMS modules (Shipping, Receiving, Transfers, Production)
- Multi-branch support
- Direct NetSuite API integration
- Photo capture for variance proof
@@ -328,7 +380,16 @@ All passwords should be changed in production!
---
## 📧 Support
## 📝 Version History
- **v0.12.0** - Modular architecture with user-based module access
- **v0.11.x** - CSS refactoring, multi-device support, duplicate detection
- **v0.10.x** - Global CURRENT baseline, session archival
- **v0.9.x** - Core counting workflow, dual baseline system
---
## 🔧 Support
This is a custom internal tool. Contact your system administrator for support.

Binary file not shown.

1468
app.py

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,96 @@
from flask import Blueprint, jsonify, session
from db import query_db, execute_db
from utils import login_required
admin_locations_bp = Blueprint('admin_locations', __name__)
@admin_locations_bp.route('/location/<int:location_count_id>/reopen', methods=['POST'])
@login_required
def reopen_location(location_count_id):
"""Reopen a completed location (admin/owner only)"""
# Check permissions
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
if not user or user['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'}), 403
# Verify location exists
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'})
# Reopen the location
execute_db('''
UPDATE LocationCounts
SET status = 'in_progress', end_timestamp = NULL
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({'success': True, 'message': 'Bin reopened for counting'})
@admin_locations_bp.route('/location/<int:location_count_id>/scans')
@login_required
def get_location_scans(location_count_id):
"""Get all scans for a specific location (admin/owner only)"""
# Check permissions
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
if not user or user['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'}), 403
try:
scans = query_db('''
SELECT
se.*,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
WHERE se.location_count_id = ?
AND se.is_deleted = 0
ORDER BY se.scan_timestamp DESC
''', [location_count_id])
# Convert Row objects to dicts
scans_list = [dict(scan) for scan in scans] if scans else []
return jsonify({'success': True, 'scans': scans_list})
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'})

1240
blueprints/cons_sheets.py Normal file

File diff suppressed because it is too large Load Diff

836
blueprints/counting.py Normal file
View File

@@ -0,0 +1,836 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
from db import query_db, execute_db, get_db
from utils import login_required
counting_bp = Blueprint('counting', __name__)
def get_active_session(session_id):
"""Get session if it exists and is not archived. Returns None if invalid."""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess or sess['status'] == 'archived':
return None
return sess
@counting_bp.route('/counts/admin')
@login_required
def admin_dashboard():
"""Admin dashboard for Counts module"""
# Security check: Ensure user is admin/owner
if session.get('role') not in ['owner', 'admin']:
flash('Access denied. Admin role required.', 'danger')
return redirect(url_for('counting.index'))
show_archived = request.args.get('show_archived', '0') == '1'
# This SQL was moved from app.py
if show_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
''')
else:
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
''')
return render_template('counts/admin_dashboard.html', sessions=sessions_list, show_archived=show_archived)
@counting_bp.route('/counts')
@login_required
def index():
"""Counts module landing - show active sessions"""
# Check if user has access to this module
user_id = session.get('user_id')
has_access = query_db('''
SELECT 1 FROM UserModules um
JOIN Modules m ON um.module_id = m.module_id
WHERE um.user_id = ? AND m.module_key = 'counting' AND m.is_active = 1
''', [user_id], one=True)
if not has_access:
flash('You do not have access to this module', 'danger')
return redirect(url_for('home'))
active_sessions = query_db('''
SELECT session_id, session_name, session_type, created_timestamp
FROM CountSessions
WHERE status = 'active'
ORDER BY created_timestamp DESC
''')
return render_template('counts/staff_dashboard.html', sessions=active_sessions)
@counting_bp.route('/count/<int:session_id>')
@login_required
def count_session(session_id):
"""Select session and begin counting"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ? AND status = "active"',
[session_id], one=True)
if not sess:
flash('Session not found or not active', 'danger')
return redirect(url_for('counting.index'))
# Redirect to my_counts page (staff can manage multiple bins)
return redirect(url_for('counting.my_counts', session_id=session_id))
@counting_bp.route('/session/<int:session_id>/my-counts')
@login_required
def my_counts(session_id):
"""Staff view of their active and completed bins"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
flash('Session not found', 'danger')
return redirect(url_for('counting.index'))
if sess['status'] == 'archived':
flash('This session has been archived', 'warning')
return redirect(url_for('counting.index'))
# Get this user's active bins
active_bins = query_db('''
SELECT lc.*,
COUNT(se.entry_id) as scan_count
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.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['user_id']])
# Get this user's completed bins
completed_bins = query_db('''
SELECT lc.*,
COUNT(se.entry_id) as scan_count
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.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.start_timestamp DESC
''', [session_id, session['user_id'], session['user_id']])
return render_template('counts/my_counts.html',
count_session=sess,
active_bins=active_bins,
completed_bins=completed_bins)
@counting_bp.route('/session/<int:session_id>/start-bin', methods=['POST'])
@login_required
def start_bin_count(session_id):
"""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')
return redirect(url_for('counting.index'))
if not sess['master_baseline_timestamp']:
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
return redirect(url_for('counting.my_counts', session_id=session_id))
location_name = request.form.get('location_name', '').strip().upper()
if not location_name:
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
FROM BaselineInventory_Master
WHERE session_id = ? AND system_bin = ?
''', [session_id, location_name], one=True)
expected_count = expected_lots['count'] if expected_lots else 0
# Create new location count if none existed
conn = get_db()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO LocationCounts (session_id, location_name, counted_by, status, start_timestamp, expected_lots_master)
VALUES (?, ?, ?, 'in_progress', CURRENT_TIMESTAMP, ?)
''', [session_id, location_name, session['user_id'], expected_count])
location_count_id = cursor.lastrowid
conn.commit()
conn.close()
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):
"""Mark a location count as complete (Simple toggle)"""
# 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'})
# Mark as completed
execute_db('''
UPDATE LocationCounts
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({'success': True, 'message': 'Bin marked as complete'})
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>')
@login_required
def count_location(session_id, location_count_id):
"""Count lots in a specific location"""
# Get session info to determine type (Cycle Count vs Physical)
sess = get_active_session(session_id)
if not sess:
flash('Session not found or archived', 'warning')
return redirect(url_for('counting.index'))
if not sess['master_baseline_timestamp']:
flash('Master File not uploaded. Please upload it before starting bins.', 'warning')
return redirect(url_for('counting.my_counts', session_id=session_id))
location = query_db('''
SELECT * FROM LocationCounts
WHERE location_count_id = ? AND session_id = ?
''', [location_count_id, session_id], one=True)
if not location:
flash('Location not found', 'danger')
return redirect(url_for('counting.count_session', session_id=session_id))
# Check if location is completed and user is staff (not admin/owner)
if location['status'] == 'completed' and session['role'] == 'staff':
flash(f'Location {location["location_name"]} has been finalized and cannot accept new scans', 'warning')
return redirect(url_for('counting.my_counts', session_id=session_id))
# Get scans for this location (Scanned Lots)
scans = query_db('''
SELECT * FROM ScanEntries
WHERE location_count_id = ? AND is_deleted = 0
ORDER BY scan_timestamp DESC
''', [location_count_id])
# NEW LOGIC: Get Expected Lots for Cycle Counts (Grouped & Summed)
expected_lots = []
if sess and sess['session_type'] == 'cycle_count':
expected_lots = query_db('''
SELECT
lot_number,
MAX(item) as item, -- Pick one item code if they differ (rare)
SUM(system_quantity) as total_weight
FROM BaselineInventory_Master
WHERE session_id = ?
AND system_bin = ?
AND lot_number NOT IN (
SELECT lot_number
FROM ScanEntries
WHERE location_count_id = ?
AND is_deleted = 0
)
GROUP BY lot_number
ORDER BY lot_number
''', [session_id, location['location_name'], location_count_id])
return render_template('counts/count_location.html',
session_id=session_id,
location=location,
scans=scans,
expected_lots=expected_lots,
session_type=sess['session_type'] if sess else '')
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>/scan', methods=['POST'])
@login_required
def scan_lot(session_id, location_count_id):
"""Process a lot scan with duplicate detection"""
sess = get_active_session(session_id)
if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'})
data = request.get_json()
lot_number = data.get('lot_number', '').strip()
weight = data.get('weight')
confirm_duplicate = data.get('confirm_duplicate', False)
check_only = data.get('check_only', False) # Just checking for duplicates, not saving
if not lot_number:
return jsonify({'success': False, 'message': 'Lot number required'})
if not check_only and not weight:
return jsonify({'success': False, 'message': 'Weight required'})
if not check_only:
try:
weight = float(weight)
except ValueError:
return jsonify({'success': False, 'message': 'Invalid weight value'})
# Get location info
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
[location_count_id], one=True)
# Check for duplicates in this session
existing_scans = query_db('''
SELECT se.*, lc.location_name, u.full_name
FROM ScanEntries se
JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id
JOIN Users u ON se.scanned_by = u.user_id
WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0
''', [session_id, lot_number])
duplicate_status = '00' # Default: no duplicate
duplicate_info = None
needs_confirmation = False
if existing_scans:
# Check for same location duplicates (by this user)
same_location = [s for s in existing_scans
if s['location_name'] == location['location_name']
and s['scanned_by'] == session['user_id']]
# Check for different location duplicates (by anyone)
diff_location = [s for s in existing_scans
if s['location_name'] != location['location_name']]
if same_location and diff_location:
# Status 04: Duplicate in both same and different locations
duplicate_status = '04'
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
duplicate_info = f"Also found in {other_locs}. Duplicate Lot"
needs_confirmation = True
elif same_location:
# Status 01: Duplicate in same location only
duplicate_status = '01'
duplicate_info = "Duplicate"
needs_confirmation = True
elif diff_location:
# Status 03: Duplicate in different location only
duplicate_status = '03'
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
duplicate_info = f"Also found in {other_locs}"
# If just checking, return early with baseline info
if check_only:
# Get baseline info to show what they're scanning
master_info = query_db('''
SELECT item, description FROM BaselineInventory_Master
WHERE session_id = ? AND lot_number = ?
LIMIT 1
''', [session_id, lot_number], one=True)
if needs_confirmation:
return jsonify({
'success': False,
'needs_confirmation': True,
'message': 'Lot already scanned, Are you sure?',
'duplicate_status': duplicate_status,
'item': master_info['item'] if master_info else None,
'description': master_info['description'] if master_info else None
})
else:
return jsonify({
'success': True,
'needs_confirmation': False,
'item': master_info['item'] if master_info else None,
'description': master_info['description'] if master_info else None
})
# If needs confirmation and not yet confirmed, ask user
if needs_confirmation and not confirm_duplicate:
return jsonify({
'success': False,
'needs_confirmation': True,
'message': 'Lot already scanned, Are you sure?',
'duplicate_status': duplicate_status
})
# Check against MASTER baseline
master = query_db('''
SELECT
system_bin,
SUM(system_quantity) as system_quantity,
MAX(item) as item,
MAX(description) as description
FROM BaselineInventory_Master
WHERE session_id = ? AND lot_number = ? AND system_bin = ?
GROUP BY system_bin
''', [session_id, lot_number, location['location_name']], one=True)
# Determine master_status (only if not a duplicate issue)
if duplicate_status == '00':
if master:
# Lot exists in correct location
master_status = 'match'
if master['system_quantity'] is not None:
variance_lbs = weight - master['system_quantity']
variance_pct = (variance_lbs / master['system_quantity'] * 100) if master['system_quantity'] > 0 else 0
else:
variance_lbs = None
variance_pct = None
else:
# Check if lot exists in different location
master_other = query_db('''
SELECT
system_bin,
SUM(system_quantity) as system_quantity,
MAX(item) as item,
MAX(description) as description
FROM BaselineInventory_Master
WHERE session_id = ? AND lot_number = ?
GROUP BY system_bin
ORDER BY system_bin
LIMIT 1
''', [session_id, lot_number], one=True)
if master_other:
master_status = 'wrong_location'
master = master_other
variance_lbs = None
variance_pct = None
else:
# Ghost lot
master_status = 'ghost_lot'
variance_lbs = None
variance_pct = None
else:
# For duplicates, still check baseline for item info
if not master:
master = query_db('''
SELECT
system_bin,
SUM(system_quantity) as system_quantity,
MAX(item) as item,
MAX(description) as description
FROM BaselineInventory_Master
WHERE session_id = ? AND lot_number = ?
GROUP BY system_bin
ORDER BY system_bin
LIMIT 1
''', [session_id, lot_number], one=True)
master_status = 'match' # Don't override with wrong_location for duplicates
variance_lbs = None
variance_pct = None
# Insert scan
entry_id = execute_db('''
INSERT INTO ScanEntries
(session_id, location_count_id, lot_number, item, description,
scanned_location, actual_weight, scanned_by,
master_status, master_expected_location, master_expected_weight,
master_variance_lbs, master_variance_pct,
duplicate_status, duplicate_info, comment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', [
session_id, location_count_id, lot_number,
master['item'] if master else None,
master['description'] if master else None,
location['location_name'], weight, session['user_id'],
master_status,
master['system_bin'] if master else None,
master['system_quantity'] if master else None,
variance_lbs, variance_pct,
duplicate_status, duplicate_info, duplicate_info
])
# If this is a confirmed duplicate (01 or 04), update previous scans in same location
updated_entry_ids = []
if duplicate_status in ['01', '04'] and confirm_duplicate:
same_location_ids = [s['entry_id'] for s in existing_scans
if s['location_name'] == location['location_name']
and s['scanned_by'] == session['user_id']]
for scan_id in same_location_ids:
execute_db('''
UPDATE ScanEntries
SET duplicate_status = ?,
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
''', [duplicate_status, duplicate_info, duplicate_info, scan_id])
updated_entry_ids.append(scan_id)
# Update location count
execute_db('''
UPDATE LocationCounts
SET lots_found = lots_found + 1
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({
'success': True,
'entry_id': entry_id,
'master_status': master_status,
'duplicate_status': duplicate_status,
'duplicate_info': duplicate_info,
'master_expected_location': master['system_bin'] if master else None,
'master_expected_weight': master['system_quantity'] if master else None,
'actual_weight': weight,
'variance_lbs': variance_lbs,
'item': master['item'] if master else 'Unknown Item',
'description': master['description'] if master else 'Not in system',
'updated_entry_ids': updated_entry_ids # IDs of scans that were updated to duplicate
})
@counting_bp.route('/scan/<int:entry_id>/delete', methods=['POST'])
@login_required
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 = ? AND is_deleted = 0', [entry_id], one=True)
if not scan:
return jsonify({'success': False, 'message': 'Scan not found'})
# Only allow user to delete their own scans
if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Soft delete the scan
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1,
deleted_by = ?,
deleted_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ?
''', [session['user_id'], entry_id])
# Recalculate duplicate statuses for this lot number in this session
updated_entries = recalculate_duplicate_status(scan['session_id'], scan['lot_number'], scan['scanned_location'])
# Update location count
execute_db('''
UPDATE LocationCounts
SET lots_found = lots_found - 1
WHERE location_count_id = ?
''', [scan['location_count_id']])
return jsonify({
'success': True,
'message': 'Scan deleted',
'updated_entries': updated_entries # Return which scans were updated
})
@counting_bp.route('/scan/<int:entry_id>/details', methods=['GET'])
@login_required
def get_scan_details(entry_id):
"""Get detailed information about a scan"""
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'})
return jsonify({
'success': True,
'scan': dict(scan)
})
@counting_bp.route('/scan/<int:entry_id>/update', methods=['POST'])
@login_required
def update_scan(entry_id):
"""Update scan item, weight and comment"""
data = request.get_json()
item = data.get('item', '').strip()
weight = data.get('weight')
comment = data.get('comment', '')
# Get the scan
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'})
# Only allow user to update their own scans
if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
try:
weight = float(weight)
except ValueError:
return jsonify({'success': False, 'message': 'Invalid weight value'})
# Update the scan
execute_db('''
UPDATE ScanEntries
SET item = ?,
actual_weight = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ? and is_deleted = 0
''', [item, weight, comment, entry_id])
return jsonify({'success': True, 'message': 'Scan updated'})
def recalculate_duplicate_status(session_id, lot_number, current_location):
"""Recalculate duplicate statuses for a lot after deletion"""
# Track which entries were updated
updated_entries = []
# Get all active scans for this lot in this session
scans = query_db('''
SELECT se.*, lc.location_name, u.full_name, u.user_id as scan_user_id
FROM ScanEntries se
JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id
JOIN Users u ON se.scanned_by = u.user_id
WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0
ORDER BY se.scan_timestamp
''', [session_id, lot_number])
if not scans:
return updated_entries
# Reset all to status 00
for scan in scans:
execute_db('''
UPDATE ScanEntries
SET duplicate_status = '00',
duplicate_info = NULL,
comment = NULL,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ? and is_deleted = 0
''', [scan['entry_id']])
updated_entries.append({
'entry_id': scan['entry_id'],
'duplicate_status': '00',
'duplicate_info': None
})
# Recalculate statuses
for i, scan in enumerate(scans):
# Get previous scans (before this one chronologically)
prev_scans = scans[:i]
if not prev_scans:
continue # First scan, stays 00
same_location = [s for s in prev_scans if s['location_name'] == scan['location_name'] and s['scan_user_id'] == scan['scan_user_id']]
diff_location = [s for s in prev_scans if s['location_name'] != scan['location_name']]
duplicate_status = '00'
duplicate_info = None
if same_location and diff_location:
# Status 04
duplicate_status = '04'
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
duplicate_info = f"Also found in {other_locs}. Duplicate Lot"
elif same_location:
# Status 01
duplicate_status = '01'
duplicate_info = "Duplicate"
elif diff_location:
# Status 03
duplicate_status = '03'
other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location))
duplicate_info = f"Also found in {other_locs}"
# Update this scan if it changed from 00
if duplicate_status != '00':
execute_db('''
UPDATE ScanEntries
SET duplicate_status = ?,
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, scan['entry_id']])
# Update our tracking list
for entry in updated_entries:
if entry['entry_id'] == scan['entry_id']:
entry['duplicate_status'] = duplicate_status
entry['duplicate_info'] = duplicate_info
break
# If status 01 or 04, also update previous scans in same location
if duplicate_status in ['01', '04'] and same_location:
for prev_scan in same_location:
execute_db('''
UPDATE ScanEntries
SET duplicate_status = ?,
duplicate_info = ?,
comment = ?,
modified_timestamp = CURRENT_TIMESTAMP
WHERE entry_id = ? and is_deleted = 0
''', [duplicate_status, duplicate_info, duplicate_info, prev_scan['entry_id']])
# Update tracking for previous scans
for entry in updated_entries:
if entry['entry_id'] == prev_scan['entry_id']:
entry['duplicate_status'] = duplicate_status
entry['duplicate_info'] = duplicate_info
break
return updated_entries
@counting_bp.route('/count/<int:session_id>/location/<int:location_count_id>/finish', methods=['POST'])
@login_required
def finish_location(session_id, location_count_id):
"""Finish counting a location"""
sess = get_active_session(session_id)
if not sess:
return jsonify({'success': False, 'message': 'Session not found or archived'})
# Get location info
location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?',
[location_count_id], one=True)
if not location:
return jsonify({'success': False, 'message': 'Location not found'})
# Mark location as completed
execute_db('''
UPDATE LocationCounts
SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP
WHERE location_count_id = ?
''', [location_count_id])
# V1.0: Mark missing lots from MASTER baseline that weren't scanned
# Get all expected lots for this location 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, location['location_name']])
# Get all scanned lots for this location
scanned_lots = query_db('''
SELECT DISTINCT lot_number
FROM ScanEntries
WHERE location_count_id = ? AND is_deleted = 0
''', [location_count_id])
scanned_lot_numbers = {s['lot_number'] for s in scanned_lots}
# Insert missing 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'], location['location_name'],
expected['item'], expected['system_quantity'], session['user_id']])
flash('Location count completed!', 'success')
return jsonify({
'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

@@ -17,12 +17,12 @@ def upload_current(session_id):
if 'csv_file' not in request.files:
flash('No file part', 'danger')
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))
file = request.files['csv_file']
if file.filename == '':
flash('No selected file', 'danger')
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))
if file:
conn = get_db()
@@ -76,7 +76,7 @@ def upload_current(session_id):
finally:
conn.close()
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))
# --- ROUTE 2: Upload MASTER Baseline (Session Specific) ---
@data_imports_bp.route('/session/<int:session_id>/upload_master', methods=['POST'])
@@ -85,15 +85,16 @@ def upload_master(session_id):
if 'csv_file' not in request.files:
flash('No file uploaded', 'danger')
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))
file = request.files['csv_file']
if file.filename == '':
flash('No file selected', 'danger')
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))
conn = get_db()
cursor = conn.cursor()
cursor.execute('DELETE FROM BaselineInventory_Master WHERE session_id = ?', [session_id])
try:
stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None)
csv_reader = csv.DictReader(stream)
@@ -152,4 +153,4 @@ def upload_master(session_id):
finally:
conn.close()
return redirect(url_for('session_detail', session_id=session_id))
return redirect(url_for('sessions.session_detail', session_id=session_id))

296
blueprints/sessions.py Normal file
View File

@@ -0,0 +1,296 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
from db import query_db, execute_db
from utils import role_required
sessions_bp = Blueprint('sessions', __name__)
@sessions_bp.route('/session/create', methods=['GET', 'POST'])
@role_required('owner', 'admin')
def create_session():
"""Create new count session"""
if request.method == 'POST':
session_name = request.form.get('session_name', '').strip()
session_type = request.form.get('session_type')
if not session_name:
flash('Session name is required', 'danger')
return redirect(url_for('sessions.create_session'))
session_id = execute_db('''
INSERT INTO CountSessions (session_name, session_type, created_by, branch)
VALUES (?, ?, ?, ?)
''', [session_name, session_type, session['user_id'], 'Main'])
flash(f'Session "{session_name}" created successfully!', 'success')
return redirect(url_for('sessions.session_detail', session_id=session_id))
return render_template('/counts/create_session.html')
@sessions_bp.route('/session/<int:session_id>')
@role_required('owner', 'admin')
def session_detail(session_id):
"""Session detail and monitoring page"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
flash('Session not found', 'danger')
return redirect(url_for('dashboard'))
# Get statistics
stats = query_db('''
SELECT
COUNT(DISTINCT se.entry_id) FILTER (WHERE se.is_deleted = 0) as total_scans,
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 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 weight_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_lots
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)
# 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 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
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'
AND lc.is_deleted = 0
GROUP BY u.user_id
ORDER BY start_timestamp DESC
''', [session_id])
return render_template('/counts/session_detail.html',
count_session=sess,
stats=stats,
locations=locations,
active_counters=active_counters)
@sessions_bp.route('/session/<int:session_id>/status-details/<status>')
@role_required('owner', 'admin')
def get_status_details(session_id, status):
"""Get detailed breakdown for a specific status"""
try:
if status == 'match':
# Matched lots (not duplicates) - JOIN with CURRENT for live data
items = query_db('''
SELECT
se.*,
u.full_name as scanned_by_name,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
JOIN Users u ON se.scanned_by = u.user_id
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
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])
elif status == 'duplicates':
# Duplicate lots (grouped by lot number) - JOIN with CURRENT
items = query_db('''
SELECT
se.lot_number,
se.item,
se.description,
GROUP_CONCAT(DISTINCT se.scanned_location) as scanned_location,
SUM(se.actual_weight) as actual_weight,
se.master_expected_location,
se.master_expected_weight,
GROUP_CONCAT(DISTINCT u.full_name) as scanned_by_name,
MIN(se.scan_timestamp) as scan_timestamp,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
JOIN Users u ON se.scanned_by = u.user_id
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
WHERE se.session_id = ?
AND se.duplicate_status IN ('01', '03', '04')
AND se.is_deleted = 0
GROUP BY se.lot_number
ORDER BY se.lot_number
''', [session_id])
elif status == 'wrong_location':
# Wrong location lots - JOIN with CURRENT
items = query_db('''
SELECT
se.*,
u.full_name as scanned_by_name,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
JOIN Users u ON se.scanned_by = u.user_id
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
WHERE se.session_id = ?
AND se.master_status = 'wrong_location'
AND se.is_deleted = 0
ORDER BY se.scan_timestamp DESC
''', [session_id])
elif status == 'weight_discrepancy':
# Weight discrepancies (right location, wrong weight) - JOIN with CURRENT
items = query_db('''
SELECT
se.*,
u.full_name as scanned_by_name,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
JOIN Users u ON se.scanned_by = u.user_id
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
WHERE se.session_id = ?
AND se.master_status = 'match'
AND se.duplicate_status = '00'
AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01
AND se.is_deleted = 0
ORDER BY ABS(se.actual_weight - se.master_expected_weight) DESC
''', [session_id])
elif status == 'ghost_lot':
# Ghost lots (not in master baseline) - JOIN with CURRENT
items = query_db('''
SELECT
se.*,
u.full_name as scanned_by_name,
bic.system_bin as current_system_location,
bic.system_quantity as current_system_weight
FROM ScanEntries se
JOIN Users u ON se.scanned_by = u.user_id
LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number
WHERE se.session_id = ?
AND se.master_status = 'ghost_lot'
AND se.is_deleted = 0
ORDER BY se.scan_timestamp DESC
''', [session_id])
elif status == 'missing':
# Missing lots (in master but not scanned)
items = query_db('''
SELECT
ml.lot_number,
ml.item,
bim.description,
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'})
return jsonify({
'success': True,
'items': [dict(item) for item in items] if items else []
})
except Exception as e:
print(f"Error in get_status_details: {str(e)}")
return jsonify({'success': False, 'message': f'Error: {str(e)}'})
@sessions_bp.route('/session/<int:session_id>/archive', methods=['POST'])
@role_required('owner', 'admin')
def archive_session(session_id):
"""Archive a count session"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
if sess['status'] == 'archived':
return jsonify({'success': False, 'message': 'Session is already archived'})
execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['archived', session_id])
return jsonify({'success': True, 'message': 'Session archived successfully'})
@sessions_bp.route('/session/<int:session_id>/activate', methods=['POST'])
@role_required('owner', 'admin')
def activate_session(session_id):
"""Reactivate an archived session"""
sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True)
if not sess:
return jsonify({'success': False, 'message': 'Session not found'})
if sess['status'] != 'archived':
return jsonify({'success': False, 'message': 'Session is not archived'})
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)

237
blueprints/users.py Normal file
View File

@@ -0,0 +1,237 @@
from flask import Blueprint, render_template, request, jsonify, session
from werkzeug.security import generate_password_hash
from db import query_db, execute_db
from utils import role_required
users_bp = Blueprint('users', __name__)
@users_bp.route('/settings/users')
@role_required('owner', 'admin')
def manage_users():
"""User management page"""
# Get all users
if session['role'] == 'owner':
# Owners can see everyone
users = query_db('SELECT * FROM Users ORDER BY role, full_name')
else:
# Admins can only see staff
users = query_db("SELECT * FROM Users WHERE role = 'staff' ORDER BY full_name")
# Get all active modules
modules = query_db('SELECT * FROM Modules WHERE is_active = 1 ORDER BY display_order')
return render_template('manage_users.html', users=users, modules=modules)
@users_bp.route('/settings/users/add', methods=['POST'])
@role_required('owner', 'admin')
def add_user():
"""Add a new user"""
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
first_name = data.get('first_name', '').strip()
last_name = data.get('last_name', '').strip()
email = data.get('email', '').strip()
role = data.get('role', 'staff')
branch = data.get('branch', 'Main')
# Validation
if not username or not password or not first_name or not last_name:
return jsonify({'success': False, 'message': 'Username, password, first name, and last name are required'})
# Admins can't create admins or owners
if session['role'] == 'admin' and role in ['admin', 'owner']:
return jsonify({'success': False, 'message': 'Permission denied: Admins can only create Staff users'})
# Check if username exists
existing = query_db('SELECT user_id FROM Users WHERE username = ?', [username], one=True)
if existing:
return jsonify({'success': False, 'message': 'Username already exists'})
# Create user
full_name = f"{first_name} {last_name}"
hashed_password = generate_password_hash(password)
try:
execute_db('''
INSERT INTO Users (username, password, full_name, role, branch, is_active)
VALUES (?, ?, ?, ?, ?, 1)
''', [username, hashed_password, full_name, role, branch])
return jsonify({'success': True, 'message': 'User created successfully'})
except Exception as e:
return jsonify({'success': False, 'message': f'Error creating user: {str(e)}'})
@users_bp.route('/settings/users/<int:user_id>', methods=['GET'])
@role_required('owner', 'admin')
def get_user(user_id):
"""Get user details"""
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
if not user:
return jsonify({'success': False, 'message': 'User not found'})
# Admins can't view other admins or owners
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Split full name
name_parts = user['full_name'].split(' ', 1)
first_name = name_parts[0] if len(name_parts) > 0 else ''
last_name = name_parts[1] if len(name_parts) > 1 else ''
# Get email, handle None
email = user['email'] if user['email'] else ''
return jsonify({
'success': True,
'user': {
'user_id': user['user_id'],
'username': user['username'],
'first_name': first_name,
'last_name': last_name,
'email': email,
'role': user['role'],
'branch': user['branch'],
'is_active': user['is_active']
}
})
@users_bp.route('/settings/users/<int:user_id>/update', methods=['POST'])
@role_required('owner', 'admin')
def update_user(user_id):
"""Update user details"""
data = request.get_json()
# Get existing user
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
if not user:
return jsonify({'success': False, 'message': 'User not found'})
# Admins can't edit other admins or owners
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Can't edit yourself to change your own role or deactivate
if user_id == session['user_id']:
if data.get('role') != user['role']:
return jsonify({'success': False, 'message': 'Cannot change your own role'})
if data.get('is_active') == 0:
return jsonify({'success': False, 'message': 'Cannot deactivate yourself'})
# Build update
username = data.get('username', '').strip()
first_name = data.get('first_name', '').strip()
last_name = data.get('last_name', '').strip()
email = data.get('email', '').strip()
role = data.get('role', user['role'])
branch = data.get('branch', user['branch'])
is_active = data.get('is_active', user['is_active'])
password = data.get('password', '').strip()
if not username or not first_name or not last_name:
return jsonify({'success': False, 'message': 'Username, first name, and last name are required'})
# Check if username is taken by another user
if username != user['username']:
existing = query_db('SELECT user_id FROM Users WHERE username = ? AND user_id != ?', [username, user_id], one=True)
if existing:
return jsonify({'success': False, 'message': 'Username already taken'})
# Admins can't change role to admin or owner
if session['role'] == 'admin' and role in ['admin', 'owner']:
return jsonify({'success': False, 'message': 'Permission denied: Cannot assign Admin or Owner role'})
full_name = f"{first_name} {last_name}"
try:
if password:
# Update with new password
hashed_password = generate_password_hash(password)
execute_db('''
UPDATE Users
SET username = ?, full_name = ?, email = ?, role = ?, branch = ?, is_active = ?, password = ?
WHERE user_id = ?
''', [username, full_name, email, role, branch, is_active, hashed_password, user_id])
else:
# Update without changing password
execute_db('''
UPDATE Users
SET username = ?, full_name = ?, email = ?, role = ?, branch = ?, is_active = ?
WHERE user_id = ?
''', [username, full_name, email, role, branch, is_active, user_id])
return jsonify({'success': True, 'message': 'User updated successfully'})
except Exception as e:
return jsonify({'success': False, 'message': f'Error updating user: {str(e)}'})
@users_bp.route('/settings/users/<int:user_id>/delete', methods=['POST'])
@role_required('owner', 'admin')
def delete_user(user_id):
"""Delete (deactivate) a user"""
# Get user
user = query_db('SELECT * FROM Users WHERE user_id = ?', [user_id], one=True)
if not user:
return jsonify({'success': False, 'message': 'User not found'})
# Admins can't delete other admins or owners
if session['role'] == 'admin' and user['role'] in ['admin', 'owner']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Can't delete yourself
if user_id == session['user_id']:
return jsonify({'success': False, 'message': 'Cannot delete yourself'})
# Soft delete (deactivate)
try:
execute_db('UPDATE Users SET is_active = 0 WHERE user_id = ?', [user_id])
return jsonify({'success': True, 'message': 'User deleted successfully'})
except Exception as e:
return jsonify({'success': False, 'message': f'Error deleting user: {str(e)}'})
@users_bp.route('/settings/users/<int:user_id>/modules', methods=['GET'])
@role_required('owner', 'admin')
def get_user_modules(user_id):
"""Get modules assigned to a user"""
modules = query_db('''
SELECT module_id FROM UserModules WHERE user_id = ?
''', [user_id])
module_ids = [m['module_id'] for m in modules]
return jsonify({'success': True, 'module_ids': module_ids})
@users_bp.route('/settings/users/<int:user_id>/modules', methods=['POST'])
@role_required('owner', 'admin')
def update_user_modules(user_id):
"""Update modules assigned to a user"""
data = request.get_json()
module_ids = data.get('module_ids', [])
# Verify user exists
user = query_db('SELECT user_id FROM Users WHERE user_id = ?', [user_id], one=True)
if not user:
return jsonify({'success': False, 'message': 'User not found'})
try:
# Remove all current assignments
execute_db('DELETE FROM UserModules WHERE user_id = ?', [user_id])
# Add new assignments
for module_id in module_ids:
execute_db('''
INSERT INTO UserModules (user_id, module_id, granted_by)
VALUES (?, ?, ?)
''', [user_id, module_id, session['user_id']])
return jsonify({'success': True, 'message': 'Modules updated'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})

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,160 +35,63 @@ def init_database():
)
''')
# CountSessions Table
# NOTE: current_baseline_version removed - CURRENT is now global
# Modules Table (legacy - for user permissions)
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,
CREATE TABLE IF NOT EXISTS Modules (
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
module_key TEXT UNIQUE 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)
icon TEXT,
is_active INTEGER DEFAULT 1,
display_order INTEGER DEFAULT 0
)
''')
# 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
# UserModules Table (module access per user)
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Current (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS UserModules (
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id INTEGER NOT NULL,
granted_by INTEGER,
granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES Users(user_id),
FOREIGN KEY (module_id) REFERENCES Modules(module_id),
FOREIGN KEY (granted_by) REFERENCES Users(user_id),
UNIQUE(user_id, module_id)
)
''')
# Module Registry Table (new module manager system)
cursor.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,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
is_installed INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 0,
installed_at TEXT,
config_json TEXT
)
''')
# LocationCounts Table
# Schema Migrations Table (for core migrations only)
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)
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# 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)
)
''')
# 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
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():
@@ -220,3 +126,4 @@ def create_default_users():
if __name__ == '__main__':
init_database()
create_default_users()

125
global_actions.py Normal file
View File

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

11
gunicorn_config.py Normal file
View File

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

200
migrations.py Normal file
View File

@@ -0,0 +1,200 @@
"""
ScanLook Core Database Migration System
IMPORTANT: This file only contains CORE system migrations.
Module-specific migrations are in each module's migrations.py file.
"""
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), 'database', 'scanlook.db')
def get_db():
"""Get database connection"""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_migrations_table():
"""Create the migrations tracking table if it doesn't exist"""
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def get_applied_migrations():
"""Get list of already-applied migration versions"""
conn = get_db()
try:
rows = conn.execute('SELECT version FROM schema_migrations ORDER BY version').fetchall()
return [row['version'] for row in rows]
except:
return []
finally:
conn.close()
def record_migration(version, name):
"""Record that a migration was applied"""
conn = get_db()
conn.execute('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [version, name])
conn.commit()
conn.close()
def column_exists(table, column):
"""Check if a column exists in a table"""
conn = get_db()
cursor = conn.execute(f'PRAGMA table_info({table})')
columns = [row[1] for row in cursor.fetchall()]
conn.close()
return column in columns
def table_exists(table):
"""Check if a table exists"""
conn = get_db()
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [table])
exists = cursor.fetchone() is not None
conn.close()
return exists
# ============================================
# CORE SYSTEM MIGRATIONS ONLY
# ============================================
# Module-specific migrations are handled by each module's migrations.py
# ============================================
def migration_001_add_modules_tables():
"""Add Modules and UserModules tables (if not created by init_db)"""
conn = get_db()
if not table_exists('Modules'):
conn.execute('''
CREATE TABLE Modules (
module_id INTEGER PRIMARY KEY AUTOINCREMENT,
module_name TEXT NOT NULL,
module_key TEXT UNIQUE NOT NULL,
description TEXT,
icon TEXT,
is_active INTEGER DEFAULT 1,
display_order INTEGER DEFAULT 0
)
''')
print(" Created Modules table")
if not table_exists('UserModules'):
conn.execute('''
CREATE TABLE UserModules (
user_module_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id INTEGER NOT NULL,
granted_by INTEGER,
granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES Users(user_id),
FOREIGN KEY (module_id) REFERENCES Modules(module_id),
FOREIGN KEY (granted_by) REFERENCES Users(user_id),
UNIQUE(user_id, module_id)
)
''')
print(" Created UserModules table")
conn.commit()
conn.close()
def migration_002_add_usermodules_granted_columns():
"""Add granted_by and granted_timestamp to UserModules if missing"""
conn = get_db()
if table_exists('UserModules'):
if not column_exists('UserModules', 'granted_by'):
conn.execute('ALTER TABLE UserModules ADD COLUMN granted_by INTEGER')
print(" Added granted_by column to UserModules")
if not column_exists('UserModules', 'granted_timestamp'):
conn.execute('ALTER TABLE UserModules ADD COLUMN granted_timestamp DATETIME')
print(" Added granted_timestamp column to UserModules")
conn.commit()
conn.close()
def migration_003_add_module_registry():
"""Add module_registry table for new module manager system"""
conn = get_db()
if not table_exists('module_registry'):
conn.execute('''
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
)
''')
print(" Created module_registry table")
conn.commit()
conn.close()
# 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_module_registry', migration_003_add_module_registry),
]
def run_migrations():
"""Run all pending core migrations"""
print("🔄 Checking core database migrations...")
# Make sure migrations table exists
init_migrations_table()
# Get already-applied migrations
applied = get_applied_migrations()
# Run pending migrations
pending = [(v, n, f) for v, n, f in MIGRATIONS if v not in applied]
if not pending:
print("✅ Core database is up to date")
return
print(f"📦 Running {len(pending)} core migration(s)...")
for version, name, func in pending:
print(f"\n Migration {version}: {name}")
try:
func()
record_migration(version, name)
print(f" ✅ Migration {version} complete")
except Exception as e:
print(f" ❌ Migration {version} failed: {e}")
raise
print("\n✅ All core migrations complete")
if __name__ == '__main__':
run_migrations()

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,157 @@
"""
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")
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),
]

1270
modules/conssheets/routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Add {{ 'Header' if table_type == 'header' else 'Detail' }} Field - {{ process.process_name }} - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center;">Add {{ 'Header' if table_type == 'header' else 'Detail' }} Field</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">{{ process.process_name }}</p>
<form method="POST" class="form-card">
<div class="form-group">
<label for="field_label" class="form-label">Field Label *</label>
<input type="text" id="field_label" name="field_label" class="form-input"
placeholder="e.g., Lot Number" required autofocus>
<p class="form-hint">Display name (field_name is auto-generated)</p>
</div>
<div class="form-group">
<label for="field_type" class="form-label">Data Type *</label>
<select id="field_type" name="field_type" class="form-input" required>
<option value="TEXT">TEXT (words, codes, etc.)</option>
<option value="INTEGER">INTEGER (whole numbers)</option>
<option value="REAL">REAL (decimals, weights)</option>
<option value="DATE">DATE</option>
<option value="DATETIME">DATETIME</option>
</select>
</div>
<div class="form-group">
<label for="max_length" class="form-label">Max Length</label>
<input type="number" id="max_length" name="max_length" class="form-input"
placeholder="Leave blank for no limit" min="1">
<p class="form-hint">Only applies to TEXT fields</p>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_required" value="1">
<span>Required field</span>
</label>
</div>
{% if table_type == 'detail' %}
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_duplicate_key" value="1">
<span>Use for duplicate detection</span>
</label>
<p class="form-hint">Check this for the field that should be checked for duplicates (e.g., Lot Number)</p>
</div>
{% endif %}
<div class="form-group">
<label for="excel_cell" class="form-label">Excel Cell{% if table_type == 'detail' %} (Column){% endif %}</label>
<input type="text" id="excel_cell" name="excel_cell" class="form-input"
placeholder="{% if table_type == 'header' %}e.g., A3{% else %}e.g., A{% endif %}"
style="text-transform: uppercase;">
<p class="form-hint">
{% if table_type == 'header' %}
Full cell reference (e.g., A3, B5)
{% else %}
Column letter only (e.g., A, B) — row is calculated
{% endif %}
</p>
</div>
<div class="form-actions">
<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>
</div>
</div>
{% endblock %}

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

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Create Process - Consumption Sheets - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center; margin-bottom: var(--space-xl);">Create New Process</h1>
<form method="POST" class="form-card">
<div class="form-group">
<label for="process_name" class="form-label">Process Name</label>
<input type="text"
id="process_name"
name="process_name"
class="form-input"
placeholder="e.g., AD WIP"
required
autofocus>
<p class="form-hint">This will be displayed in menus and reports</p>
</div>
<div class="form-actions">
<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>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Edit Field - {{ process.process_name }} - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="form-container" style="max-width: 600px; margin: 0 auto;">
<h1 class="page-title" style="text-align: center;">Edit Field</h1>
<p class="page-subtitle" style="text-align: center; margin-bottom: var(--space-xl);">
{{ process.process_name }} — {{ 'Header' if field.table_type == 'header' else 'Detail' }}
</p>
<form method="POST" class="form-card">
<div class="form-group">
<label class="form-label">Field Name</label>
<input type="text" class="form-input" value="{{ field.field_name }}" disabled
style="opacity: 0.6; font-family: var(--font-mono);">
<p class="form-hint">Cannot be changed (used in database)</p>
</div>
<div class="form-group">
<label for="field_label" class="form-label">Field Label *</label>
<input type="text" id="field_label" name="field_label" class="form-input"
value="{{ field.field_label }}" required autofocus>
</div>
<div class="form-group">
<label for="field_type" class="form-label">Data Type *</label>
<select id="field_type" name="field_type" class="form-input" required>
<option value="TEXT" {% if field.field_type == 'TEXT' %}selected{% endif %}>TEXT</option>
<option value="INTEGER" {% if field.field_type == 'INTEGER' %}selected{% endif %}>INTEGER</option>
<option value="REAL" {% if field.field_type == 'REAL' %}selected{% endif %}>REAL</option>
<option value="DATE" {% if field.field_type == 'DATE' %}selected{% endif %}>DATE</option>
<option value="DATETIME" {% if field.field_type == 'DATETIME' %}selected{% endif %}>DATETIME</option>
</select>
</div>
<div class="form-group">
<label for="max_length" class="form-label">Max Length</label>
<input type="number" id="max_length" name="max_length" class="form-input"
value="{{ field.max_length or '' }}" min="1">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_required" value="1" {% if field.is_required %}checked{% endif %}>
<span>Required field</span>
</label>
</div>
{% if field.table_type == 'detail' %}
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_duplicate_key" value="1" {% if field.is_duplicate_key %}checked{% endif %}>
<span>Use for duplicate detection</span>
</label>
<p class="form-hint">Check this for the field that should be checked for duplicates (e.g., Lot Number)</p>
</div>
{% endif %}
<div class="form-group">
<label for="excel_cell" class="form-label">Excel Cell{% if field.table_type == 'detail' %} (Column){% endif %}</label>
<input type="text" id="excel_cell" name="excel_cell" class="form-input"
value="{{ field.excel_cell or '' }}" style="text-transform: uppercase;">
</div>
<div class="form-actions">
<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>
</div>
</div>
{% endblock %}

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

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}New {{ process.process_name }} Session - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<form method="POST" class="form-card">
{% for field in header_fields %}
<div class="form-group">
<label for="{{ field.field_name }}" class="form-label">
{{ field.field_label }}
{% if field.is_required %}<span class="required">*</span>{% endif %}
</label>
{% if field.field_type == 'DATE' %}
<input type="date"
id="{{ field.field_name }}"
name="{{ field.field_name }}"
class="form-input"
value="{{ form_data.get(field.field_name, '') }}"
{% if field.is_required %}required{% endif %}>
{% elif field.field_type == 'DATETIME' %}
<input type="datetime-local"
id="{{ field.field_name }}"
name="{{ field.field_name }}"
class="form-input"
value="{{ form_data.get(field.field_name, '') }}"
{% if field.is_required %}required{% endif %}>
{% elif field.field_type == 'INTEGER' %}
<input type="number"
id="{{ field.field_name }}"
name="{{ field.field_name }}"
class="form-input"
value="{{ form_data.get(field.field_name, '') }}"
step="1"
{% if field.is_required %}required{% endif %}>
{% elif field.field_type == 'REAL' %}
<input type="number"
id="{{ field.field_name }}"
name="{{ field.field_name }}"
class="form-input"
value="{{ form_data.get(field.field_name, '') }}"
step="0.01"
{% if field.is_required %}required{% endif %}>
{% else %}
<input type="text"
id="{{ field.field_name }}"
name="{{ field.field_name }}"
class="form-input"
value="{{ form_data.get(field.field_name, '') }}"
{% if field.max_length %}maxlength="{{ field.max_length }}"{% endif %}
{% if field.is_required %}required{% endif %}>
{% endif %}
</div>
{% endfor %}
{% if not header_fields %}
<div class="empty-state-small">
<p>No header fields configured for this process.</p>
<p>Contact your administrator to set up fields.</p>
</div>
{% endif %}
<div class="form-actions">
<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
</button>
</div>
</form>
</div>
</div>
<style>
.required {
color: var(--color-danger);
}
.empty-state-small {
text-align: center;
padding: var(--space-xl);
color: var(--color-text-muted);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,264 @@
{% extends "base.html" %}
{% block title %}{{ process.process_name }} - Consumption Sheets - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">{{ process.process_name }}</h1>
<p class="page-subtitle">Key: <code style="font-family: var(--font-mono); color: var(--color-primary);">{{ process.process_key }}</code></p>
</div>
</div>
<!-- Configuration Options -->
<div class="config-grid">
<!-- Database Configuration Card -->
<div class="config-card">
<div class="config-card-header">
<div class="config-icon">🗄️</div>
<h2 class="config-title">Database</h2>
</div>
<p class="config-desc">Define fields for header and detail tables</p>
<div class="config-stats">
<div class="config-stat">
<span class="stat-number">{{ header_fields|length }}</span>
<span class="stat-label">Header Fields</span>
</div>
<div class="config-stat">
<span class="stat-number">{{ detail_fields|length }}</span>
<span class="stat-label">Detail Fields</span>
</div>
</div>
<a href="{{ url_for('conssheets.process_fields', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Fields
</a>
</div>
<!-- Excel Template Card -->
<div class="config-card">
<div class="config-card-header">
<div class="config-icon">📊</div>
<h2 class="config-title">Excel Template</h2>
</div>
<p class="config-desc">Upload template and map fields to cells</p>
<div class="config-stats">
<div class="config-stat">
{% if process.template_file %}
<span class="stat-number" style="color: var(--color-success);"></span>
<span class="stat-label">{{ process.template_filename or 'Uploaded' }}</span>
{% else %}
<span class="stat-number" style="color: var(--color-warning);"></span>
<span class="stat-label">No template</span>
{% endif %}
</div>
<div class="config-stat">
<span class="stat-number">{{ process.rows_per_page or 30 }}</span>
<span class="stat-label">Rows/Page</span>
</div>
</div>
<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 -->
{% if header_fields or detail_fields %}
<div class="fields-preview">
<h3 class="section-title">Field Preview</h3>
{% if header_fields %}
<div class="field-section">
<h4 class="field-section-title">Header Fields</h4>
<div class="field-list">
{% for field in header_fields %}
<div class="field-chip">
<span class="field-name">{{ field.field_label }}</span>
<span class="field-type">{{ field.field_type }}</span>
{% if field.excel_cell %}
<span class="field-cell">→ {{ field.excel_cell }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if detail_fields %}
<div class="field-section">
<h4 class="field-section-title">Detail Fields</h4>
<div class="field-list">
{% for field in detail_fields %}
<div class="field-chip">
<span class="field-name">{{ field.field_label }}</span>
<span class="field-type">{{ field.field_type }}</span>
{% if field.excel_cell %}
<span class="field-cell">→ Col {{ field.excel_cell }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
<style>
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-xl);
margin-top: var(--space-xl);
}
.config-card {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
transition: var(--transition);
}
.config-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-glow);
}
.config-card-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.config-icon {
font-size: 2rem;
}
.config-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.config-desc {
color: var(--color-text-muted);
margin-bottom: var(--space-lg);
}
.config-stats {
display: flex;
gap: var(--space-xl);
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--color-surface-elevated);
border-radius: var(--radius-md);
}
.config-stat {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.config-stat .stat-number {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
font-family: var(--font-mono);
}
.config-stat .stat-label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fields-preview {
margin-top: var(--space-2xl);
padding-top: var(--space-xl);
border-top: 2px solid var(--color-border);
}
.field-section {
margin-bottom: var(--space-lg);
}
.field-section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md);
}
.field-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.field-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.field-name {
color: var(--color-text);
font-weight: 600;
}
.field-type {
color: var(--color-text-dim);
font-size: 0.75rem;
font-family: var(--font-mono);
}
.field-cell {
color: var(--color-primary);
font-size: 0.75rem;
font-family: var(--font-mono);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,173 @@
{% extends "base.html" %}
{% block title %}Fields - {{ process.process_name }} - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">Database Fields</h1>
<p class="page-subtitle">{{ process.process_name }}</p>
</div>
</div>
<!-- Header Fields Section -->
<div class="fields-section">
<div class="section-header">
<h2 class="section-title">Header Fields</h2>
<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>
{% if header_fields %}
<div class="fields-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Required</th>
<th>Excel Cell</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for field in header_fields %}
<tr>
<td><code>{{ field.field_name }}</code></td>
<td>{{ field.field_label }}</td>
<td>{{ field.field_type }}</td>
<td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</td>
<td>
<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 }}"
class="btn btn-sm"
style="background: var(--color-danger); color: white;">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state-small">
<p>No header fields defined yet.</p>
</div>
{% endif %}
</div>
<!-- Detail Fields Section -->
<div class="fields-section">
<div class="section-header">
<h2 class="section-title">Detail Fields</h2>
<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>
{% if detail_fields %}
<div class="fields-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Required</th>
<th>Excel Column</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for field in detail_fields %}
<tr>
<td><code>{{ field.field_name }}</code></td>
<td>{{ field.field_label }}</td>
<td>{{ field.field_type }}</td>
<td>{{ '✓' if field.is_required else '—' }}</td>
<td>{{ field.excel_cell or '—' }}</td>
<td>
<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 }}"
class="btn btn-sm"
style="background: var(--color-danger); color: white;">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state-small">
<p>No detail fields defined yet.</p>
</div>
{% endif %}
</div>
</div>
<style>
.fields-section {
margin-top: var(--space-xl);
padding: var(--space-lg);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.fields-table-wrapper {
overflow-x: auto;
}
.empty-state-small {
text-align: center;
padding: var(--space-xl);
color: var(--color-text-muted);
}
</style>
<script>
function confirmDelete(btn) {
// Read values from data attributes
const fieldId = btn.dataset.id;
const fieldLabel = btn.dataset.label;
if (confirm('Delete field "' + fieldLabel + '"?\n\nThis will soft-delete the field (data preserved but hidden).')) {
fetch('{{ url_for("conssheets.delete_field", process_id=process.id, field_id=0) }}'.replace('0', fieldId), {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.message);
}
});
}
}
</script>
{% endblock %}

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

@@ -0,0 +1,293 @@
{% extends "base.html" %}
{% block title %}Excel Template - {{ process.process_name }} - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="mode-selector">
<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>
<div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">Excel Template</h1>
<p class="page-subtitle">{{ process.process_name }}</p>
</div>
</div>
<div class="template-grid">
<!-- Upload Section -->
<div class="config-section">
<h2 class="section-title">Template File</h2>
<div class="current-template">
{% if process.template_filename %}
<div class="template-info">
<span class="template-icon">📄</span>
<span class="template-name">{{ process.template_filename }}</span>
<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('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>
<p class="form-hint">Excel files (.xlsx) only</p>
</div>
<button type="submit" class="btn btn-primary">Upload</button>
</form>
</div>
<!-- Settings Section -->
<div class="config-section">
<h2 class="section-title">Page Settings</h2>
<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 (Capacity)</label>
<input type="number" id="rows_per_page" name="rows_per_page"
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="5000" class="form-input">
<p class="form-hint">Excel row number where detail data begins</p>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
<!-- Field Mapping Summary -->
<div class="mapping-section">
<h2 class="section-title">Field Mappings</h2>
<p class="section-desc">Excel cell mappings are configured per-field in the Database section. Here's a summary:</p>
{% if header_fields or detail_fields %}
<div class="mapping-grid">
{% if header_fields %}
<div class="mapping-group">
<h3 class="mapping-group-title">Header Fields</h3>
<table class="mapping-table">
<thead>
<tr>
<th>Field</th>
<th>Excel Cell</th>
</tr>
</thead>
<tbody>
{% for field in header_fields %}
<tr>
<td>{{ field.field_label }}</td>
<td>
{% if field.excel_cell %}
<code>{{ field.excel_cell }}</code>
{% else %}
<span class="not-mapped">Not mapped</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if detail_fields %}
<div class="mapping-group">
<h3 class="mapping-group-title">Detail Fields</h3>
<p class="mapping-note">Columns only — rows start at {{ process.detail_start_row or 10 }}</p>
<table class="mapping-table">
<thead>
<tr>
<th>Field</th>
<th>Excel Column</th>
</tr>
</thead>
<tbody>
{% for field in detail_fields %}
<tr>
<td>{{ field.field_label }}</td>
<td>
{% if field.excel_cell %}
<code>{{ field.excel_cell }}</code>
{% else %}
<span class="not-mapped">Not mapped</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<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('conssheets.process_fields', process_id=process.id) }}">Add fields first</a>.</p>
</div>
{% endif %}
</div>
</div>
<style>
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-xl);
margin-top: var(--space-xl);
}
.config-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
}
.current-template {
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--color-surface-elevated);
border-radius: var(--radius-md);
}
.template-info {
display: flex;
align-items: center;
gap: var(--space-md);
}
.template-icon {
font-size: 1.5rem;
}
.template-name {
flex: 1;
font-family: var(--font-mono);
color: var(--color-primary);
}
.no-template {
color: var(--color-text-muted);
font-style: italic;
}
.upload-form {
margin-top: var(--space-lg);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.mapping-section {
margin-top: var(--space-xl);
padding: var(--space-xl);
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
}
.section-desc {
color: var(--color-text-muted);
margin-bottom: var(--space-lg);
}
.mapping-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-xl);
}
.mapping-group-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-sm);
}
.mapping-note {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-bottom: var(--space-sm);
}
.mapping-table {
width: 100%;
border-collapse: collapse;
}
.mapping-table th,
.mapping-table td {
padding: var(--space-sm);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.mapping-table th {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
}
.mapping-table code {
background: var(--color-primary-glow);
color: var(--color-primary);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.not-mapped {
color: var(--color-warning);
font-size: 0.8rem;
}
.empty-state-small {
text-align: center;
padding: var(--space-xl);
color: var(--color-text-muted);
}
.empty-state-small a {
color: var(--color-primary);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,641 @@
{% extends "base.html" %}
{% block title %}Scanning - {{ session.process_name }} - ScanLook{% endblock %}
{% block content %}
<div class="count-location-container">
<div class="location-header">
<div class="location-info">
<a href="{{ url_for('conssheets.index') }}" class="breadcrumb">← Back to Sessions</a>
<div class="location-label">{{ session.process_name }}</div>
<div class="header-values">
{% for hv in header_values %}
<span class="header-pill"><strong>{{ hv.field_label }}:</strong> {{ hv.field_value }}</span>
{% endfor %}
</div>
<div class="location-stats">
<span class="stat-pill">Scanned: <span id="scanCount">{{ scans|length }}</span></span>
</div>
</div>
</div>
{% if not dup_key_field %}
<div class="alert alert-warning" style="margin: var(--space-lg) 0; padding: var(--space-lg); background: rgba(255,170,0,0.2); border-radius: var(--radius-md);">
<strong>⚠️ No duplicate key configured!</strong><br>
Please configure a detail field with "Use for duplicate detection" checked in the admin panel.
</div>
{% endif %}
<div class="scan-card" style="border: 2px solid var(--color-primary); margin-bottom: 20px;">
<div class="scan-header" style="background: rgba(0, 123, 255, 0.1);">
<h2 class="scan-title" style="color: var(--color-primary);">🚀 Smart Router Scan</h2>
</div>
<div class="scan-form">
<div class="scan-input-group">
<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>
<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">
<div class="duplicate-lot-number" id="dupSameLotNumber"></div>
<h3 class="duplicate-title" style="color: var(--color-duplicate);">Already Scanned</h3>
<p class="duplicate-message">This was already scanned in this session.<br>Add it again?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary btn-lg" onclick="cancelDuplicate()">No</button>
<button type="button" class="btn btn-lg" style="background: var(--color-duplicate); color: white;" onclick="confirmDuplicate()">Yes, Add</button>
</div>
</div>
</div>
<div id="duplicateOtherModal" class="modal">
<div class="modal-content modal-duplicate">
<div class="duplicate-lot-number" id="dupOtherLotNumber"></div>
<h3 class="duplicate-title" style="color: var(--color-warning);">⚠️ Previously Consumed</h3>
<p class="duplicate-message" id="dupOtherInfo"></p>
<p class="duplicate-message">Are you sure you want to add it?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary btn-lg" onclick="cancelDuplicate()">No</button>
<button type="button" class="btn btn-lg" style="background: var(--color-warning); color: var(--color-bg);" onclick="confirmDuplicate()">Yes, Add</button>
</div>
</div>
</div>
<div id="fieldsModal" class="modal">
<div class="modal-content">
<h3 class="modal-title">Enter Details</h3>
<div class="modal-lot-info">
<div id="modalDupKeyValue" class="modal-lot-number"></div>
</div>
<form id="fieldsForm" class="weight-form">
{% for field in detail_fields %}
{% if not field.is_duplicate_key %}
<div class="form-group">
<label class="form-label">{{ field.field_label }}{% if field.is_required %}<span style="color: var(--color-danger);">*</span>{% endif %}</label>
{% if field.field_type == 'REAL' %}
<input type="number" id="field_{{ field.field_name }}" name="{{ field.field_name }}" class="form-input" step="0.01" inputmode="decimal" {% if field.is_required %}required{% endif %}>
{% elif field.field_type == 'INTEGER' %}
<input type="number" id="field_{{ field.field_name }}" name="{{ field.field_name }}" class="form-input" step="1" {% if field.is_required %}required{% endif %}>
{% elif field.field_type == 'DATE' %}
<input type="date" id="field_{{ field.field_name }}" name="{{ field.field_name }}" class="form-input" {% if field.is_required %}required{% endif %}>
{% else %}
<input type="text" id="field_{{ field.field_name }}" name="{{ field.field_name }}" class="form-input" {% if field.max_length %}maxlength="{{ field.max_length }}"{% endif %} {% if field.is_required %}required{% endif %}>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="cancelFields()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSmartScanData()">Save</button>
</div>
</form>
</div>
</div>
<div class="scans-section">
<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 id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% for scan in scans %}
<div class="scan-row 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>
{% endfor %}
<div class="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 %}
</div>
</div>
{% endfor %}
</div>
</div>
<div id="scanDetailModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header-bar">
<h3 class="modal-title">Scan Details</h3>
<button type="button" class="btn-close-modal" onclick="closeScanDetail()"></button>
</div>
<div id="scanDetailContent" class="scan-detail-content"></div>
</div>
</div>
<div class="finish-section">
<div class="action-buttons-row">
<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>
<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>
<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>
<script id="session-data" type="application/json">
{
"detailFields": {{ detail_fields|tojson|safe }},
"dupKeyFieldName": {{ (dup_key_field.field_name if dup_key_field else '')|tojson|safe }},
"sessionId": {{ session.id }}
}
</script>
<script>
// Read data from the JSON block above
const sessionData = JSON.parse(document.getElementById('session-data').textContent);
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) {
isSmartScan = true;
currentDupKeyValue = barcode;
if (data.duplicate_status === 'dup_same_session') {
// Try to fill display element, fallback to simple ID
let el = document.getElementById('dupSameLotNumber');
if(el) el.textContent = barcode;
document.getElementById('duplicateSameModal').style.display = 'flex';
} else {
let el = document.getElementById('dupOtherLotNumber');
if(el) el.textContent = barcode;
if(document.getElementById('dupOtherInfo')) document.getElementById('dupOtherInfo').textContent = data.message;
document.getElementById('duplicateOtherModal').style.display = 'flex';
}
// IMPORTANT: Refocus away from scanner to prevent phantom enters
document.activeElement.blur();
return;
}
// --- 2. HANDLE "OPEN DETAILS FORM" ---
if (data.needs_input) {
// CRITICAL FIX: Save the barcode globally so the 'Save' button can use it
currentDupKeyValue = barcode;
const parsedData = data.data;
// Populate fields
for (const [key, value] of Object.entries(parsedData)) {
let el = document.getElementById(key);
if (!el) el = document.querySelector(`[name="${key}"]`);
if (el) {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) {
el.value = value;
} else {
el.textContent = value;
}
}
let displayEl = document.getElementById(key + '_display') || document.getElementById('display_' + key);
if (displayEl) displayEl.textContent = value;
}
showFieldsModal();
smartInput.value = '';
return;
}
// --- 3. STANDARD SUCCESS/FAIL ---
if(smartInput) {
smartInput.value = '';
smartInput.focus();
}
feedbackArea.style.display = 'block';
if (data.success) {
feedbackArea.style.background = 'rgba(40, 167, 69, 0.2)';
feedbackArea.style.border = '1px solid #28a745';
feedbackText.style.color = '#28a745';
feedbackText.textContent = data.message;
if (data.message.includes('Saved')) setTimeout(() => location.reload(), 800);
} else {
feedbackArea.style.background = 'rgba(220, 53, 69, 0.2)';
feedbackArea.style.border = '1px solid #dc3545';
feedbackText.style.color = '#dc3545';
feedbackText.textContent = data.message;
}
})
.catch(err => {
if(smartInput) smartInput.disabled = false;
console.error(err); // Log it, don't popup alert to annoy user
});
}
// Function to handle the "Save" click from the Details Modal
// Function to handle the "Save" click from the Details Modal
function saveSmartScanData() {
// 1. Validate we have the original barcode
if (!currentDupKeyValue) {
alert("Error: Original barcode lost. Please scan again.");
return;
}
// 2. Collect inputs using NAME ONLY (Fixes the database error)
const form = document.getElementById('fieldsForm');
if (!form) return console.error("Form not found!");
let formData = {};
// Grab all inputs, but ONLY use the 'name' attribute
form.querySelectorAll('input, select').forEach(el => {
if (el.name) {
formData[el.name] = el.value;
}
});
// 3. Send the payload
fetch(`/conssheets/session/${sessionId}/scan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
barcode: currentDupKeyValue, // The original scan (TEST10)
field_values: formData, // The Clean Data (weight: 164)
confirm_duplicate: true, // Bypass duplicate check
extra_data: formData
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('fieldsModal').style.display = 'none';
// Optional: Small delay to let the user see it worked
setTimeout(() => location.reload(), 300);
} else if (data.needs_input) {
alert("Error: Please fill in all required fields.");
} else {
alert("System Message: " + data.message);
}
})
.catch(err => console.error(err));
}
function resetSmartScan() {
document.getElementById('smartScanner').value = '';
document.getElementById('smartScanner').focus();
document.getElementById('routerFeedback').style.display = 'none';
}
// Standard variables
let currentDupKeyValue = '';
let currentDuplicateStatus = '';
let isDuplicateConfirmed = false;
let isProcessing = false;
let isSmartScan = false;
document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault();
if (isProcessing) return;
const scannedValue = document.getElementById('dupKeyInput').value.trim();
if (!scannedValue) return;
isProcessing = true;
currentDupKeyValue = scannedValue;
document.getElementById('dupKeyInput').value = '';
checkDuplicate();
});
function checkDuplicate() {
const fieldValues = {};
fieldValues[dupKeyFieldName] = currentDupKeyValue;
fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, check_only: true })
})
.then(r => r.json())
.then(data => {
if (data.needs_confirmation) {
currentDuplicateStatus = data.duplicate_status;
if (data.duplicate_status === 'dup_same_session') {
document.getElementById('dupSameLotNumber').textContent = currentDupKeyValue;
document.getElementById('duplicateSameModal').style.display = 'flex';
} else if (data.duplicate_status === 'dup_other_session') {
document.getElementById('dupOtherLotNumber').textContent = currentDupKeyValue;
document.getElementById('dupOtherInfo').textContent = data.duplicate_info;
document.getElementById('duplicateOtherModal').style.display = 'flex';
}
} else {
showFieldsModal();
}
})
.catch(error => { console.error('Error:', error); showFieldsModal(); });
}
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 = '';
}
function showFieldsModal() {
document.getElementById('modalDupKeyValue').textContent = currentDupKeyValue;
document.getElementById('fieldsModal').style.display = 'flex';
const form = document.getElementById('fieldsForm');
form.reset();
// Focus on first required field, or first input if none required
const firstRequired = form.querySelector('input[required]');
const firstInput = form.querySelector('input:not([disabled])');
if (firstRequired) firstRequired.focus();
else if (firstInput) firstInput.focus();
}
function cancelFields() {
document.getElementById('fieldsModal').style.display = 'none';
document.getElementById('dupKeyInput').focus();
isDuplicateConfirmed = false;
isProcessing = false;
currentDuplicateStatus = '';
}
document.getElementById('fieldsForm').addEventListener('submit', function(e) {
e.preventDefault();
submitScan();
});
function submitScan() {
const fieldValues = {};
fieldValues[dupKeyFieldName] = currentDupKeyValue;
detailFields.forEach(field => {
if (!field.is_duplicate_key) {
const input = document.getElementById('field_' + field.field_name);
if (input) fieldValues[field.field_name] = input.value;
}
});
fetch(`/conssheets/session/${sessionId}/scan`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, confirm_duplicate: isDuplicateConfirmed })
})
.then(r => r.json())
.then(data => {
if (data.success) {
document.getElementById('fieldsModal').style.display = 'none';
if (data.updated_entry_ids && data.updated_entry_ids.length > 0) {
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';
}
});
}
addScanToList(data.detail_id, fieldValues, data.duplicate_status);
currentDupKeyValue = '';
isDuplicateConfirmed = false;
isProcessing = false;
currentDuplicateStatus = '';
document.getElementById('dupKeyInput').focus();
} else {
alert(data.message || 'Error saving scan');
isProcessing = false;
}
})
.catch(error => { console.error('Error:', error); alert('Error saving scan'); isProcessing = false; });
}
function addScanToList(detailId, fieldValues, duplicateStatus) {
const scansList = document.getElementById('scansList');
let statusClass = duplicateStatus || 'normal';
let statusDot = 'green', statusText = 'OK';
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.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="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');
const scanCountSpan = document.getElementById('scanCount');
const newCount = scansList.children.length;
if (countSpan) countSpan.textContent = newCount;
if (scanCountSpan) scanCountSpan.textContent = newCount;
}
function openScanDetail(detailId) {
fetch(`/conssheets/session/${sessionId}/detail/${detailId}`)
.then(r => r.json())
.then(data => {
if (data.success) displayScanDetail(data.detail);
else alert('Error loading details');
});
}
function displayScanDetail(detail) {
const content = document.getElementById('scanDetailContent');
let statusBadge = '<span class="badge badge-success">✓ OK</span>';
if (detail.duplicate_status === 'dup_same_session') statusBadge = '<span class="badge badge-duplicate">🔵 Duplicate</span>';
else if (detail.duplicate_status === 'dup_other_session') statusBadge = '<span class="badge badge-warning">🟠 Previously Consumed</span>';
let detailRows = '';
detailFields.forEach(field => {
let value = detail[field.field_name] || 'N/A';
if (field.field_type === 'REAL' && value !== 'N/A') value = parseFloat(value).toFixed(2);
detailRows += `<div class="detail-row"><span class="detail-label">${field.field_label}:</span><span class="detail-value">${value}</span></div>`;
});
let editFields = '';
detailFields.forEach(field => {
let inputType = 'text', extraAttrs = '';
if (field.field_type === 'REAL') { inputType = 'number'; extraAttrs = 'step="0.01"'; }
else if (field.field_type === 'INTEGER') { inputType = 'number'; extraAttrs = 'step="1"'; }
else if (field.field_type === 'DATE') { inputType = 'date'; }
let value = detail[field.field_name] || '';
editFields += `<div class="form-group"><label class="form-label">${field.field_label}</label><input type="${inputType}" id="edit_${field.field_name}" class="form-input" value="${value}" ${extraAttrs}></div>`;
});
content.innerHTML = `
<div class="detail-section">${detailRows}
<div class="detail-row"><span class="detail-label">Status:</span><span class="detail-value">${statusBadge}</span></div>
${detail.duplicate_info ? `<div class="detail-row"><span class="detail-label">Info:</span><span class="detail-value">${detail.duplicate_info}</span></div>` : ''}
<div class="detail-row"><span class="detail-label">Scanned:</span><span class="detail-value">${detail.scanned_at} by ${detail.scanned_by_name}</span></div>
</div>
<div class="detail-section"><h4 class="detail-section-title">Edit Scan</h4><div class="detail-form">${editFields}
<div class="form-group"><label class="form-label">Comment</label><textarea id="editComment" class="form-textarea" rows="3">${detail.comment || ''}</textarea></div>
</div></div>
<div class="detail-actions">
<button class="btn btn-secondary" onclick="closeScanDetail()">Cancel</button>
<button class="btn btn-danger" onclick="deleteDetail(${detail.id})">Delete</button>
<button class="btn btn-primary" onclick="saveDetail(${detail.id})">Save Changes</button>
</div>`;
document.getElementById('scanDetailModal').style.display = 'flex';
}
function closeScanDetail() { document.getElementById('scanDetailModal').style.display = 'none'; }
function saveDetail(detailId) {
const fieldValues = {};
detailFields.forEach(field => {
const input = document.getElementById('edit_' + field.field_name);
if (input) fieldValues[field.field_name] = input.value;
});
const comment = document.getElementById('editComment').value;
fetch(`/conssheets/session/${sessionId}/detail/${detailId}/update`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ field_values: fieldValues, comment: comment })
})
.then(r => r.json())
.then(data => {
if (data.success) { closeScanDetail(); location.reload(); }
else alert(data.message || 'Error updating');
});
}
function deleteDetail(detailId) {
if (!confirm('Delete this scan?')) return;
fetch(`/conssheets/session/${sessionId}/detail/${detailId}/delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) { closeScanDetail(); location.reload(); }
else alert(data.message || 'Error deleting');
});
}
function exportToExcel() {
window.location.href = `/conssheets/session/${sessionId}/export?format=xlsx`;
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.getElementById('fieldsModal').style.display = 'none';
document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none';
document.getElementById('scanDetailModal').style.display = 'none';
document.getElementById('dupKeyInput').focus();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,166 @@
{% 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">
<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('conssheets.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({{ s.id }}, '{{ 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(`/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');
}
});
}
</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

@@ -5,7 +5,7 @@
{% block content %}
<div class="count-container">
<div class="count-header">
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back</a>
<a href="{{ url_for('admin_dashboard') }}" class="breadcrumb">← Back</a>
<h1 class="page-title">{{ session.session_name }}</h1>
</div>

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Inventory Counts - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-header">
<div class="header-left">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary btn-sm" style="margin-right: var(--space-md);">
<i class="fa-solid fa-arrow-left"></i> Back to Admin
</a>
<div>
<h1 class="page-title">Inventory Counts</h1>
<p class="page-subtitle">Manage cycle counts and physical inventory</p>
</div>
</div>
<div class="header-right">
<label class="filter-toggle" style="margin-right: var(--space-lg);">
<input type="checkbox" id="showArchived" {% if show_archived %}checked{% endif %} onchange="toggleArchived()">
<span class="filter-label">Show Archived</span>
</label>
<a href="{{ url_for('invcount.create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session
</a>
</div>
</div>
{% if sessions %}
<div class="sessions-grid">
{% for session in sessions %}
<div class="session-card {% if session.status == 'archived' %}session-archived{% endif %}">
<div class="session-card-header">
<h3 class="session-name">
{{ session.session_name }}
{% if session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
</h3>
<span class="session-type-badge session-type-{{ session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
</span>
</div>
<div class="session-stats">
<div class="stat-item">
<div class="stat-value">{{ session.total_locations or 0 }}</div>
<div class="stat-label">Total Locations</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.completed_locations or 0 }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.in_progress_locations or 0 }}</div>
<div class="stat-label">In Progress</div>
</div>
</div>
<div class="session-meta">
<div class="meta-item">
<span class="meta-label">Created:</span>
<span class="meta-value">{{ session.created_timestamp[:16] }}</span>
</div>
<div class="meta-item">
<span class="meta-label">By:</span>
<span class="meta-value">{{ session.created_by_name }}</span>
</div>
</div>
<div class="session-actions">
<a href="{{ url_for('invcount.session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
View Details
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📋</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Create a new count session to get started</p>
</div>
{% endif %}
</div>
<script>
function toggleArchived() {
const checked = document.getElementById('showArchived').checked;
window.location.href = '{{ url_for("invcount.admin_dashboard") }}' + (checked ? '?show_archived=1' : '');
}
</script>
<style>
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xl);
}
.header-left { display: flex; align-items: center; }
.header-right { display: flex; align-items: center; }
</style>
{% endblock %}

View File

@@ -97,12 +97,12 @@
{% set row_class = 'weight_discrepancy' %}
{% endif %}
<div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}"
onclick="openScanDetail({{ scan.entry_id }})">
<div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}"
onclick="openScanDetail('{{ scan.entry_id }}')">
<div class="scan-row-lot">{{ scan.lot_number }}</div>
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
<div class="scan-row-weight">{{ '%.1f'|format(scan.actual_weight) if scan.actual_weight is not none else '-' }} lbs</div>
<div class="scan-row-status">
{% if scan.duplicate_status == '01' or scan.duplicate_status == '04' %}
<span class="status-dot status-dot-blue"></span> Duplicate
@@ -160,14 +160,22 @@
<div class="finish-section">
<div class="action-buttons-row">
<a href="{{ url_for('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'})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
<button class="scroll-to-bottom" onclick="window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'})">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<script>
@@ -175,6 +183,23 @@ let currentLotNumber = '';
let isDuplicateConfirmed = false;
let isProcessing = false;
function parseWeight(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function formatWeight(value) {
const num = parseWeight(value);
return num === null ? '-' : num.toFixed(1);
}
function weightsDiffer(actual, expected) {
const actualNum = parseWeight(actual);
const expectedNum = parseWeight(expected);
if (actualNum === null || expectedNum === null) return false;
return Math.abs(actualNum - expectedNum) >= 0.01;
}
// Lot scan handler
document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault();
@@ -191,7 +216,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
});
function checkDuplicate() {
fetch('{{ url_for("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({
@@ -260,7 +285,7 @@ function submitScan(weight) {
return;
}
fetch('{{ url_for("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({
@@ -378,7 +403,7 @@ function addScanToList(data, weight) {
statusDot = 'orange';
}
} else if (data.master_status === 'match') {
if (data.master_expected_weight && Math.abs(weight - data.master_expected_weight) >= 0.01) {
if (data.master_expected_weight && weightsDiffer(weight, data.master_expected_weight)) {
statusClass = 'weight_discrepancy';
statusText = 'Weight Off';
statusDot = 'orange';
@@ -404,7 +429,7 @@ function addScanToList(data, weight) {
scanRow.innerHTML = `
<div class="scan-row-lot">${currentLotNumber}</div>
<div class="scan-row-item">${data.item || 'N/A'}</div>
<div class="scan-row-weight">${weight} lbs</div>
<div class="scan-row-weight">${formatWeight(weight)} lbs</div>
<div class="scan-row-status">
<span class="status-dot status-dot-${statusDot}"></span> ${statusText}
</div>
@@ -441,7 +466,7 @@ function displayScanDetail(scan) {
// Check for weight discrepancy (Tolerance 0.01)
let isWeightOff = false;
if (scan.master_status === 'match' && scan.master_expected_weight) {
if (Math.abs(scan.actual_weight - scan.master_expected_weight) >= 0.01) {
if (weightsDiffer(scan.actual_weight, scan.master_expected_weight)) {
isWeightOff = true;
}
}
@@ -492,7 +517,7 @@ function displayScanDetail(scan) {
</div>
<div class="form-group">
<label class="form-label">Weight (lbs)</label>
<input type="number" id="editWeight" class="form-input" value="${scan.actual_weight}" step="0.01" min="0" inputmode="decimal">
<input type="number" id="editWeight" class="form-input" value="${formatWeight(scan.actual_weight)}" step="0.01" min="0" inputmode="decimal">
</div>
<div class="form-group">
<label class="form-label">Comment</label>
@@ -560,7 +585,7 @@ function deleteFromDetail(entryId) {
function finishLocation() {
if (!confirm('Are you finished counting this location?')) return;
fetch('{{ url_for("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

@@ -34,7 +34,7 @@
</div>
<div class="form-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Create Session</button>
</div>
</form>

View File

@@ -6,13 +6,22 @@
<div class="dashboard-container">
<div class="page-header">
<div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</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 %}
<p class="page-subtitle">Master File not uploaded yet. Please contact an admin before starting bins.</p>
{% endif %}
</div>
{% if count_session.master_baseline_timestamp %}
<button class="btn btn-primary" onclick="showStartBinModal()">
<span class="btn-icon">+</span> Start New Bin
</button>
{% else %}
<button class="btn btn-primary" disabled title="Upload a Master File to start bins">
<span class="btn-icon">+</span> Start New Bin
</button>
{% endif %}
</div>
<!-- Active Bins -->
@@ -37,17 +46,9 @@
</div>
</div>
<div class="bin-actions">
<a href="{{ url_for('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 %}
@@ -105,7 +106,7 @@
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button>
</div>
<form id="startBinForm" action="{{ url_for('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

@@ -6,12 +6,22 @@
<div class="session-detail-container">
<div class="session-detail-header">
<div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">{{ count_session.session_name }}</h1>
<a href="{{ url_for('admin_dashboard') }}{% if count_session.status == 'archived' %}?show_archived=1{% endif %}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">
{{ count_session.session_name }}
{% if count_session.status == 'archived' %}<span class="archived-badge">ARCHIVED</span>{% endif %}
</h1>
<span class="session-type-badge session-type-{{ count_session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
</span>
</div>
<div class="session-actions-header">
{% if count_session.status == 'archived' %}
<button class="btn btn-success" onclick="activateSession()">✓ Activate Session</button>
{% else %}
<button class="btn btn-secondary" onclick="archiveSession()">📦 Archive Session</button>
{% endif %}
</div>
</div>
<!-- Baseline Upload Section -->
@@ -30,7 +40,8 @@
{% endif %}
</div>
{% if not count_session.master_baseline_timestamp %}
<form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<!-- Note: Using data_imports blueprint URL -->
<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>
@@ -41,14 +52,14 @@
<div class="baseline-label">CURRENT Baseline (Optional)</div>
<div class="baseline-status">
{% if count_session.current_baseline_timestamp %}
<span class="status-badge status-success">Last Updated:
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div>
<span class="status-badge status-success">✓ Uploaded</span>
<small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
{% else %}
<span class="status-badge status-neutral">Not Uploaded</span>
{% 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">
@@ -61,43 +72,43 @@
</div>
<!-- Statistics Section -->
<div class="section-card">
<h2 class="section-title">Real-Time Statistics</h2>
<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', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div>
</div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div>
</div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div>
</div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div>
</div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div>
</div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div>
</div>
<div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match')">
<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" 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" 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" 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" 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" id="count-missing">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</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">
{% for counter in active_counters %}
<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>
<div class="counter-info">
@@ -114,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>
@@ -129,7 +145,12 @@
</thead>
<tbody>
{% for loc in locations %}
<tr class="location-row-clickable" onclick="showLocationDetails({{ loc.location_count_id }}, '{{ loc.location_name }}', '{{ loc.status }}')">
<!-- Refactored to use data attributes instead of direct Jinja injection in onclick -->
<tr class="location-row-clickable"
data-id="{{ loc.location_count_id }}"
data-name="{{ loc.location_name }}"
data-status="{{ loc.status }}"
onclick="handleLocationClick(this)">
<td><strong>{{ loc.location_name }}</strong></td>
<td>
<span class="status-badge status-{{ loc.status }}">
@@ -141,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>
@@ -182,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>
@@ -246,7 +270,10 @@
</div>
<script>
function showStatusDetails(status, sessionId) {
// Store the Session ID globally to use in functions without passing it every time
const CURRENT_SESSION_ID = "{{ count_session.session_id }}";
function showStatusDetails(status) {
document.getElementById('statusModal').style.display = 'flex';
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
@@ -261,8 +288,8 @@ function showStatusDetails(status, sessionId) {
};
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details
fetch(`/session/${sessionId}/status-details/${status}`)
// Fetch details using the blueprint URL structure
fetch(`/invcount/session/${CURRENT_SESSION_ID}/status-details/${status}`)
.then(response => response.json())
.then(data => {
if (data.success) {
@@ -421,6 +448,14 @@ let currentLocationName = '';
let currentLocationStatus = '';
let currentLocationData = null;
// New helper function to handle click from data attributes
function handleLocationClick(row) {
const id = row.getAttribute('data-id');
const name = row.getAttribute('data-name');
const status = row.getAttribute('data-status');
showLocationDetails(id, name, status);
}
function showLocationDetails(locationCountId, locationName, status) {
currentLocationId = locationCountId;
currentLocationName = locationName;
@@ -433,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';
@@ -446,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) {
@@ -594,7 +632,8 @@ function closeFinalizeConfirm() {
}
function confirmFinalize() {
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',
@@ -605,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';
@@ -625,7 +666,8 @@ function closeReopenConfirm() {
}
function confirmReopen() {
fetch(`/location/${currentLocationId}/reopen`, {
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
fetch(`/invcount/location/${currentLocationId}/reopen`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -655,5 +697,111 @@ document.addEventListener('keydown', function(e) {
closeReopenConfirm();
}
});
function archiveSession() {
if (!confirm('Archive this session? It will be hidden from the main dashboard but can be reactivated later.')) return;
fetch('{{ url_for("invcount.archive_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '{{ url_for("admin_dashboard") }}';
} else {
alert(data.message || 'Error archiving session');
}
});
}
function activateSession() {
if (!confirm('Reactivate this session? It will appear on the main dashboard again.')) return;
fetch('{{ url_for("invcount.activate_session", session_id=count_session.session_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error activating session');
}
});
}
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

@@ -7,9 +7,9 @@
<!-- Mode Selector (only for admins) -->
{% if session.role in ['owner', 'admin'] %}
<div class="mode-selector">
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'">
<a href="{{ url_for('admin_dashboard') }}" class="mode-btn">
👔 Admin Console
</button>
</a>
<button class="mode-btn mode-btn-active">
📦 Scanning Mode
</button>
@@ -17,16 +17,19 @@
{% endif %}
<div class="dashboard-header">
<a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md);">
<i class="fa-solid fa-arrow-left"></i> Back to Home
</a>
<h1 class="page-title">Select Count Session</h1>
</div>
{% if sessions %}
<div class="sessions-list">
{% for session in sessions %}
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item">
{% for s in sessions %}
<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">{{ session.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span>
<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>
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>

View File

@@ -1,2 +1,5 @@
Flask==3.0.0
Werkzeug==3.0.1
Flask==3.1.2
Werkzeug==3.1.5
openpyxl
Pillow
gunicorn==21.2.0

188
static/css/mobile.css Normal file
View File

@@ -0,0 +1,188 @@
/* ==================== MOBILE STYLES (Phones) ==================== */
/* Viewport: 360-767px | Target: min-width: 360px and max-width: 767px */
/* This file contains overrides for mobile phones (iPhone, Android, etc.) */
@media screen and (min-width: 360px) and (max-width: 767px) {
/* ---------------------------------------------------------
Base Typography
--------------------------------------------------------- */
html {
font-size: 14px;
}
/* ---------------------------------------------------------
Navbar
--------------------------------------------------------- */
.nav-content {
flex-direction: row;
gap: var(--space-md);
}
/* ---------------------------------------------------------
Forms
--------------------------------------------------------- */
.form-row {
grid-template-columns: 1fr;
}
/* ---------------------------------------------------------
Dashboard
--------------------------------------------------------- */
.dashboard-header {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.sessions-grid {
grid-template-columns: 1fr;
}
.session-stats {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.baseline-grid {
grid-template-columns: 1fr;
}
/* ---------------------------------------------------------
Scanning Interface
--------------------------------------------------------- */
.scan-input {
font-size: 1.25rem;
}
.location-name {
font-size: 2rem;
}
/* ---------------------------------------------------------
Modals
--------------------------------------------------------- */
.modal-content {
padding: var(--space-md);
margin: var(--space-xs);
}
.modal-header-bar {
padding: var(--space-md) 0;
margin-bottom: var(--space-md);
}
.modal-large,
.modal-xl {
width: 95%;
max-width: 95%;
margin: var(--space-xs);
}
/* ---------------------------------------------------------
Detail Sections (Scan Detail Modal)
--------------------------------------------------------- */
.detail-section {
padding: var(--space-md) 0;
}
.detail-section-title {
font-size: 1rem;
margin-bottom: var(--space-sm);
}
.detail-row {
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) 0;
}
.detail-label {
flex: none;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 1rem;
}
.detail-lot {
font-size: 0.95rem;
word-break: break-all;
}
.detail-input {
width: 100%;
font-size: 1rem;
}
.detail-form {
gap: var(--space-sm);
}
.detail-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) 0 0 0;
}
.detail-actions .btn {
width: 100%;
margin: 0;
}
/* ---------------------------------------------------------
Forms (Mobile)
--------------------------------------------------------- */
.form-group {
margin-bottom: var(--space-sm);
}
/* ---------------------------------------------------------
Scroll to Top or Bottom Buttonss
--------------------------------------------------------- */
/* ---------------------------------------------------------
Scroll Buttons - Floating, semi-transparent
--------------------------------------------------------- */
.scroll-to-top,
.scroll-to-bottom {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
width: 44px;
height: 44px;
background: rgba(0, 212, 255, 0.3);
color: var(--color-text);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 50%;
font-size: 1.3rem;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
z-index: 50;
transition: var(--transition);
}
.scroll-to-top:active,
.scroll-to-bottom:active {
background: rgba(0, 212, 255, 0.6);
transform: scale(0.95);
}
.scroll-to-top {
right: 10px;
bottom: 270px;
}
.scroll-to-bottom {
right: 10px;
bottom: 170px;
}
}

344
static/css/scanner.css Normal file
View File

@@ -0,0 +1,344 @@
/* ==================== SCANNER STYLES (MC9300) ==================== */
/* Viewport: 320 x 405 | Target: max-width: 359px */
/* This file contains overrides for Zebra MC9300 handheld scanners */
@media screen and (max-width: 359px) {
/* ---------------------------------------------------------
CSS Variables - Tighter spacing for small screen
--------------------------------------------------------- */
:root {
--space-2xl: 0.8rem;
--space-xl: 0.6rem;
--space-lg: 0.4rem;
--space-md: 0.3rem;
}
/* ---------------------------------------------------------
Navbar - Minimal, compact
--------------------------------------------------------- */
.navbar {
padding: 4px 10px;
height: 38px;
}
.logo {
font-size: 1rem;
}
.user-badge,
.role-pill,
.breadcrumb,
.settings-dropdown {
display: none;
}
.btn-logout {
padding: 2px 8px;
font-size: 0.7rem;
}
/* ---------------------------------------------------------
Page Header
--------------------------------------------------------- */
.page-title {
font-size: 1.1rem;
margin: 0;
padding: 2px 0;
}
.page-header {
margin-bottom: 5px;
flex-direction: row;
align-items: center;
}
/* ---------------------------------------------------------
Location Header - Compact single line top
--------------------------------------------------------- */
.location-header {
padding: 6px 10px;
margin-bottom: 6px;
border-width: 1px;
}
.location-info {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
gap: 0 8px;
align-items: center;
}
.location-label {
grid-column: 1;
grid-row: 1;
font-size: 0.6rem;
margin: 0;
}
.location-stats {
grid-column: 2;
grid-row: 1;
justify-content: flex-end;
gap: 4px;
}
.location-name {
grid-column: 1 / -1;
grid-row: 2;
font-size: 1.4rem;
margin: 0;
text-align: left;
}
.stat-pill {
padding: 2px 6px;
font-size: 0.65rem;
}
/* ---------------------------------------------------------
Scan Input Card - Compact
--------------------------------------------------------- */
.scan-card {
padding: 8px;
margin-bottom: 6px;
border-width: 0px;
}
.scan-header {
display: none;
}
.scan-input {
padding: 8px;
font-size: 1rem;
height: 38px;
border-width: 2px;
}
/* ---------------------------------------------------------
Scanned/Expected Lists
--------------------------------------------------------- */
.scans-section,
.expected-section {
padding: 5px;
background: none;
border: none;
}
.scans-header {
margin-bottom: 4px;
padding-bottom: 2px;
}
.scans-title {
font-size: 0.9rem;
}
/* ---------------------------------------------------------
Lists - No scroll boxes, natural page flow
--------------------------------------------------------- */
.scans-grid {
max-height: none;
overflow-y: visible;
gap: normal;
}
/* ---------------------------------------------------------
List Rows - 2-row layout for scanner
Row 1: Lot number (full width)
Row 2: Item + Weight
--------------------------------------------------------- */
.scan-row,
.expected-row {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 2px 8px;
padding: 6px 8px;
margin-bottom: 4px;
font-size: 0.8rem;
}
/* Lot spans full width on row 1 */
.scan-row-lot {
grid-column: 1 / -1;
font-size: 0.85rem;
}
/* Item on row 2, left */
.scan-row-item {
grid-column: 1;
grid-row: 2;
}
/* Weight on row 2, right */
.scan-row-weight {
grid-column: 2;
grid-row: 2;
text-align: right;
}
/* Hide status column on scanner */
.scan-row-status {
display: none;
}
/* ---------------------------------------------------------
Row Status Colors (more visible without status badge)
--------------------------------------------------------- */
.scan-row-match {
background: rgba(0, 255, 136, 0.15);
}
.scan-row-wrong_location {
background: rgba(255, 170, 0, 0.15);
}
.scan-row-ghost_lot {
background: rgba(179, 102, 255, 0.15);
}
.scan-row-weight_discrepancy {
background: rgba(255, 152, 0, 0.25);
}
.scan-row-duplicate-01,
.scan-row-duplicate-04 {
background: rgba(0, 163, 255, 0.15);
}
.scan-row-duplicate-03 {
background: rgba(255, 140, 0, 0.15);
}
/* ---------------------------------------------------------
Action Buttons (Back / Finish)
--------------------------------------------------------- */
.finish-section {
position: relative; /* Not fixed/floating */
bottom: 0;
margin-top: 10px;
}
.action-buttons-row {
display: flex;
gap: 8px;
}
.action-buttons-row .btn {
flex: 1;
padding: 8px;
font-size: 0.85rem;
min-height: 40px;
}
.btn-success {
background: var(--color-success);
box-shadow: none; /* Remove glow for performance */
}
/* ---------------------------------------------------------
Detail Sections (Scan Detail Modal)
--------------------------------------------------------- */
.detail-section {
padding: var(--space-md) 0;
}
.detail-section-title {
font-size: 1rem;
margin-bottom: var(--space-sm);
}
.detail-row {
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) 0;
}
.detail-label {
flex: none;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 1rem;
}
.detail-lot {
font-size: 0.95rem;
word-break: break-all;
}
.detail-input {
width: 100%;
font-size: 1rem;
}
.detail-form {
gap: var(--space-sm);
}
.detail-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) 0 0 0;
}
.detail-actions .btn {
width: 100%;
margin: 0;
}
/* ---------------------------------------------------------
Scroll to Top or Bottom Buttonss
--------------------------------------------------------- */
/* ---------------------------------------------------------
Scroll Buttons - Floating, semi-transparent
--------------------------------------------------------- */
.scroll-to-top,
.scroll-to-bottom {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
width: 44px;
height: 44px;
background: rgba(0, 212, 255, 0.3);
color: var(--color-text);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 50%;
font-size: 1.3rem;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
z-index: 50;
transition: var(--transition);
}
.scroll-to-top:active,
.scroll-to-bottom:active {
background: rgba(0, 212, 255, 0.6);
transform: scale(0.95);
}
.scroll-to-top {
right: 10px;
bottom: 150px;
}
.scroll-to-bottom {
right: 10px;
bottom: 70px;
}
/* ---------------------------------------------------------
Footer - Hidden on scanner
--------------------------------------------------------- */
.footer {
display: none;
}
}

View File

@@ -1,5 +1,6 @@
/* ScanLook - Inventory Management System CSS */
/* Mobile-first, high-contrast design optimized for warehouse scanners */
/* Desktop-first design - mobile/scanner overrides in separate files */
/* Files: style.css (base), mobile.css (phones), scanner.css (MC9300) */
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap');
@@ -1385,9 +1386,10 @@ body {
}
.scan-row-weight_discrepancy {
border-color: #ff9800; /* Orange Border */
background: rgba(255, 152, 0, 0.05); /* Very light orange background */
border-color: #ff9800;
background: rgba(255, 152, 0, 0.05);
}
.scan-row-wrong_location {
border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.05);
@@ -1696,12 +1698,6 @@ body {
gap: var(--space-lg);
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
.finish-section {
position: sticky;
bottom: var(--space-lg);
@@ -1713,123 +1709,6 @@ body {
gap: var(--space-md);
}
/* ==================== MOBILE OPTIMIZATIONS ==================== */
@media (max-width: 768px) {
html {
font-size: 14px;
}
.nav-content {
flex-direction: row;
gap: var(--space-md);
}
.sessions-grid {
grid-template-columns: 1fr;
}
.session-stats {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.baseline-grid {
grid-template-columns: 1fr;
}
.dashboard-header {
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.scan-input {
font-size: 1.25rem;
}
.location-name {
font-size: 2rem;
}
/* Scan Detail Modal Mobile Fixes */
.modal-content {
padding: var(--space-md);
margin: var(--space-xs);
}
.modal-header-bar {
padding: var(--space-md) 0;
margin-bottom: var(--space-md);
}
.detail-section {
padding: var(--space-md) 0;
}
.detail-row {
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) 0;
}
.detail-label {
flex: none;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 1rem;
}
.detail-lot {
font-size: 0.95rem;
word-break: break-all;
}
.detail-input {
width: 100%;
font-size: 1rem;
}
.detail-section-title {
font-size: 1rem;
margin-bottom: var(--space-sm);
}
.detail-form {
gap: var(--space-sm);
}
.form-group {
margin-bottom: var(--space-sm);
}
.modal-large,
.modal-xl {
width: 95%;
max-width: 95%;
margin: var(--space-xs);
}
.detail-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) 0 0 0;
}
.detail-actions .btn {
width: 100%;
margin: 0;
}
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 10px;
@@ -1926,21 +1805,6 @@ body {
color: var(--color-text-muted);
}
/* Colored column headers */
.detail-table th.col-counted {
background: var(--color-bg);
}
.detail-table th.col-expected {
background: #3d2d00;
color: var(--color-warning);
}
.detail-table th.col-current {
background: #00293d;
color: var(--color-duplicate);
}
.detail-table td {
padding: var(--space-md);
border: none;
@@ -2118,10 +1982,6 @@ body {
gap: var(--space-sm);
}
.btn-block {
width: 100%;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -2233,9 +2093,8 @@ body {
/* Expected rows look "pending" or "unscanned" */
.expected-row {
opacity: 0.7;
border-left: 4px solid var(--color-border); /* Neutral/Gray border */
background: var(--color-surface); /* Darker than scanned rows */
/* Use the same grid layout as scanned rows */
border-left: 4px solid var(--color-border);
background: var(--color-surface);
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr;
gap: var(--space-md);
@@ -2255,156 +2114,191 @@ body {
box-shadow: none;
}
/* ==================== ZEBRA MC9300 CONSOLIDATED FIX ==================== */
@media screen and (max-width: 480px) and (max-height: 600px) {
:root {
--space-2xl: 0.8rem !important;
--space-xl: 0.6rem !important;
--space-lg: 0.4rem !important;
--space-md: 0.3rem !important;
}
/* Header & Nav Hiding */
.user-badge, .role-pill, .breadcrumb { display: none !important; }
.navbar { padding: 4px 10px !important; height: 38px !important; }
.logo { font-size: 1rem !important; }
.btn-logout { padding: 2px 8px !important; font-size: 0.7rem !important; }
/* Compact Titles */
.page-title { font-size: 1.1rem !important; margin: 0 !important; padding: 2px 0 !important; }
.page-header { margin-bottom: 5px !important; flex-direction: row !important; align-items: center !important; }
/* Compact Location Header */
.location-header { padding: 5px !important; margin-bottom: 5px !important; border-width: 1px !important; }
.location-label { font-size: 0.6rem !important; margin: 0 !important; }
.location-name { font-size: 1.4rem !important; margin: 0 !important; }
.stat-pill { padding: 2px 6px !important; font-size: 0.7rem !important; }
/* Compact Input Area */
.scan-card { padding: 8px !important; margin-bottom: 6px !important; }
.scan-header { display: none !important; } /* Hide "Scan Lot Barcode" text to save space */
.scan-input {
padding: 8px !important;
font-size: 1.1rem !important;
height: 40px !important;
border-width: 2px !important;
}
/* ---------------------------------------------------------
UPDATED LIST LAYOUT (Single Line, No Status Text)
--------------------------------------------------------- */
/* Reconfigure grid to 3 columns: Lot | Item | Weight (Status hidden) */
.scan-row, .expected-row {
grid-template-columns: 2fr 1.5fr 1fr !important;
gap: 5px !important;
padding: 8px 6px !important;
margin-bottom: 2px !important;
font-size: 0.8rem !important;
/* Increase background opacity so color is visible without the badge */
}
/* Hide the status column completely */
.scan-row-status { display: none !important; }
/* Make background colors slightly more visible since the text is gone */
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; }
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; }
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; }
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; }
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; }
/* Font tweaks for readability on small screen */
.scan-row-lot { font-size: 0.9rem !important; }
.scan-row-item { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* List Containers */
.scans-section, .expected-section { padding: 4px !important; border-width: 1px !important; }
.scans-header { margin-bottom: 2px !important; padding-bottom: 2px !important; }
.scans-title { font-size: 0.85rem !important; }
/* Scrollable areas - constrain height so page doesn't scroll infinitely */
.scans-grid { max-height: 150px !important; overflow-y: auto !important; }
/* Action Buttons (Fixed at bottom) */
.finish-section { margin-top: 5px !important; padding-top: 5px !important; }
.action-buttons-row { display: flex !important; gap: 5px !important; }
.action-buttons-row .btn { padding: 8px !important; font-size: 0.8rem !important; height: 38px !important; }
/* Hide footer on scanner */
.footer { display: none !important; }
/* Hide scroll buttons on desktop */
.scroll-to-top,
.scroll-to-bottom {
display: none;
}
/* ==================== SCANNING INTERFACE (COUNT LOCATION) ==================== */
@media screen and (max-width: 480px) and (max-height: 600px) {
/* 1. Shrink the Location Header (R303D area) */
.location-header {
padding: 8px !important;
margin-bottom: 5px !important;
border-width: 1px !important;
}
.location-label { font-size: 0.6rem !important; margin: 0 !important; }
.location-name { font-size: 1.5rem !important; margin: 0 !important; }
.location-stats { gap: 5px !important; }
.stat-pill { padding: 2px 8px !important; font-size: 0.7rem !important; }
/* 2. Compact Scan Input Card */
.scan-card {
padding: 10px !important;
margin-bottom: 8px !important;
}
.scan-header { margin-bottom: 5px !important; }
.scan-title { font-size: 1rem !important; }
.scan-input {
padding: 10px !important;
font-size: 1.1rem !important;
height: 44px !important;
border-width: 2px !important;
}
/* 3. Reclaim Space from the Scanned List */
.scans-section { padding: 5px !important; }
.scans-header { margin-bottom: 4px !important; }
.scans-title { font-size: 0.9rem !important; }
.scans-grid { max-height: 180px !important; } /* Ensure list is visible but short */
/* 4. FIX THE GIANT FLOATING BUTTONS */
.finish-section {
position: relative !important; /* Stop it from floating over content */
bottom: 0 !important;
margin-top: 10px !important;
}
.action-buttons-row {
display: flex !important;
gap: 8px !important;
}
/* Make "Back" small and "Finish" primary but compact */
.action-buttons-row .btn {
flex: 1;
padding: 8px !important;
font-size: 0.85rem !important;
min-height: 40px !important;
}
.btn-success {
background: var(--color-success) !important;
box-shadow: none !important; /* Remove glow to save rendering power */
}
/* Inside the @media query ... */
/* Make background colors slightly more visible since the text is gone */
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; }
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; }
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; }
/* ADD THIS LINE: */
.scan-row-weight_discrepancy { background: rgba(255, 152, 0, 0.25) !important; }
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; }
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; }
/* ==================== ARCHIVED SESSIONS ==================== */
.session-archived {
opacity: 0.7;
border-color: var(--color-text-dim);
}
.session-archived:hover {
opacity: 0.85;
}
.archived-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--color-text-dim);
color: var(--color-bg);
border-radius: var(--radius-sm);
font-size: 0.65rem;
font-weight: 700;
margin-left: var(--space-sm);
vertical-align: middle;
}
/* Session Detail Header Actions */
.session-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.session-actions-header {
flex-shrink: 0;
}
/* ==================== MODULE GRID (Home Page) ==================== */
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-xl);
margin-top: var(--space-xl);
}
.module-card {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-2xl) var(--space-xl);
text-decoration: none;
text-align: center;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
}
.module-card:hover {
border-color: var(--color-primary);
transform: translateY(-4px);
box-shadow: var(--shadow-glow), var(--shadow-lg);
}
.module-icon {
width: 80px;
height: 80px;
background: var(--color-primary-glow);
border: 2px solid var(--color-primary);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--color-primary);
transition: var(--transition);
}
.module-card:hover .module-icon {
background: var(--color-primary);
color: var(--color-bg);
box-shadow: 0 0 30px var(--color-primary-glow);
}
.module-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.module-desc {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: 0;
line-height: 1.5;
}
/* ==================== MODAL SCROLL FIX ==================== */
.modal {
overflow-y: auto;
padding: var(--space-xl) 0;
}
.modal-content {
max-height: none;
margin: auto;
}
/* ==================== ADMIN MODULES SECTION ==================== */
.modules-section {
margin-bottom: var(--space-2xl);
padding-bottom: var(--space-xl);
border-bottom: 2px solid var(--color-border);
}
.section-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text-muted);
margin-bottom: var(--space-lg);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.modules-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-lg);
}
.module-card-link {
text-decoration: none;
}
.modules-grid .module-card {
padding: var(--space-lg);
border-radius: var(--radius-lg);
}
.modules-grid .module-icon {
width: 50px;
height: 50px;
font-size: 1.5rem;
border-radius: var(--radius-md);
}
.modules-grid .module-name {
font-size: 1.1rem;
}
.modules-grid .module-desc {
font-size: 0.8rem;
}
.module-card-active {
border-color: var(--color-primary);
background: var(--color-primary-glow);
}
.module-card-active .module-icon {
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,77 +4,41 @@
{% block content %}
<div class="dashboard-container">
<!-- Mode Selector -->
<div class="mode-selector">
<button class="mode-btn mode-btn-active" onclick="window.location.href='{{ url_for('dashboard') }}'">
👔 Admin Console
</button>
<button class="mode-btn" onclick="window.location.href='{{ url_for('staff_mode') }}'">
📦 Scanning Mode
</button>
<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" 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="dashboard-header">
<h1 class="page-title">Admin Dashboard</h1>
<a href="{{ url_for('create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session
</a>
</div>
<div class="modules-section">
<h2 class="section-title">Modules</h2>
{% if sessions %}
<div class="sessions-grid">
{% for session in sessions %}
<div class="session-card">
<div class="session-card-header">
<h3 class="session-name">{{ session.session_name }}</h3>
<span class="session-type-badge session-type-{{ session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
</span>
</div>
<div class="session-stats">
<div class="stat-item">
<div class="stat-value">{{ session.total_locations or 0 }}</div>
<div class="stat-label">Total Locations</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.completed_locations or 0 }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.in_progress_locations or 0 }}</div>
<div class="stat-label">In Progress</div>
</div>
</div>
<div class="session-meta">
<div class="meta-item">
<span class="meta-label">Created:</span>
<span class="meta-value">{{ session.created_timestamp[:16] }}</span>
</div>
<div class="meta-item">
<span class="meta-label">By:</span>
<span class="meta-value">{{ session.created_by_name }}</span>
</div>
</div>
<div class="session-actions">
<a href="{{ url_for('session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
View Details
</a>
</div>
{% 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="empty-state">
<div class="empty-icon">📋</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Create a new count session to get started</p>
<a href="{{ url_for('create_session') }}" class="btn btn-primary">
Create First Session
</a>
{% 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 %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -8,6 +8,10 @@
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{% block title %}ScanLook{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/mobile.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/scanner.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
{% block extra_css %}{% endblock %}
</head>
<body>
@@ -15,7 +19,7 @@
<nav class="navbar">
<div class="nav-content">
<div class="nav-left">
<a href="{{ url_for('dashboard') }}" class="logo">
<a href="{{ url_for('home') }}" class="logo">
<span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
</a>
</div>
@@ -25,8 +29,14 @@
<div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<div id="settingsMenu" class="settings-menu">
<a href="{{ url_for('manage_users') }}" class="settings-item">
<span class="settings-icon">👥</span> Manage Users
<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"><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>
@@ -62,6 +72,8 @@
<footer class="footer">
<div class="footer-content">
<p>&copy; 2026 Javier Torres. All Rights Reserved.</p>
<p class="text-muted"><small>v{{ version }}</small></p>
</div>
</footer>
</html>

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; {{ '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('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 %}

35
templates/home.html Normal file
View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Home - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-header">
<h1 class="page-title">Welcome, {{ session.full_name }}</h1>
</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="/{{ m.module_key }}" class="module-card">
<div class="module-icon">
<i class="fa-solid {{ m.icon }}"></i>
</div>
<h3 class="module-name">{{ m.module_name }}</h3>
<p class="module-desc">{{ m.description }}</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔒</div>
<h2 class="empty-title">No Modules Available</h2>
<p class="empty-text">You don't have access to any modules. Please contact your administrator.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -129,6 +129,18 @@
</label>
</div>
<div class="form-group">
<label class="form-label">Module Access</label>
<div class="module-checkboxes" id="moduleCheckboxes">
{% for module in modules %}
<label class="checkbox-label">
<input type="checkbox" name="modules" value="{{ module.module_id }}" class="module-checkbox">
{{ module.module_name }}
</label>
{% endfor %}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save User</button>
@@ -162,9 +174,10 @@ function openAddUser() {
document.getElementById('passwordOptional').style.display = 'none';
document.getElementById('password').required = true;
document.getElementById('activeToggleGroup').style.display = 'none';
// Uncheck all modules for new user
document.querySelectorAll('.module-checkbox').forEach(cb => cb.checked = false);
document.getElementById('userModal').style.display = 'flex';
}
function openEditUser(userId) {
editingUserId = userId;
document.getElementById('modalTitle').textContent = 'Edit User';
@@ -191,7 +204,22 @@ function openEditUser(userId) {
const isEditingSelf = user.user_id === {{ session.user_id }};
document.getElementById('role').disabled = isEditingSelf;
document.getElementById('userModal').style.display = 'flex';
// Load user's modules
fetch('/settings/users/' + userId + '/modules')
.then(resp => resp.json())
.then(moduleData => {
// Uncheck all first
document.querySelectorAll('.module-checkbox').forEach(cb => cb.checked = false);
// Check assigned modules
if (moduleData.success) {
moduleData.module_ids.forEach(id => {
const cb = document.querySelector(`.module-checkbox[value="${id}"]`);
if (cb) cb.checked = true;
});
}
// Show modal after modules are loaded
document.getElementById('userModal').style.display = 'flex';
});
} else {
alert(data.message);
}
@@ -201,7 +229,6 @@ function openEditUser(userId) {
console.error(error);
});
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
document.getElementById('userForm').reset();
@@ -234,8 +261,23 @@ document.getElementById('userForm').addEventListener('submit', function(e) {
.then(response => response.json())
.then(data => {
if (data.success) {
closeUserModal();
location.reload();
// Save modules if editing existing user
if (userId) {
const moduleIds = Array.from(document.querySelectorAll('.module-checkbox:checked'))
.map(cb => parseInt(cb.value));
fetch(`/settings/users/${userId}/modules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_ids: moduleIds })
}).then(() => {
closeUserModal();
location.reload();
});
} else {
closeUserModal();
location.reload();
}
} else {
alert(data.message);
}

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>

31
utils.py Normal file
View File

@@ -0,0 +1,31 @@
from functools import wraps
from flask import session, flash, redirect, url_for
from db import query_db
def login_required(f):
"""Require login for route"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page', 'warning')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def role_required(*roles):
"""Require specific role(s) for route"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page', 'warning')
return redirect(url_for('login'))
user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True)
if not user or user['role'] not in roles:
flash('You do not have permission to access this page', 'danger')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator