Compare commits

..

22 Commits

Author SHA1 Message Date
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
46 changed files with 5972 additions and 1730 deletions

6
.dockerignore Normal file
View File

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

108
AI Prompt.txt Normal file
View File

@@ -0,0 +1,108 @@
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 web app for warehouse counting workflows.
- Admin creates a **Count Session** (e.g., “Jan 24 2026 - First Shift”) and uploads a **Master Inventory list**.
- Staff select the active Count Session, enter a **Location/BIN**, and the app shows the **Expected** lots/items/weights that should be there (Cycle Count mode).
- Staff **scan lot numbers**, enter **weights**, and each scan moves from **Expected → Scanned**.
- System flags:
- duplicates
- wrong location
- “ghost” lots (physically found but not in system/master list)
- Staff can **Finalize** a BIN; once finalized, it should clearly report **missing items/lots**.
- Admin sees live progress in an **Admin Dashboard**.
- Multiple Count Sessions can exist even on the same day (e.g., First Shift vs Second Shift) and must be completely isolated.
There are two types of counts:
1) **Cycle Count**: shows Expected list for the BIN.
2) **Physical Inventory**: same workflow but **blind** (does NOT show Expected list; only scanned results, then missing is determined after).
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable.
## Operating rules (must follow)
1) **Be accurate, not fast.** Double-check code, SQL, and commands before sending.
2) **No assumptions about files/environment.** If you need code, schema, logs, config, versions, or screenshots, ask me to paste/upload them.
3) **Step-by-step only.** Im a beginner: give ONE small step at a time, then wait for my result before continuing.
4) **No command dumps.** Dont give long chains of commands. One command (or tiny set) per step.
5) **Keep it to the point.** Default to short answers. Only explain more if I ask.
6) **Verify safety.** Warn me before destructive actions (delete/overwrite/migrations). Offer a safer alternative.
7) **Evidence-based debugging.** Ask for exact error text/logs and versions before guessing.
8) **CSS changes:** Ask which device(s) the change is for (desktop/mobile/scanner) before editing. Each has its own file.
9) **Docker deployment:** Production runs in Docker on Linux (PortainerVM). Volume mounts only /app/database to preserve data between updates.
10) Database changes: Never tell user to "manually run SQL". Always add changes to migrations.py so they auto-apply on deployment.
## How you should respond
- Start by confirming which mode were working on: Cycle Count or Physical Inventory.
- Ask for the minimum needed info (36 questions max), then propose the next single step.
- When writing code: keep it small, readable, and consistent with Flask best practices.
- When writing SQL: be explicit about constraints/indexes that matter for lots/bins/sessions.
- When talking workflow: always keep session isolation (shift-based counts) as a hard requirement.
## Scanlook (current product summary)
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
**Current Version:** 0.13.0
**Tech Stack:**
- Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation)
- Database: SQLite (located in /database/scanlook.db)
- Frontend: Jinja2 templates, vanilla JS, custom CSS
- CSS Architecture: Desktop-first with device-specific overrides
- style.css (base/desktop)
- mobile.css (phones, 360-767px)
- scanner.css (MC9300 scanners, max-width 359px)
- Deployment: Docker container, Gitea for version control + container registry
**Project Structure:**
- app.py (main Flask app, routes for auth + dashboard)
- /blueprints/ (modular routes: counting.py, sessions.py, users.py, data_imports.py, admin_locations.py)
- /templates/ (Jinja2 HTML templates)
- /static/css/ (style.css, mobile.css, scanner.css)
- /database/ (scanlook.db, init_db.py)
- db.py (database helper functions: query_db, execute_db)
- utils.py (decorators: login_required, role_required)
- migrations.py (database migration system)
**Key Features (implemented):**
- Count Sessions with archive/activate functionality
- Master baseline upload (CSV)
- Current baseline upload (optional, for comparison)
- Staff scanning interface optimized for MC9300 Zebra scanners
- Scan statuses: Match, Duplicate, Wrong Location, Ghost Lot, Weight Discrepancy
- Location/BIN workflow with Expected → Scanned flow
- Session isolation (archived sessions blocked from access)
- Role-based access: owner, admin, staff
- Auto-initialize database on first run
- Consumption Sheets module (production lot tracking with Excel export)
- Database migration system (auto-applies schema changes on startup)
**Two count types:**
1. Cycle Count: shows Expected list for the BIN
2. Physical Inventory: blind count (no Expected list shown)
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
**Module System (v0.13.0):**
- Modules table defines available modules (module_key used for routing)
- UserModules table tracks per-user access
- Home page (/home) shows module cards based on user's access
- Each module needs: database entry, route with access check, home page card
- New modules should go in /modules/{module_name}/ with:
- __init__.py (blueprint registration)
- routes.py (all routes)
- templates/ (module-specific templates)
- Current modules:
- Inventory Counts (counting)
- Consumption Sheets (cons_sheets)
## Quick Reference
- Database: SQLite at /database/scanlook.db
- Scanner viewport: 320px wide (MC9300)
- Mobile breakpoint: 360-767px
- Desktop: 768px+
- Git remote: http://10.44.44.33:3000/stuff/ScanLook.git
- Docker registry: 10.44.44.33:3000/stuff/scanlook

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

219
README.md
View File

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

Binary file not shown.

1332
app.py

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,89 @@
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>/delete', methods=['POST'])
@login_required
def delete_location_count(location_count_id):
"""Delete all counts for a location (soft delete)"""
# Verify ownership
loc = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', [location_count_id], one=True)
if not loc:
return jsonify({'success': False, 'message': 'Location not found'})
if loc['counted_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']:
return jsonify({'success': False, 'message': 'Permission denied'})
# Soft delete all scan entries for this location
execute_db('''
UPDATE ScanEntries
SET is_deleted = 1
WHERE location_count_id = ?
''', [location_count_id])
# Delete the location count record
execute_db('''
DELETE FROM LocationCounts
WHERE location_count_id = ?
''', [location_count_id])
return jsonify({'success': True, 'message': 'Bin count deleted'})
@admin_locations_bp.route('/location/<int:location_count_id>/scans')
@login_required
def get_location_scans(location_count_id):
"""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)})

1120
blueprints/cons_sheets.py Normal file

File diff suppressed because it is too large Load Diff

757
blueprints/counting.py Normal file
View File

@@ -0,0 +1,757 @@
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.counted_by = ?
AND lc.status = 'in_progress'
GROUP BY lc.location_count_id
ORDER BY lc.start_timestamp DESC
''', [session_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.counted_by = ?
AND lc.status = 'completed'
GROUP BY lc.location_count_id
ORDER BY lc.end_timestamp DESC
''', [session_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"""
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))
# 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
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 = ?', [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 = ?', [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 = ?
''', [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 = ?
''', [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 = ?
''', [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 = ?
''', [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)
})

View File

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

244
blueprints/sessions.py Normal file
View File

@@ -0,0 +1,244 @@
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('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
locations = query_db('''
SELECT lc.*, u.full_name as counter_name
FROM LocationCounts lc
LEFT JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ?
ORDER BY lc.status DESC, lc.location_name
''', [session_id])
# Get active counters
active_counters = query_db('''
SELECT DISTINCT u.full_name, lc.location_name, lc.start_timestamp
FROM LocationCounts lc
JOIN Users u ON lc.counted_by = u.user_id
WHERE lc.session_id = ? AND lc.status = 'in_progress'
ORDER BY lc.start_timestamp DESC
''', [session_id])
return render_template('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.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
bim.lot_number,
bim.item,
bim.description,
bim.system_bin,
bim.system_quantity
FROM BaselineInventory_Master bim
WHERE bim.session_id = ?
AND bim.lot_number NOT IN (
SELECT lot_number
FROM ScanEntries
WHERE session_id = ? AND is_deleted = 0
)
ORDER BY bim.system_bin, bim.lot_number
''', [session_id, session_id])
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'})

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

@@ -166,6 +166,109 @@ def init_database():
) )
''') ''')
# ============================================
# MODULE SYSTEM TABLES
# ============================================
# Modules Table - Available feature modules
cursor.execute('''
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,
icon TEXT,
is_active INTEGER DEFAULT 1,
display_order INTEGER DEFAULT 0
)
''')
# UserModules Table - Module access per user
cursor.execute('''
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)
)
''')
# ============================================
# CONSUMPTION SHEETS MODULE TABLES
# ============================================
# cons_processes - Master list of consumption sheet process types
cursor.execute('''
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,
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
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_process_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')),
field_name TEXT NOT NULL,
field_label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')),
max_length INTEGER,
is_required INTEGER DEFAULT 0,
is_duplicate_key INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
excel_cell TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (process_id) REFERENCES cons_processes(id)
)
''')
# cons_sessions - Staff scanning sessions
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_id INTEGER NOT NULL,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived')),
FOREIGN KEY (process_id) REFERENCES cons_processes(id),
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# Note: Header values still use flexible key-value storage
# cons_session_header_values - Flexible storage for header field values
cursor.execute('''
CREATE TABLE IF NOT EXISTS cons_session_header_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
field_id INTEGER NOT NULL,
field_value TEXT,
FOREIGN KEY (session_id) REFERENCES cons_sessions(id),
FOREIGN KEY (field_id) REFERENCES cons_process_fields(id)
)
''')
# Note: Detail tables are created dynamically per process as cons_proc_{process_key}_details
# They include system columns (id, session_id, scanned_by, scanned_at, duplicate_status,
# duplicate_info, comment, is_deleted) plus custom fields defined in cons_process_fields
# Create Indexes # Create Indexes
# MASTER baseline 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_lot ON BaselineInventory_Master(session_id, lot_number)')
@@ -182,6 +285,13 @@ def init_database():
# Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups # Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups
# Consumption Sheets indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)')
# Note: Detail table indexes are created dynamically when process tables are created
conn.commit() conn.commit()
conn.close() conn.close()
print(f"✅ Database initialized at: {DB_PATH}") print(f"✅ Database initialized at: {DB_PATH}")
@@ -217,6 +327,52 @@ def create_default_users():
conn.close() conn.close()
def create_default_modules():
"""Create default modules and assign to admin users"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Define default modules
default_modules = [
('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1),
('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2),
]
# Insert modules (ignore if already exist)
for module in default_modules:
try:
cursor.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES (?, ?, ?, ?, ?, ?)
''', module)
except sqlite3.IntegrityError:
pass # Module already exists
conn.commit()
# Auto-assign all modules to owner and admin users
cursor.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")')
admin_users = cursor.fetchall()
cursor.execute('SELECT module_id FROM Modules')
all_modules = cursor.fetchall()
for user in admin_users:
for module in all_modules:
try:
cursor.execute('''
INSERT INTO UserModules (user_id, module_id)
VALUES (?, ?)
''', (user[0], module[0]))
except sqlite3.IntegrityError:
pass # Assignment already exists
conn.commit()
conn.close()
print("✅ Default modules created and assigned to admin users")
if __name__ == '__main__': if __name__ == '__main__':
init_database() init_database()
create_default_users() create_default_users()
create_default_modules()

Binary file not shown.

245
migrations.py Normal file
View File

@@ -0,0 +1,245 @@
"""
ScanLook Database Migration System
Simple migration system that tracks and applies database changes.
Each migration has a version number and an up() function.
Usage:
from migrations import run_migrations
run_migrations() # Call on app startup
"""
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
# ============================================
# MIGRATIONS
# ============================================
# Add new migrations to this list.
# Each migration is a tuple: (version, name, up_function)
#
# RULES:
# - Never modify an existing migration
# - Always add new migrations at the end with the next version number
# - Check if changes are needed before applying (idempotent)
# ============================================
def migration_001_add_modules_tables():
"""Add Modules and UserModules tables"""
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_default_modules():
"""Add default modules if they don't exist"""
conn = get_db()
# Check if modules exist
existing = conn.execute('SELECT COUNT(*) as cnt FROM Modules').fetchone()
if existing['cnt'] == 0:
conn.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1)
''')
conn.execute('''
INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order)
VALUES ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2)
''')
print(" Added default modules")
conn.commit()
conn.close()
def migration_004_assign_modules_to_admins():
"""Auto-assign all modules to owner and admin users"""
conn = get_db()
# Get admin users
admins = conn.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")').fetchall()
modules = conn.execute('SELECT module_id FROM Modules').fetchall()
for user in admins:
for module in modules:
try:
conn.execute('''
INSERT INTO UserModules (user_id, module_id)
VALUES (?, ?)
''', [user['user_id'], module['module_id']])
except sqlite3.IntegrityError:
pass # Already assigned
conn.commit()
conn.close()
print(" Assigned modules to admin users")
def migration_005_add_cons_process_fields_duplicate_key():
"""Add is_duplicate_key column to cons_process_fields if missing"""
conn = get_db()
if table_exists('cons_process_fields'):
if not column_exists('cons_process_fields', 'is_duplicate_key'):
conn.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0')
print(" Added is_duplicate_key column to cons_process_fields")
conn.commit()
conn.close()
# List of all migrations in order
MIGRATIONS = [
(1, 'add_modules_tables', migration_001_add_modules_tables),
(2, 'add_usermodules_granted_columns', migration_002_add_usermodules_granted_columns),
(3, 'add_default_modules', migration_003_add_default_modules),
(4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins),
(5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key),
]
def run_migrations():
"""Run all pending migrations"""
print("🔄 Checking 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("✅ Database is up to date")
return
print(f"📦 Running {len(pending)} 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 migrations complete")
if __name__ == '__main__':
run_migrations()

View File

@@ -1,2 +1,3 @@
Flask==3.0.0 Flask==3.1.2
Werkzeug==3.0.1 Werkzeug==3.1.5
openpyxl

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 */ /* 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'); @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 { .scan-row-weight_discrepancy {
border-color: #ff9800; /* Orange Border */ border-color: #ff9800;
background: rgba(255, 152, 0, 0.05); /* Very light orange background */ background: rgba(255, 152, 0, 0.05);
} }
.scan-row-wrong_location { .scan-row-wrong_location {
border-color: var(--color-warning); border-color: var(--color-warning);
background: rgba(255, 170, 0, 0.05); background: rgba(255, 170, 0, 0.05);
@@ -1696,12 +1698,6 @@ body {
gap: var(--space-lg); gap: var(--space-lg);
} }
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
}
.finish-section { .finish-section {
position: sticky; position: sticky;
bottom: var(--space-lg); bottom: var(--space-lg);
@@ -1713,123 +1709,6 @@ body {
gap: var(--space-md); 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 */ /* Scrollbar Styling */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;
@@ -1926,21 +1805,6 @@ body {
color: var(--color-text-muted); 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 { .detail-table td {
padding: var(--space-md); padding: var(--space-md);
border: none; border: none;
@@ -2118,10 +1982,6 @@ body {
gap: var(--space-sm); gap: var(--space-sm);
} }
.btn-block {
width: 100%;
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -2233,9 +2093,8 @@ body {
/* Expected rows look "pending" or "unscanned" */ /* Expected rows look "pending" or "unscanned" */
.expected-row { .expected-row {
opacity: 0.7; opacity: 0.7;
border-left: 4px solid var(--color-border); /* Neutral/Gray border */ border-left: 4px solid var(--color-border);
background: var(--color-surface); /* Darker than scanned rows */ background: var(--color-surface);
/* Use the same grid layout as scanned rows */
display: grid; display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr; grid-template-columns: 2fr 1fr 1fr 1.5fr;
gap: var(--space-md); gap: var(--space-md);
@@ -2255,156 +2114,172 @@ body {
box-shadow: none; box-shadow: none;
} }
/* ==================== ZEBRA MC9300 CONSOLIDATED FIX ==================== */ /* Hide scroll buttons on desktop */
@media screen and (max-width: 480px) and (max-height: 600px) { .scroll-to-top,
:root { .scroll-to-bottom {
--space-2xl: 0.8rem !important; display: none;
--space-xl: 0.6rem !important;
--space-lg: 0.4rem !important;
--space-md: 0.3rem !important;
} }
/* Header & Nav Hiding */ /* ==================== ARCHIVED SESSIONS ==================== */
.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 */ .session-archived {
.page-title { font-size: 1.1rem !important; margin: 0 !important; padding: 2px 0 !important; } opacity: 0.7;
.page-header { margin-bottom: 5px !important; flex-direction: row !important; align-items: center !important; } border-color: var(--color-text-dim);
/* 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;
} }
/* --------------------------------------------------------- .session-archived:hover {
UPDATED LIST LAYOUT (Single Line, No Status Text) opacity: 0.85;
--------------------------------------------------------- */
/* 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 */ .archived-badge {
.scan-row-status { display: none !important; } display: inline-block;
padding: 0.15rem 0.5rem;
/* Make background colors slightly more visible since the text is gone */ background: var(--color-text-dim);
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; } color: var(--color-bg);
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; } border-radius: var(--radius-sm);
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; } font-size: 0.65rem;
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; } font-weight: 700;
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; } margin-left: var(--space-sm);
vertical-align: middle;
/* 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; }
} }
/* ==================== SCANNING INTERFACE (COUNT LOCATION) ==================== */ /* Session Detail Header Actions */
@media screen and (max-width: 480px) and (max-height: 600px) { .session-detail-header {
display: flex;
/* 1. Shrink the Location Header (R303D area) */ justify-content: space-between;
.location-header { align-items: flex-start;
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 */ .session-actions-header {
.scans-section { padding: 5px !important; } flex-shrink: 0;
.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 { /* ==================== MODULE GRID (Home Page) ==================== */
display: flex !important;
gap: 8px !important; .module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-xl);
margin-top: var(--space-xl);
} }
/* Make "Back" small and "Finish" primary but compact */ .module-card {
.action-buttons-row .btn { background: var(--color-surface);
flex: 1; border: 2px solid var(--color-border);
padding: 8px !important; border-radius: var(--radius-xl);
font-size: 0.85rem !important; padding: var(--space-2xl) var(--space-xl);
min-height: 40px !important; text-decoration: none;
text-align: center;
transition: var(--transition);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
} }
.btn-success { .module-card:hover {
background: var(--color-success) !important; border-color: var(--color-primary);
box-shadow: none !important; /* Remove glow to save rendering power */ transform: translateY(-4px);
box-shadow: var(--shadow-glow), var(--shadow-lg);
} }
.module-icon {
/* Inside the @media query ... */ width: 80px;
height: 80px;
/* Make background colors slightly more visible since the text is gone */ background: var(--color-primary-glow);
.scan-row-match { background: rgba(0, 255, 136, 0.15) !important; } border: 2px solid var(--color-primary);
.scan-row-wrong_location { background: rgba(255, 170, 0, 0.15) !important; } border-radius: var(--radius-lg);
.scan-row-ghost_lot { background: rgba(179, 102, 255, 0.15) !important; } display: flex;
align-items: center;
/* ADD THIS LINE: */ justify-content: center;
.scan-row-weight_discrepancy { background: rgba(255, 152, 0, 0.25) !important; } font-size: 2.5rem;
color: var(--color-primary);
.scan-row-duplicate-01, .scan-row-duplicate-04 { background: rgba(0, 163, 255, 0.15) !important; } transition: var(--transition);
.scan-row-duplicate-03 { background: rgba(255, 140, 0, 0.15) !important; } }
.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);
} }

View File

@@ -4,77 +4,28 @@
{% block content %} {% block content %}
<div class="dashboard-container"> <div class="dashboard-container">
<!-- Mode Selector --> <div class="dashboard-header" style="margin-top: var(--space-lg);">
<div class="mode-selector"> <div class="header-left" style="display: flex; align-items: center; gap: var(--space-md);">
<button class="mode-btn mode-btn-active" onclick="window.location.href='{{ url_for('dashboard') }}'"> <a href="{{ url_for('home') }}" class="btn btn-secondary btn-sm">
👔 Admin Console <i class="fa-solid fa-arrow-left"></i> Back to Home
</button>
<button class="mode-btn" onclick="window.location.href='{{ url_for('staff_mode') }}'">
📦 Scanning Mode
</button>
</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> </a>
</div> <h1 class="page-title" style="margin-bottom: 0;">Admin Dashboard</h1>
{% 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> </div>
<div class="session-meta"> <div class="modules-section">
<div class="meta-item"> <h2 class="section-title">Modules</h2>
<span class="meta-label">Created:</span> <div class="modules-grid">
<span class="meta-value">{{ session.created_timestamp[:16] }}</span> <a href="{{ url_for('counting.admin_dashboard') }}" class="module-card">
</div> <div class="module-icon">📊</div> <h3 class="module-name">Counts</h3>
<div class="meta-item"> <p class="module-desc">Cycle counts & physical inventory</p>
<span class="meta-label">By:</span> </a>
<span class="meta-value">{{ session.created_by_name }}</span>
</div>
</div>
<div class="session-actions"> <a href="{{ url_for('cons_sheets.admin_processes') }}" class="module-card module-card-link">
<a href="{{ url_for('session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block"> <div class="module-icon">📝</div> <h3 class="module-name">Consumption Sheets</h3>
View Details <p class="module-desc">Production consumption tracking</p>
</a> </a>
</div> </div>
</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>
<a href="{{ url_for('create_session') }}" class="btn btn-primary">
Create First Session
</a>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -8,6 +8,10 @@
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{% block title %}ScanLook{% endblock %}</title> <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/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 %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
@@ -15,7 +19,7 @@
<nav class="navbar"> <nav class="navbar">
<div class="nav-content"> <div class="nav-content">
<div class="nav-left"> <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> <span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
</a> </a>
</div> </div>
@@ -25,7 +29,7 @@
<div class="settings-dropdown"> <div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button> <button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<div id="settingsMenu" class="settings-menu"> <div id="settingsMenu" class="settings-menu">
<a href="{{ url_for('manage_users') }}" class="settings-item"> <a href="{{ url_for('users.manage_users') }}" class="settings-item">
<span class="settings-icon">👥</span> Manage Users <span class="settings-icon">👥</span> Manage Users
</a> </a>
</div> </div>
@@ -62,6 +66,8 @@
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
<p>&copy; 2026 Javier Torres. All Rights Reserved.</p> <p>&copy; 2026 Javier Torres. All Rights Reserved.</p>
<p class="text-muted"><small>v{{ version }}</small></p>
</div> </div>
</footer> </footer>
</html> </html>

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('cons_sheets.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('cons_sheets.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,73 @@
{% 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;">Consumption Sheets</h1>
<p class="page-subtitle" style="margin-bottom: 0;">Manage process types and templates</p>
</div>
</div>
<a href="{{ url_for('cons_sheets.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">
<h3 class="session-name">{{ process.process_name }}</h3>
<span class="session-type-badge">
{{ process.field_count or 0 }} fields
</span>
</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('cons_sheets.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('cons_sheets.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('cons_sheets.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('cons_sheets.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('cons_sheets.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('cons_sheets.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,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('cons_sheets.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('cons_sheets.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,245 @@
{% 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('cons_sheets.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('cons_sheets.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('cons_sheets.process_template', process_id=process.id) }}" class="btn btn-primary btn-block">
Configure Template
</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('cons_sheets.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('cons_sheets.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('cons_sheets.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('cons_sheets.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('cons_sheets.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("cons_sheets.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,269 @@
{% 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('cons_sheets.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('cons_sheets.download_template', process_id=process.id) }}" class="btn btn-secondary btn-sm">Download</a>
</div>
{% else %}
<p class="no-template">No template uploaded yet</p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('cons_sheets.upload_template', process_id=process.id) }}" enctype="multipart/form-data" class="upload-form">
<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('cons_sheets.update_template_settings', process_id=process.id) }}">
<div class="form-group">
<label for="rows_per_page" class="form-label">Rows Per Page</label>
<input type="number" id="rows_per_page" name="rows_per_page"
value="{{ process.rows_per_page or 30 }}" min="1" max="500" class="form-input">
<p class="form-hint">Max detail rows before starting a new page</p>
</div>
<div class="form-group">
<label for="detail_start_row" class="form-label">Detail Start Row</label>
<input type="number" id="detail_start_row" name="detail_start_row"
value="{{ process.detail_start_row or 10 }}" min="1" max="500" class="form-input">
<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('cons_sheets.process_fields', process_id=process.id) }}" class="btn btn-secondary" style="margin-top: var(--space-lg);">
Edit Field Mappings
</a>
{% else %}
<div class="empty-state-small">
<p>No fields defined yet. <a href="{{ url_for('cons_sheets.process_fields', process_id=process.id) }}">Add fields first</a>.</p>
</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,417 @@
{% 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('cons_sheets.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 scan-card-active">
<div class="scan-header">
<h2 class="scan-title">Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}</h2>
</div>
<form id="lotScanForm" class="scan-form">
<div class="scan-input-group">
<input type="text" name="dup_key_input" id="dupKeyInput" inputmode="none"
class="scan-input" placeholder="Scan {{ dup_key_field.field_label if dup_key_field else 'Item' }}"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus
{% if not dup_key_field %}disabled{% endif %}>
<button type="submit" style="display: none;"></button>
</div>
</form>
</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="submit" class="btn btn-primary">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 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('cons_sheets.index') }}" class="btn btn-secondary btn-block btn-lg">← Back to Sessions</a>
<button class="btn btn-success btn-block btn-lg" onclick="exportToExcel()">📊 Export to Excel</button>
</div>
</div>
</div>
<style>
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }
.header-pill strong { color: var(--color-text); }
.scan-row { display: grid; grid-template-columns: repeat(var(--field-count), 1fr) auto; gap: var(--space-sm); padding: var(--space-md); background: var(--color-surface); border: 2px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-sm); cursor: pointer; transition: var(--transition); }
.scan-row:hover { border-color: var(--color-primary); }
.scan-row-cell { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scan-row-dup_same_session { border-left: 4px solid var(--color-duplicate) !important; background: rgba(0, 163, 255, 0.1) !important; }
.scan-row-dup_other_session { border-left: 4px solid var(--color-warning) !important; background: rgba(255, 170, 0, 0.1) !important; }
.scan-row-normal { border-left: 4px solid var(--color-success); }
.modal-duplicate { text-align: center; padding: var(--space-xl); }
.duplicate-lot-number { font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; color: var(--color-primary); margin-bottom: var(--space-md); }
.duplicate-title { font-size: 1.25rem; margin-bottom: var(--space-sm); }
.duplicate-message { color: var(--color-text-muted); margin-bottom: var(--space-lg); }
</style>
<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;
// Standard variables
let currentDupKeyValue = '';
let currentDuplicateStatus = '';
let isDuplicateConfirmed = false;
let isProcessing = 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(`/cons-sheets/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';
showFieldsModal();
}
function cancelDuplicate() {
document.getElementById('duplicateSameModal').style.display = 'none';
document.getElementById('duplicateOtherModal').style.display = 'none';
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(`/cons-sheets/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(`/cons-sheets/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(`/cons-sheets/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(`/cons-sheets/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 = `/cons-sheets/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('cons_sheets.new_session', process_id=p.id) }}" class="btn btn-primary">
<span class="btn-icon">+</span> {{ p.process_name }}
</a>
{% endfor %}
{% if not processes %}
<p class="empty-text">No process types configured yet. Contact your administrator.</p>
{% endif %}
</div>
</div>
<!-- Active Sessions -->
{% if sessions %}
<div class="section-card">
<h2 class="section-title">📋 My Active Sessions</h2>
<div class="sessions-list">
{% for s in sessions %}
<div class="session-list-item-container">
<a href="{{ url_for('cons_sheets.scan_session', session_id=s.id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ s.process_name }}</h3>
<div class="session-list-meta">
<span>Started: {{ s.created_at[:16] }}</span>
<span></span>
<span>{{ s.scan_count or 0 }} lots scanned</span>
</div>
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
<button class="btn-archive" onclick="archiveSession(this)" data-id="{{ s.id }}" data-name="{{ s.process_name }}" title="Archive this session">
🗑️
</button>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">Start a new session by selecting a process type above</p>
</div>
{% endif %}
</div>
<style>
.new-session-section {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.process-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-top: var(--space-md);
}
.section-card {
margin-top: var(--space-xl);
}
.session-list-item-container {
display: flex;
align-items: stretch;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.session-list-item {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
text-decoration: none;
transition: var(--transition);
}
.session-list-item:hover {
border-color: var(--color-primary);
transform: translateX(4px);
}
.session-list-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 var(--space-xs) 0;
}
.session-list-meta {
display: flex;
gap: var(--space-sm);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.arrow-icon {
font-size: 1.5rem;
color: var(--color-primary);
}
.btn-archive {
background: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
cursor: pointer;
font-size: 1.25rem;
transition: var(--transition);
}
.btn-archive:hover {
border-color: var(--color-danger);
background: rgba(255, 51, 102, 0.1);
}
</style>
<script>
function archiveSession(sessionId, processName) {
if (!confirm(`Archive this ${processName} session?\n\nYou can still view it later from the admin panel.`)) {
return;
}
fetch(`/cons-sheets/session/${sessionId}/archive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error archiving session');
}
});
}
</script>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="count-container"> <div class="count-container">
<div class="count-header"> <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> <h1 class="page-title">{{ session.session_name }}</h1>
</div> </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('sessions.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('sessions.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("counting.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

@@ -99,10 +99,10 @@
<div class="scan-row scan-row-{{ row_class }}" <div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}" data-entry-id="{{ scan.entry_id }}"
onclick="openScanDetail({{ scan.entry_id }})"> onclick="openScanDetail('{{ scan.entry_id }}')">
<div class="scan-row-lot">{{ scan.lot_number }}</div> <div class="scan-row-lot">{{ scan.lot_number }}</div>
<div class="scan-row-item">{{ scan.item or 'N/A' }}</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"> <div class="scan-row-status">
{% if scan.duplicate_status == '01' or scan.duplicate_status == '04' %} {% if scan.duplicate_status == '01' or scan.duplicate_status == '04' %}
<span class="status-dot status-dot-blue"></span> Duplicate <span class="status-dot status-dot-blue"></span> Duplicate
@@ -160,7 +160,7 @@
<div class="finish-section"> <div class="finish-section">
<div class="action-buttons-row"> <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('counting.my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
← Back to My Counts ← Back to My Counts
</a> </a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()"> <button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
@@ -168,6 +168,16 @@
</button> </button>
</div> </div>
</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> </div>
<script> <script>
@@ -175,6 +185,23 @@ let currentLotNumber = '';
let isDuplicateConfirmed = false; let isDuplicateConfirmed = false;
let isProcessing = 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 // Lot scan handler
document.getElementById('lotScanForm').addEventListener('submit', function(e) { document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@@ -191,7 +218,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
}); });
function checkDuplicate() { function checkDuplicate() {
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -260,7 +287,7 @@ function submitScan(weight) {
return; return;
} }
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -378,7 +405,7 @@ function addScanToList(data, weight) {
statusDot = 'orange'; statusDot = 'orange';
} }
} else if (data.master_status === 'match') { } 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'; statusClass = 'weight_discrepancy';
statusText = 'Weight Off'; statusText = 'Weight Off';
statusDot = 'orange'; statusDot = 'orange';
@@ -404,7 +431,7 @@ function addScanToList(data, weight) {
scanRow.innerHTML = ` scanRow.innerHTML = `
<div class="scan-row-lot">${currentLotNumber}</div> <div class="scan-row-lot">${currentLotNumber}</div>
<div class="scan-row-item">${data.item || 'N/A'}</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"> <div class="scan-row-status">
<span class="status-dot status-dot-${statusDot}"></span> ${statusText} <span class="status-dot status-dot-${statusDot}"></span> ${statusText}
</div> </div>
@@ -441,7 +468,7 @@ function displayScanDetail(scan) {
// Check for weight discrepancy (Tolerance 0.01) // Check for weight discrepancy (Tolerance 0.01)
let isWeightOff = false; let isWeightOff = false;
if (scan.master_status === 'match' && scan.master_expected_weight) { 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; isWeightOff = true;
} }
} }
@@ -492,7 +519,7 @@ function displayScanDetail(scan) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Weight (lbs)</label> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Comment</label> <label class="form-label">Comment</label>
@@ -560,7 +587,7 @@ function deleteFromDetail(entryId) {
function finishLocation() { function finishLocation() {
if (!confirm('Are you finished counting this location?')) return; 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("counting.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })

View File

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

View File

@@ -7,9 +7,9 @@
<!-- Mode Selector (only for admins) --> <!-- Mode Selector (only for admins) -->
{% if session.role in ['owner', 'admin'] %} {% if session.role in ['owner', 'admin'] %}
<div class="mode-selector"> <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 👔 Admin Console
</button> </a>
<button class="mode-btn mode-btn-active"> <button class="mode-btn mode-btn-active">
📦 Scanning Mode 📦 Scanning Mode
</button> </button>
@@ -17,16 +17,19 @@
{% endif %} {% endif %}
<div class="dashboard-header"> <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> <h1 class="page-title">Select Count Session</h1>
</div> </div>
{% if sessions %} {% if sessions %}
<div class="sessions-list"> <div class="sessions-list">
{% for session in sessions %} {% for s in sessions %}
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item"> <a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
<div class="session-list-info"> <div class="session-list-info">
<h3 class="session-list-name">{{ session.session_name }}</h3> <h3 class="session-list-name">{{ s.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span> <span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>
</div> </div>
<div class="session-list-action"> <div class="session-list-action">
<span class="arrow-icon"></span> <span class="arrow-icon"></span>

View File

@@ -34,7 +34,7 @@
</div> </div>
<div class="form-actions"> <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> <button type="submit" class="btn btn-primary">Create Session</button>
</div> </div>
</form> </form>

42
templates/home.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Home - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Admin Button (only for admins/owners) -->
{% if session.role in ['owner', 'admin'] %}
<div class="mode-selector">
<a href="{{ url_for('admin_dashboard') }}" class="mode-btn">
👔 Admin Console
</a>
</div>
{% endif %}
<div class="dashboard-header">
<h1 class="page-title">Welcome, {{ session.full_name }}</h1>
<p class="page-subtitle">Select a module to get started</p>
</div>
{% if modules %}
<div class="module-grid">
{% for m in modules %}
<a href="{{ url_for(m.module_key + '.index') }}" 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> </label>
</div> </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"> <div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save User</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('passwordOptional').style.display = 'none';
document.getElementById('password').required = true; document.getElementById('password').required = true;
document.getElementById('activeToggleGroup').style.display = 'none'; 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'; document.getElementById('userModal').style.display = 'flex';
} }
function openEditUser(userId) { function openEditUser(userId) {
editingUserId = userId; editingUserId = userId;
document.getElementById('modalTitle').textContent = 'Edit User'; document.getElementById('modalTitle').textContent = 'Edit User';
@@ -191,7 +204,22 @@ function openEditUser(userId) {
const isEditingSelf = user.user_id === {{ session.user_id }}; const isEditingSelf = user.user_id === {{ session.user_id }};
document.getElementById('role').disabled = isEditingSelf; document.getElementById('role').disabled = isEditingSelf;
// 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'; document.getElementById('userModal').style.display = 'flex';
});
} else { } else {
alert(data.message); alert(data.message);
} }
@@ -201,7 +229,6 @@ function openEditUser(userId) {
console.error(error); console.error(error);
}); });
} }
function closeUserModal() { function closeUserModal() {
document.getElementById('userModal').style.display = 'none'; document.getElementById('userModal').style.display = 'none';
document.getElementById('userForm').reset(); document.getElementById('userForm').reset();
@@ -234,8 +261,23 @@ document.getElementById('userForm').addEventListener('submit', function(e) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// 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(); closeUserModal();
location.reload(); location.reload();
});
} else {
closeUserModal();
location.reload();
}
} else { } else {
alert(data.message); alert(data.message);
} }

View File

@@ -6,12 +6,22 @@
<div class="session-detail-container"> <div class="session-detail-container">
<div class="session-detail-header"> <div class="session-detail-header">
<div> <div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a> <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 }}</h1> <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 }}"> <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> </span>
</div> </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> </div>
<!-- Baseline Upload Section --> <!-- Baseline Upload Section -->
@@ -30,6 +40,7 @@
{% endif %} {% endif %}
</div> </div>
{% if not count_session.master_baseline_timestamp %} {% if not count_session.master_baseline_timestamp %}
<!-- Note: Using data_imports blueprint URL -->
<form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form"> <form method="POST" action="{{ url_for('data_imports.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"> <input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button> <button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button>
@@ -41,8 +52,8 @@
<div class="baseline-label">CURRENT Baseline (Optional)</div> <div class="baseline-label">CURRENT Baseline (Optional)</div>
<div class="baseline-status"> <div class="baseline-status">
{% if count_session.current_baseline_timestamp %} {% if count_session.current_baseline_timestamp %}
<span class="status-badge status-success">Last Updated: <span class="status-badge status-success">✓ Uploaded</span>
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div> <small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
{% else %} {% else %}
<span class="status-badge status-neutral">Not Uploaded</span> <span class="status-badge status-neutral">Not Uploaded</span>
{% endif %} {% endif %}
@@ -65,27 +76,27 @@
<h2 class="section-title">Real-Time Statistics</h2> <h2 class="section-title">Real-Time Statistics</h2>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match', {{ count_session.session_id }})"> <div class="stat-card stat-match" onclick="showStatusDetails('match')">
<div class="stat-number">{{ stats.matched or 0 }}</div> <div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div> <div class="stat-label">✓ Matched</div>
</div> </div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates', {{ count_session.session_id }})"> <div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates')">
<div class="stat-number">{{ stats.duplicates or 0 }}</div> <div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div> <div class="stat-label">🔵 Duplicates</div>
</div> </div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy', {{ count_session.session_id }})"> <div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy')">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div> <div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div> <div class="stat-label">⚖️ Weight Discrepancy</div>
</div> </div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location', {{ count_session.session_id }})"> <div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location')">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div> <div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div> <div class="stat-label">⚠ Wrong Location</div>
</div> </div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot', {{ count_session.session_id }})"> <div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot')">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div> <div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div> <div class="stat-label">🟣 Ghost Lots</div>
</div> </div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing', {{ count_session.session_id }})"> <div class="stat-card stat-missing" onclick="showStatusDetails('missing')">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div> <div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div> <div class="stat-label">🔴 Missing</div>
</div> </div>
@@ -129,7 +140,12 @@
</thead> </thead>
<tbody> <tbody>
{% for loc in locations %} {% 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><strong>{{ loc.location_name }}</strong></td>
<td> <td>
<span class="status-badge status-{{ loc.status }}"> <span class="status-badge status-{{ loc.status }}">
@@ -246,7 +262,10 @@
</div> </div>
<script> <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('statusModal').style.display = 'flex';
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>'; document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
@@ -261,8 +280,8 @@ function showStatusDetails(status, sessionId) {
}; };
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details'; document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details // Fetch details using the blueprint URL structure
fetch(`/session/${sessionId}/status-details/${status}`) fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -421,6 +440,14 @@ let currentLocationName = '';
let currentLocationStatus = ''; let currentLocationStatus = '';
let currentLocationData = null; 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) { function showLocationDetails(locationCountId, locationName, status) {
currentLocationId = locationCountId; currentLocationId = locationCountId;
currentLocationName = locationName; currentLocationName = locationName;
@@ -594,6 +621,7 @@ function closeFinalizeConfirm() {
} }
function confirmFinalize() { function confirmFinalize() {
// Note: The /complete endpoint is handled by blueprints/counting.py
fetch(`/location/${currentLocationId}/complete`, { fetch(`/location/${currentLocationId}/complete`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -625,6 +653,7 @@ function closeReopenConfirm() {
} }
function confirmReopen() { function confirmReopen() {
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
fetch(`/location/${currentLocationId}/reopen`, { fetch(`/location/${currentLocationId}/reopen`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -655,5 +684,38 @@ document.addEventListener('keydown', function(e) {
closeReopenConfirm(); 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("sessions.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("sessions.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');
}
});
}
</script> </script>
{% endblock %} {% endblock %}

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