Initial V1 Backup

This commit is contained in:
Javier
2026-01-22 00:36:01 -06:00
commit 4c5a588197
27 changed files with 7509 additions and 0 deletions

437
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,437 @@
# ScanLook - Deployment & Testing Guide
## 📦 Phase 1 (MVP) - What's Been Built
### ✅ Complete Features
1. **User Authentication System**
- 3 role levels: Owner, Admin, Staff
- Secure password hashing
- Session management
- Default test accounts created
2. **Session Management**
- Admin can create count sessions
- Session types: Cycle Count, Full Physical
- Active session tracking
3. **MASTER Baseline Upload**
- CSV import from NetSuite
- Validation of required columns
- Storage in BaselineInventory_Master table
- Never changes during session
4. **Scanning Interface**
- Mobile-optimized, high-contrast design
- Location scanning workflow
- Lot scanning with immediate validation
- Weight entry modal
- Real-time status feedback
5. **Variance Detection**
- ✅ MATCH: Lot in correct location
- ⚠️ WRONG LOCATION: Lot exists elsewhere
- ❌ GHOST LOT: Not in system
6. **Real-Time Dashboard**
- Admin view: All sessions, statistics
- Staff view: Active session selection
- Session detail with progress tracking
- Active counter monitoring
7. **Mobile-First Design**
- Dark theme for warehouse environment
- Large touch targets (44px+)
- Scanner-optimized inputs
- Autofocus on scan fields
- Color-coded status indicators
---
## 🧪 Testing the Application
### Step 1: Start the Server
```bash
cd /home/claude/scanlook
./start.sh
```
Or manually:
```bash
python app.py
```
The server will start on `http://localhost:5000`
### Step 2: Admin Workflow Test
1. **Login as Admin**
- Username: `admin`
- Password: `admin123`
2. **Create a Count Session**
- Click "New Session"
- Session name: "Test Session - January 17 2026"
- Type: Cycle Count
- Click "Create Session"
3. **Upload MASTER Baseline**
- In session detail, find "MASTER Baseline" section
- Click "Choose File" → select `sample_baseline.csv`
- Click "Upload MASTER"
- Verify: "✅ MASTER baseline uploaded: 16 records"
4. **Monitor Dashboard**
- View real-time statistics (all zeros initially)
- Check baseline status shows uploaded timestamp
### Step 3: Staff Workflow Test
1. **Logout** (top right) and **Login as Staff**
- Username: `staff1`
- Password: `staff123`
2. **Select Active Session**
- Click on "Test Session - January 17 2026"
3. **Start Counting a Location**
- Type or scan: `R903B`
- Click "START"
- You're now counting location R903B
4. **Scan First Lot**
- Lot input: `20260108-132620-PO25146`
- Press Enter (or click submit)
- Weight modal appears
- Enter weight: `2030`
- Click "Save"
- Result: ✅ **MATCH** (green badge)
5. **Scan Second Lot (Same Location)**
- Lot input: `20260108-132700-PO25146`
- Enter weight: `2030`
- Result: ✅ **MATCH**
6. **Scan Third Lot (Wrong Location)**
- Lot input: `20260106-150649-PO24949`
- Enter weight: `2065`
- Result: ⚠️ **WRONG LOCATION** (yellow badge)
- Shows expected: R905C
7. **Scan Ghost Lot (Not in System)**
- Lot input: `GHOST-LOT-999`
- Enter weight: `1500`
- Result: ❌ **GHOST LOT** (red badge)
8. **View Your Scans**
- Scroll down to see all scanned lots
- Each shows:
- Lot number
- Weight
- Item description
- Status badge
- Timestamp
9. **Finish Location**
- Click "✓ Finish Location"
- Confirm
- Returns to location selection
10. **Count Another Location**
- Try location: `R810D`
- Scan lots: `20260110-084530-PO25200`, `20260110-091215-PO25200`
### Step 4: Admin Monitoring Test
1. **Login as Admin** again
2. **View Session Detail**
- See statistics updated:
- Matched: X lots
- Wrong Location: Y lots
- Ghost Lots: Z lots
3. **Check Active Counters**
- Shows "John Doe" at location (if still in progress)
4. **View Location Progress**
- Table shows:
- R903B: ✓ Done
- R810D: ⏳ Counting (or ✓ Done)
---
## 📊 Understanding the Data Flow
### MASTER Baseline Flow:
```
1. Admin uploads CSV
2. System parses and validates
3. Inserts into BaselineInventory_Master
4. Updates session.master_baseline_timestamp
5. Staff can now scan and validate against this
```
### Scanning Flow:
```
1. Staff scans location → Creates LocationCount record
2. Staff scans lot → System looks up in MASTER baseline
3. Validates:
- Exists? → Check location
- Correct? → MATCH
- Wrong? → WRONG_LOCATION
- Doesn't exist? → GHOST_LOT
4. Staff enters weight
5. Insert into ScanEntries with status
6. Update LocationCount.lots_found counter
7. Dashboard auto-updates via page refresh
```
---
## 🔍 Database Inspection
Check what's in the database:
```bash
cd /home/claude/scanlook
sqlite3 database/scanlook.db
```
Useful queries:
```sql
-- View all users
SELECT * FROM Users;
-- View all sessions
SELECT * FROM CountSessions;
-- View MASTER baseline
SELECT * FROM BaselineInventory_Master LIMIT 10;
-- View all scans
SELECT
lot_number,
scanned_location,
actual_weight,
master_status
FROM ScanEntries
ORDER BY scan_timestamp DESC;
-- View location counts
SELECT * FROM LocationCounts;
-- Exit
.quit
```
---
## 🐛 Troubleshooting
### Issue: Can't connect from scanner device
**Solution:**
1. Check server is running on `0.0.0.0` (it is by default)
2. Find server IP: `hostname -I`
3. Ensure scanner and server on same network
4. Disable firewall or allow port 5000
### Issue: CSV upload fails
**Solution:**
1. Verify CSV has exact column names (case-sensitive):
- Item, Description, Lot Number, Location, Bin Number, On Hand
2. Check for special characters or encoding issues
3. Use the `sample_baseline.csv` as template
### Issue: Scans not showing status
**Solution:**
1. Verify MASTER baseline was uploaded first
2. Check lot numbers match exactly (case-sensitive)
3. Look at browser console for JavaScript errors
### Issue: Can't log in
**Solution:**
1. Verify database was initialized: `ls database/scanlook.db`
2. Re-run: `python database/init_db.py`
3. Use exact credentials (case-sensitive):
- admin / admin123
---
## 🔒 Security Notes (For Production)
### Before Deploying to Production:
1. **Change Secret Key**
```python
# In app.py, line 12
app.secret_key = 'your-unique-random-secret-key-here'
```
2. **Change Default Passwords**
- Delete default users from database
- Create new users with strong passwords
3. **Enable HTTPS**
- Use reverse proxy (nginx/Apache)
- Get SSL certificate (Let's Encrypt)
4. **Restrict Network Access**
- Firewall rules
- VPN or internal network only
5. **Backup Database**
- Regular automated backups
- Test restore procedures
6. **Set Proper File Permissions**
```bash
chmod 600 database/scanlook.db
```
---
## 📈 Next Steps: Phase 2
### CURRENT Baseline Refresh (Next to Build)
**What it does:**
- Admin uploads new CSV anytime during session
- Soft deletes old CURRENT baseline
- Inserts new version with incremented version number
- Recalculates CURRENT status for all existing scans
- Shows what moved/changed after count
**New statuses:**
- `still_there`: Still in same location
- `moved_after_count`: Lot moved after we counted it
- `removed_from_system`: No longer in system
- `still_wrong`: Still in wrong location
**Benefits:**
- Proves count was correct
- Shows operational changes during count
- Distinguishes real variances from movement
---
## 🎯 Production Deployment Checklist
- [ ] Change app.secret_key
- [ ] Change all default passwords
- [ ] Set up HTTPS/reverse proxy
- [ ] Configure firewall rules
- [ ] Set up database backups
- [ ] Test on actual scanner devices
- [ ] Train staff on interface
- [ ] Create support documentation
- [ ] Set up monitoring/logging
- [ ] Plan data retention policy
---
## 📝 Sample Test Scenarios
### Scenario 1: Perfect Count
- All lots scanned in correct locations
- Weights match exactly
- Result: 100% accuracy, no variances
### Scenario 2: Location Errors
- Some lots in wrong bins
- System flags immediately
- Admin can investigate
### Scenario 3: Ghost Inventory
- Physical lots not in system
- Identifies data quality issues
- Needs system update
### Scenario 4: Missing Lots
- System expects lots not found
- Possible theft, damage, or location error
- Flagged for investigation
---
## 💻 Development Notes
### File Structure:
```
scanlook/
├── app.py # Main application (500+ lines)
├── database/
│ ├── init_db.py # DB schema & initialization
│ └── scanlook.db # SQLite database
├── static/
│ ├── css/style.css # ~1000 lines of custom CSS
│ └── js/main.js # Client-side utilities
├── templates/ # 8 HTML templates
├── sample_baseline.csv # Test data
├── start.sh # Startup script
├── requirements.txt # Python dependencies
└── README.md # User documentation
```
### Code Quality:
- Parameterized SQL queries (injection prevention)
- Password hashing (Werkzeug)
- Session management (Flask)
- Role-based access control
- Soft deletes (audit trail)
- Mobile-first responsive design
- High-contrast warehouse theme
---
## 🎨 Design Philosophy
**Why this design?**
1. **Dark Theme**: Better for warehouse lighting conditions
2. **High Contrast**: Easy to read at a glance
3. **Large Inputs**: Scanner-friendly, touch-optimized
4. **Minimal Typing**: Scan everything possible
5. **Instant Feedback**: Color-coded status immediately
6. **Autofocus**: Scanner workflow optimization
7. **Bold Typography**: Legibility in fast-paced environment
**Color Meanings:**
- **Cyan (#00d4ff)**: Primary actions, matches
- **Green (#00ff88)**: Success, correct scans
- **Yellow (#ffaa00)**: Warnings, wrong location
- **Red (#ff3366)**: Errors, ghost lots, missing
---
## 🚀 Ready to Deploy?
Once testing is complete and you're satisfied with Phase 1:
1. Copy entire `scanlook` folder to production server
2. Follow Production Deployment Checklist above
3. Train users on the workflow
4. Start with pilot (small session)
5. Gather feedback
6. Scale up to full operations
**Remember:** This replaces 5 days of work with a weekend. You're about to be a hero at your workplace! 🎉
---
*Built January 2026 • ScanLook v1.0 (Phase 1)*

337
README.md Normal file
View File

@@ -0,0 +1,337 @@
# ScanLook - Inventory Management System
**Barcode-based inventory counting system designed for food production facilities**
Replace 5 days of manual year-end physical inventory with a weekend project. Track variances, location errors, phantom lots, and ghost inventory in real-time.
---
## 🚀 Quick Start
### 1. Install Dependencies
```bash
pip install flask werkzeug --break-system-packages
```
### 2. Initialize Database
```bash
cd /home/claude/scanlook
python database/init_db.py
```
This creates the database with default users:
- **Owner**: `owner` / `owner123`
- **Admin**: `admin` / `admin123`
- **Staff**: `staff1` / `staff123`
- **Staff**: `staff2` / `staff123`
### 3. Run Application
```bash
python app.py
```
Access at: **http://localhost:5000**
---
## 📱 Accessing from Scanner Devices
For Zebra Android scanners or tablets on the same network:
1. Find your server's IP address:
```bash
hostname -I
```
2. On scanner device, open browser and navigate to:
```
http://YOUR_SERVER_IP:5000
```
3. Scanner trigger acts as Enter key (keyboard wedge mode)
---
## 👥 User Roles
### Owner (Level 1)
- Super admin privileges
- Manage all admins
- View all sessions across branches
### Admin (Level 2)
- Create count sessions
- Upload MASTER baseline (morning snapshot)
- Upload CURRENT baseline (refresh anytime)
- View real-time dashboard
- Export variance reports
- Manage staff users
### Staff/Counter (Level 3)
- Select active count session
- Scan locations and lots
- Enter weights
- Edit/delete their own scans
- View their progress
---
## 🔄 Core Workflow
### Phase 1: Session Setup (Admin)
1. **Log in as admin**
2. **Create new count session**:
- Session name: "January 17, 2026 - Daily Cycle Count"
- Type: Cycle Count OR Full Physical Inventory
3. **Upload MASTER baseline** CSV from NetSuite:
- Format: `Item, Description, Lot Number, Location, Bin Number, On Hand`
- Creates baseline version 1
4. Session status: **Active**
5. Counters can now begin
### Phase 2: Counting (Staff)
1. **Log in as staff**
2. **Select active session**
3. **Scan location barcode** (e.g., R903B)
- System marks location "in progress"
- Shows: "Counting R903B"
4. **Scan lot barcode**
5. System checks lot against MASTER baseline:
- ✅ **MATCH**: Expected here, location correct
- ⚠️ **WRONG LOCATION**: Lot exists but system says different location
- ❌ **GHOST LOT**: Not in system at all
6. **Enter weight**
7. Save → returns to scan input (autofocus)
8. Repeat for all lots in location
9. Click **"Finish Location"**
### Phase 3: Admin Monitoring
**Real-Time Dashboard Shows:**
- Total locations & completion percentage
- Active counters with current location
- Variance summary (matches, wrong locations, missing, ghost lots)
**Optional CURRENT Baseline Refresh:**
- Admin uploads new CSV from NetSuite
- System recalculates CURRENT status for all scans
- Shows what changed in system after count
- Proves count was correct, lot moved later
---
## 📊 CSV Import Format
**Required columns:**
```csv
Item,Description,Lot Number,Location,Bin Number,On Hand
100001,Beef MDM,20260108-132620-PO25146,Wisconsin G & H,R903B,2030
100001,Beef MDM,20260108-132700-PO25146,Wisconsin G & H,R903B,2030
100001,Beef MDM,20260106-150649-PO24949,Wisconsin G & H,R905C,2065
```
**Column Descriptions:**
- **Item**: SKU or item number
- **Description**: Product description
- **Lot Number**: Unique lot identifier
- **Location**: Warehouse location name
- **Bin Number**: Specific bin/rack location code
- **On Hand**: Current quantity in pounds
---
## 🎯 Key Features
### Dual Baseline System
**MASTER Baseline**:
- Uploaded once at session start (e.g., 6:00 AM)
- Never changes during session
- What counters validate against
- Represents "what should have been there when we started"
**CURRENT Baseline**:
- Admin can refresh unlimited times
- Only affects admin dashboard/reporting
- Counters never see it
- Shows "where is it in the system NOW"
- Identifies movement after count
### Variance Detection
- **Perfect Match**: Right location, weight within tolerance
- **Weight Variance**: Right location, weight differs (calculates %)
- **Wrong Location**: Lot exists but scanned in different location
- **Ghost Lot**: Scanned but not in baseline at all
- **Missing/Phantom**: In baseline but not found during count
### Blind Counting
- Counters don't see expected quantities
- Prevents bias
- Ensures honest count
### Real-Time Dashboard
- Active counters with current location
- Progress by location
- Variance counts updating live
- Dual view (MASTER vs CURRENT) after refresh
### Audit Trail
- Every scan timestamped with user
- Soft deletes tracked
- Edits tracked with modified timestamp
- Baseline version tracking
---
## 🗂️ Database Schema
### Tables:
- **Users**: Authentication and role management
- **CountSessions**: Count session metadata
- **BaselineInventory_Master**: Morning baseline (never changes)
- **BaselineInventory_Current**: Refreshable baseline (soft delete)
- **LocationCounts**: Location-by-location progress
- **ScanEntries**: Every lot scan with dual status tracking
- **MissingLots**: Expected lots not found
### Indexes:
Optimized for fast lookups on lot numbers, locations, and statuses.
---
## 🔧 Technology Stack
- **Backend**: Python 3.13 + Flask
- **Database**: SQLite
- **Frontend**: HTML5 + CSS3 + Vanilla JavaScript
- **Templates**: Jinja2
---
## 📂 Project Structure
```
scanlook/
├── app.py # Main Flask application
├── database/
│ ├── init_db.py # Database initialization
│ └── scanlook.db # SQLite database (created)
├── static/
│ ├── css/
│ │ └── style.css # Main stylesheet
│ └── js/
│ └── main.js # Client-side JavaScript
└── templates/
├── base.html # Base template
├── login.html # Login page
├── admin_dashboard.html
├── staff_dashboard.html
├── create_session.html
├── session_detail.html
├── count_session.html
└── count_location.html
```
---
## 🎨 Design Philosophy
**Mobile-First, Warehouse-Optimized:**
- High contrast dark theme for warehouse lighting
- Large touch targets (44px minimum)
- Autofocus on scan inputs
- Scanner trigger = Enter key
- Visual feedback with color-coded statuses
- Minimal typing required
**Color Coding:**
- ✅ Green: Perfect match
- ⚠️ Yellow: Wrong location
- ❌ Red: Missing or ghost lot
- 🔵 Blue: Weight variance
---
## 🚧 Current Status: Phase 1 Complete (MVP)
### ✅ Implemented:
- User authentication (3 roles)
- Session creation
- MASTER baseline upload
- Location scanning
- Lot scanning with MASTER status
- Weight entry
- Basic dashboard
- Real-time progress tracking
- Mobile-optimized UI
### 🔜 Next Phases:
- **Phase 2**: CURRENT baseline refresh & dual status recalculation
- **Phase 3**: Missing lot detection, enhanced dashboard
- **Phase 4**: Excel export with multiple tabs
- **Phase 5**: Data retention automation, user management
- **Phase 6**: Docker deployment
---
## 📝 Development Notes
### Default Accounts (Testing)
All passwords should be changed in production!
### Security Considerations
- Passwords are hashed with Werkzeug
- Session management via Flask sessions
- SQL injection prevention (parameterized queries)
- Soft deletes preserve audit trail
### Data Retention
- Plan: Keep data for 30 days
- Nightly cleanup job (to be implemented)
- Archive old sessions
---
## 🎯 Success Metrics
**Current State (Year-End Physical):**
- Duration: 5 days
- Data entry: Manual, error-prone
- Variance resolution: Weeks later
**Target State (with ScanLook):**
- Duration: 1 weekend (2 days max)
- Data entry: Zero (all scanned)
- Variance resolution: Real-time flagging
- Accuracy: 99%+ (no handwriting errors)
- Time savings: 60%+
---
## 💡 Future Enhancements
- Multi-branch support
- Direct NetSuite API integration
- Photo capture for variance proof
- Barcode generation for location labels
- Native mobile apps
- Offline mode with sync
---
## 📧 Support
This is a custom internal tool. Contact your system administrator for support.
**Built with ❤️ for efficient inventory management**
*Inspired by BomLook from the FoxPro days* 🚀

1465
app.py Normal file

File diff suppressed because it is too large Load Diff

222
database/init_db.py Normal file
View File

@@ -0,0 +1,222 @@
"""
ScanLook Database Initialization
Creates all tables and indexes for the inventory management system
UPDATED: Reflects post-migration schema (CURRENT baseline is now global)
"""
import sqlite3
import os
from datetime import datetime
from werkzeug.security import generate_password_hash
DB_PATH = os.path.join(os.path.dirname(__file__), 'scanlook.db')
def init_database():
"""Initialize the database with all tables and indexes"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Users Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS Users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'staff')),
is_active INTEGER DEFAULT 1,
branch TEXT DEFAULT 'Main',
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# CountSessions Table
# NOTE: current_baseline_version removed - CURRENT is now global
cursor.execute('''
CREATE TABLE IF NOT EXISTS CountSessions (
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')),
created_by INTEGER NOT NULL,
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_baseline_timestamp DATETIME,
current_baseline_timestamp DATETIME,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
branch TEXT DEFAULT 'Main',
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
# BaselineInventory_Master Table (Session-specific, immutable)
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Master (
baseline_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT NOT NULL,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
uploaded_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id)
)
''')
# BaselineInventory_Current Table (GLOBAL - shared across all sessions)
# MIGRATION CHANGE: No session_id, no baseline_version, no is_deleted
# This table is replaced entirely on each upload
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Current (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
)
''')
# LocationCounts Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS LocationCounts (
location_count_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_name TEXT NOT NULL,
counted_by INTEGER NOT NULL,
start_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
end_timestamp DATETIME,
status TEXT DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed')),
expected_lots_master INTEGER DEFAULT 0,
lots_found INTEGER DEFAULT 0,
lots_missing INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (counted_by) REFERENCES Users(user_id)
)
''')
# ScanEntries Table
# MIGRATION CHANGE: Removed current_* columns - now fetched via JOIN
cursor.execute('''
CREATE TABLE IF NOT EXISTS ScanEntries (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT,
description TEXT,
scanned_location TEXT NOT NULL,
actual_weight REAL NOT NULL,
scanned_by INTEGER NOT NULL,
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
-- MASTER baseline comparison (immutable, set at scan time)
master_status TEXT CHECK(master_status IN ('match', 'wrong_location', 'ghost_lot', 'missing')),
master_expected_location TEXT,
master_expected_weight REAL,
master_variance_lbs REAL,
master_variance_pct REAL,
-- Duplicate detection
duplicate_status TEXT DEFAULT '00' CHECK(duplicate_status IN ('00', '01', '03', '04')),
duplicate_info TEXT,
-- CURRENT baseline comparison removed - now via JOIN in queries
-- Removed: current_status, current_system_location, current_system_weight,
-- current_variance_lbs, current_variance_pct, current_baseline_version
-- Metadata
comment TEXT,
is_deleted INTEGER DEFAULT 0,
deleted_by INTEGER,
deleted_timestamp DATETIME,
modified_timestamp DATETIME,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (scanned_by) REFERENCES Users(user_id),
FOREIGN KEY (deleted_by) REFERENCES Users(user_id)
)
''')
# MissingLots Table
cursor.execute('''
CREATE TABLE IF NOT EXISTS MissingLots (
missing_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER,
lot_number TEXT NOT NULL,
item TEXT,
master_expected_location TEXT NOT NULL,
master_expected_quantity REAL NOT NULL,
current_system_location TEXT,
current_system_quantity REAL,
marked_by INTEGER NOT NULL,
marked_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
found_later TEXT DEFAULT 'N' CHECK(found_later IN ('Y', 'N')),
found_location TEXT,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (marked_by) REFERENCES Users(user_id)
)
''')
# Create Indexes
# MASTER baseline indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_lot ON BaselineInventory_Master(session_id, lot_number)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_loc ON BaselineInventory_Master(session_id, system_location)')
# ScanEntries indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_session ON ScanEntries(session_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_location ON ScanEntries(location_count_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_lot ON ScanEntries(lot_number)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_deleted ON ScanEntries(is_deleted)')
# LocationCounts indexes
cursor.execute('CREATE INDEX IF NOT EXISTS idx_location_counts ON LocationCounts(session_id, status)')
# Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups
conn.commit()
conn.close()
print(f"✅ Database initialized at: {DB_PATH}")
print("📝 Schema version: Post-migration (CURRENT baseline is global)")
def create_default_users():
"""Create default users for testing"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
default_users = [
('owner', generate_password_hash('owner123'), 'System Owner', 'owner'),
('admin', generate_password_hash('admin123'), 'Admin User', 'admin'),
('staff1', generate_password_hash('staff123'), 'John Doe', 'staff'),
('staff2', generate_password_hash('staff123'), 'Jane Smith', 'staff'),
]
try:
cursor.executemany('''
INSERT INTO Users (username, password, full_name, role)
VALUES (?, ?, ?, ?)
''', default_users)
conn.commit()
print("✅ Default users created:")
print(" Owner: owner / owner123")
print(" Admin: admin / admin123")
print(" Staff: staff1 / staff123")
print(" Staff: staff2 / staff123")
except sqlite3.IntegrityError:
print(" Default users already exist")
conn.close()
if __name__ == '__main__':
init_database()
create_default_users()

View File

@@ -0,0 +1,155 @@
-- Migration: Make CURRENT baseline global (not session-specific)
-- Run this ONCE to update existing database
BEGIN TRANSACTION;
-- Step 1: Create new global CURRENT table
CREATE TABLE IF NOT EXISTS BaselineInventory_Current_New (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
);
-- Step 2: Copy latest CURRENT data (if any exists)
-- Get the most recent baseline_version
INSERT OR IGNORE INTO BaselineInventory_Current_New
(lot_number, item, description, system_location, system_bin, system_quantity, upload_timestamp)
SELECT
lot_number,
item,
description,
system_location,
system_bin,
system_quantity,
upload_timestamp
FROM BaselineInventory_Current
WHERE is_deleted = 0
AND baseline_version = (SELECT MAX(baseline_version) FROM BaselineInventory_Current WHERE is_deleted = 0)
ORDER BY upload_timestamp DESC;
-- Step 3: Drop old CURRENT table
DROP TABLE IF EXISTS BaselineInventory_Current;
-- Step 4: Rename new table
ALTER TABLE BaselineInventory_Current_New RENAME TO BaselineInventory_Current;
-- Step 5: Remove CURRENT columns from ScanEntries
-- SQLite doesn't support DROP COLUMN directly, so we recreate the table
CREATE TABLE ScanEntries_New (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT,
description TEXT,
scanned_location TEXT NOT NULL,
actual_weight REAL NOT NULL,
scanned_by INTEGER NOT NULL,
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
-- MASTER baseline reference (stored for performance)
master_status TEXT,
master_expected_location TEXT,
master_expected_weight REAL,
master_variance_lbs REAL,
master_variance_pct REAL,
-- Duplicate tracking
duplicate_status TEXT DEFAULT '00',
duplicate_info TEXT,
-- Metadata
comment TEXT,
is_deleted INTEGER DEFAULT 0,
deleted_by INTEGER,
deleted_timestamp DATETIME,
modified_timestamp DATETIME,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (scanned_by) REFERENCES Users(user_id),
FOREIGN KEY (deleted_by) REFERENCES Users(user_id)
);
-- Copy all scan data
INSERT INTO ScanEntries_New
SELECT
entry_id,
session_id,
location_count_id,
lot_number,
item,
description,
scanned_location,
actual_weight,
scanned_by,
scan_timestamp,
master_status,
master_expected_location,
master_expected_weight,
master_variance_lbs,
master_variance_pct,
duplicate_status,
duplicate_info,
comment,
is_deleted,
deleted_by,
deleted_timestamp,
modified_timestamp
FROM ScanEntries;
-- Drop old table and rename
DROP TABLE ScanEntries;
ALTER TABLE ScanEntries_New RENAME TO ScanEntries;
-- Recreate indexes
CREATE INDEX idx_scanentries_session ON ScanEntries(session_id);
CREATE INDEX idx_scanentries_location ON ScanEntries(location_count_id);
CREATE INDEX idx_scanentries_lot ON ScanEntries(lot_number);
CREATE INDEX idx_scanentries_deleted ON ScanEntries(is_deleted);
-- Step 6: Remove current_baseline_version from CountSessions
-- Recreate CountSessions table
CREATE TABLE CountSessions_New (
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'active',
branch TEXT DEFAULT 'Main',
created_by INTEGER NOT NULL,
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_baseline_timestamp DATETIME,
current_baseline_timestamp DATETIME,
completed_timestamp DATETIME,
FOREIGN KEY (created_by) REFERENCES Users(user_id)
);
-- Copy session data
INSERT INTO CountSessions_New
SELECT
session_id,
session_name,
description,
status,
branch,
created_by,
created_timestamp,
master_baseline_timestamp,
current_baseline_timestamp,
completed_timestamp
FROM CountSessions;
-- Drop old and rename
DROP TABLE CountSessions;
ALTER TABLE CountSessions_New RENAME TO CountSessions;
COMMIT;
-- Migration complete!
-- CURRENT baseline is now global and always shows latest data

BIN
database/scanlook.db Normal file

Binary file not shown.

BIN
database/scanlook.db.backup Normal file

Binary file not shown.

Binary file not shown.

234
migrate_current_global.py Normal file
View File

@@ -0,0 +1,234 @@
"""
Migration Script: Make CURRENT baseline global (not session-specific)
Run this once to update your database schema
"""
import sqlite3
import os
# Database path
DB_PATH = os.path.join(os.path.dirname(__file__), 'database', 'scanlook.db')
def run_migration():
print("Starting migration...")
print(f"Database: {DB_PATH}")
# Backup first!
backup_path = DB_PATH + '.backup_before_current_migration'
import shutil
shutil.copy2(DB_PATH, backup_path)
print(f"✅ Backup created: {backup_path}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Step 1: Create new global CURRENT table
print("\nStep 1: Creating new BaselineInventory_Current table...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS BaselineInventory_Current_New (
current_id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_number TEXT NOT NULL,
item TEXT NOT NULL,
description TEXT,
system_location TEXT,
system_bin TEXT NOT NULL,
system_quantity REAL NOT NULL,
upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(lot_number, system_bin)
)
''')
print("✅ New CURRENT table created")
# Step 2: Copy latest CURRENT data
print("\nStep 2: Copying latest CURRENT data...")
cursor.execute('''
INSERT OR IGNORE INTO BaselineInventory_Current_New
(lot_number, item, description, system_location, system_bin, system_quantity, upload_timestamp)
SELECT
lot_number,
item,
description,
system_location,
system_bin,
system_quantity,
uploaded_timestamp
FROM BaselineInventory_Current
WHERE is_deleted = 0
AND baseline_version = (SELECT MAX(baseline_version) FROM BaselineInventory_Current WHERE is_deleted = 0)
''')
rows = cursor.rowcount
print(f"✅ Copied {rows} CURRENT baseline records")
# Step 3: Drop old CURRENT table
print("\nStep 3: Dropping old CURRENT table...")
cursor.execute('DROP TABLE IF EXISTS BaselineInventory_Current')
print("✅ Old CURRENT table dropped")
# Step 4: Rename new table
print("\nStep 4: Renaming new CURRENT table...")
cursor.execute('ALTER TABLE BaselineInventory_Current_New RENAME TO BaselineInventory_Current')
print("✅ Table renamed")
# Step 5: Create new ScanEntries without CURRENT columns
print("\nStep 5: Creating new ScanEntries table (without CURRENT columns)...")
cursor.execute('''
CREATE TABLE ScanEntries_New (
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
location_count_id INTEGER NOT NULL,
lot_number TEXT NOT NULL,
item TEXT,
description TEXT,
scanned_location TEXT NOT NULL,
actual_weight REAL NOT NULL,
scanned_by INTEGER NOT NULL,
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_status TEXT,
master_expected_location TEXT,
master_expected_weight REAL,
master_variance_lbs REAL,
master_variance_pct REAL,
duplicate_status TEXT DEFAULT '00',
duplicate_info TEXT,
comment TEXT,
is_deleted INTEGER DEFAULT 0,
deleted_by INTEGER,
deleted_timestamp DATETIME,
modified_timestamp DATETIME,
FOREIGN KEY (session_id) REFERENCES CountSessions(session_id),
FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id),
FOREIGN KEY (scanned_by) REFERENCES Users(user_id),
FOREIGN KEY (deleted_by) REFERENCES Users(user_id)
)
''')
print("✅ New ScanEntries table created")
# Step 6: Copy scan data
print("\nStep 6: Copying scan data...")
cursor.execute('''
INSERT INTO ScanEntries_New
SELECT
entry_id,
session_id,
location_count_id,
lot_number,
item,
description,
scanned_location,
actual_weight,
scanned_by,
scan_timestamp,
master_status,
master_expected_location,
master_expected_weight,
master_variance_lbs,
master_variance_pct,
duplicate_status,
duplicate_info,
comment,
is_deleted,
deleted_by,
deleted_timestamp,
modified_timestamp
FROM ScanEntries
''')
rows = cursor.rowcount
print(f"✅ Copied {rows} scan entries")
# Step 7: Drop old and rename
print("\nStep 7: Replacing old ScanEntries table...")
cursor.execute('DROP TABLE ScanEntries')
cursor.execute('ALTER TABLE ScanEntries_New RENAME TO ScanEntries')
print("✅ ScanEntries table updated")
# Step 8: Recreate indexes
print("\nStep 8: Recreating indexes...")
cursor.execute('CREATE INDEX idx_scanentries_session ON ScanEntries(session_id)')
cursor.execute('CREATE INDEX idx_scanentries_location ON ScanEntries(location_count_id)')
cursor.execute('CREATE INDEX idx_scanentries_lot ON ScanEntries(lot_number)')
cursor.execute('CREATE INDEX idx_scanentries_deleted ON ScanEntries(is_deleted)')
print("✅ Indexes created")
# Step 9: Create new CountSessions
print("\nStep 9: Creating new CountSessions table...")
cursor.execute('''
CREATE TABLE CountSessions_New (
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')),
created_by INTEGER NOT NULL,
created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
master_baseline_timestamp DATETIME,
current_baseline_timestamp DATETIME,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
branch TEXT DEFAULT 'Main',
FOREIGN KEY (created_by) REFERENCES Users(user_id)
)
''')
print("✅ New CountSessions table created")
# Step 10: Copy session data
print("\nStep 10: Copying session data...")
cursor.execute('''
INSERT INTO CountSessions_New
SELECT
session_id,
session_name,
session_type,
created_by,
created_timestamp,
master_baseline_timestamp,
current_baseline_timestamp,
status,
branch
FROM CountSessions
''')
rows = cursor.rowcount
print(f"✅ Copied {rows} sessions")
# Step 11: Replace CountSessions
print("\nStep 11: Replacing old CountSessions table...")
cursor.execute('DROP TABLE CountSessions')
cursor.execute('ALTER TABLE CountSessions_New RENAME TO CountSessions')
print("✅ CountSessions table updated")
# Commit all changes
conn.commit()
print("\n" + "="*50)
print("🎉 MIGRATION COMPLETE!")
print("="*50)
print("\nChanges made:")
print(" ✅ BaselineInventory_Current is now GLOBAL (not session-specific)")
print(" ✅ Removed CURRENT columns from ScanEntries")
print(" ✅ Removed current_baseline_version from CountSessions")
print(" ✅ CURRENT data will now always show latest via JOIN")
print("\nNext steps:")
print(" 1. Replace app.py with updated version")
print(" 2. Restart Flask")
print(" 3. Test uploading CURRENT baseline")
except Exception as e:
print(f"\n❌ ERROR: {e}")
print("Rolling back...")
conn.rollback()
print("Migration failed. Database unchanged.")
print(f"Backup available at: {backup_path}")
raise
finally:
conn.close()
if __name__ == '__main__':
print("="*50)
print("CURRENT Baseline Migration")
print("="*50)
response = input("\nThis will modify your database structure. Continue? (yes/no): ")
if response.lower() == 'yes':
run_migration()
else:
print("Migration cancelled.")

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Flask==3.0.0
Werkzeug==3.0.1

17
sample_baseline.csv Normal file
View File

@@ -0,0 +1,17 @@
Item,Description,Lot Number,Location,Bin Number,On Hand
100001,Beef MDM,20260108-132620-PO25146,Wisconsin G & H,R903B,2030
100001,Beef MDM,20260108-132700-PO25146,Wisconsin G & H,R903B,2030
100001,Beef MDM,20260106-150649-PO24949,Wisconsin G & H,R905C,2065
100002,Pork Trimmings,20260110-084530-PO25200,Midwest Meats,R810D,1850
100002,Pork Trimmings,20260110-091215-PO25200,Midwest Meats,R810D,1875
100003,Chicken Breast,20260112-143045-PO25310,Poultry Plus,W202A,1250
100003,Chicken Breast,20260112-150820-PO25310,Poultry Plus,W202A,1275
100004,Turkey Dark Meat,20260115-102535-PO25450,Heritage Farms,W205B,985
100004,Turkey Dark Meat,20260115-105640-PO25450,Heritage Farms,W205B,1010
100005,Lamb Shoulder,20260114-133210-PO25380,Premium Lamb Co,R812F,750
100006,Beef Brisket,20260111-161545-PO25260,Texas Cattle,R903B,1680
100006,Beef Brisket,20260111-163920-PO25260,Texas Cattle,R903B,1695
100007,Pork Belly,20260113-094815-PO25340,Iowa Farms,R810D,1420
100008,Chicken Thighs,20260116-121030-PO25480,Organic Poultry,W202A,890
100009,Duck Breast,20260109-153245-PO25175,Farm Fresh,W210C,625
100010,Venison Ground,20260107-105520-PO25120,Wild Game Inc,R815G,540
1 Item Description Lot Number Location Bin Number On Hand
2 100001 Beef MDM 20260108-132620-PO25146 Wisconsin G & H R903B 2030
3 100001 Beef MDM 20260108-132700-PO25146 Wisconsin G & H R903B 2030
4 100001 Beef MDM 20260106-150649-PO24949 Wisconsin G & H R905C 2065
5 100002 Pork Trimmings 20260110-084530-PO25200 Midwest Meats R810D 1850
6 100002 Pork Trimmings 20260110-091215-PO25200 Midwest Meats R810D 1875
7 100003 Chicken Breast 20260112-143045-PO25310 Poultry Plus W202A 1250
8 100003 Chicken Breast 20260112-150820-PO25310 Poultry Plus W202A 1275
9 100004 Turkey Dark Meat 20260115-102535-PO25450 Heritage Farms W205B 985
10 100004 Turkey Dark Meat 20260115-105640-PO25450 Heritage Farms W205B 1010
11 100005 Lamb Shoulder 20260114-133210-PO25380 Premium Lamb Co R812F 750
12 100006 Beef Brisket 20260111-161545-PO25260 Texas Cattle R903B 1680
13 100006 Beef Brisket 20260111-163920-PO25260 Texas Cattle R903B 1695
14 100007 Pork Belly 20260113-094815-PO25340 Iowa Farms R810D 1420
15 100008 Chicken Thighs 20260116-121030-PO25480 Organic Poultry W202A 890
16 100009 Duck Breast 20260109-153245-PO25175 Farm Fresh W210C 625
17 100010 Venison Ground 20260107-105520-PO25120 Wild Game Inc R815G 540

20
start.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# ScanLook Startup Script
echo "🚀 Starting ScanLook Inventory Management System..."
echo ""
echo "📊 Access the application at:"
echo " - Local: http://localhost:5000"
echo " - Network: http://$(hostname -I | awk '{print $1}'):5000"
echo ""
echo "👤 Default Login Credentials:"
echo " Owner: owner / owner123"
echo " Admin: admin / admin123"
echo " Staff: staff1 / staff123"
echo ""
echo "Press Ctrl+C to stop the server"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
cd "$(dirname "$0")"
python app.py

2410
static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

90
static/js/main.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* ScanLook - Main JavaScript
* Client-side utilities and enhancements
*/
// Auto-dismiss flash messages after 5 seconds
document.addEventListener('DOMContentLoaded', function() {
const flashMessages = document.querySelectorAll('.flash');
flashMessages.forEach(function(flash) {
setTimeout(function() {
flash.style.animation = 'slideOut 0.3s ease';
setTimeout(function() {
flash.remove();
}, 300);
}, 5000);
});
});
// Animation for slide out
const style = document.createElement('style');
style.textContent = `
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(120%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
// Utility: Format timestamp
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString();
}
// Utility: Format number with commas
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// Auto-focus on scan inputs when page loads
window.addEventListener('load', function() {
const scanInput = document.querySelector('.scan-input');
if (scanInput) {
scanInput.focus();
}
});
// Prevent accidental page navigation
window.addEventListener('beforeunload', function(e) {
const isCountingPage = document.querySelector('.count-location-container');
if (isCountingPage) {
const scans = document.querySelectorAll('.scan-item').length;
if (scans > 0) {
e.preventDefault();
e.returnValue = '';
}
}
});
// Auto-uppercase inputs with specific classes
document.addEventListener('input', function(e) {
if (e.target.classList.contains('scan-input')) {
e.target.value = e.target.value.toUpperCase();
}
});
// Settings dropdown toggle
function toggleSettings() {
const menu = document.getElementById('settingsMenu');
if (menu) {
menu.classList.toggle('show');
}
}
// Close settings dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.matches('.btn-settings') && !e.target.closest('.settings-dropdown')) {
const menu = document.getElementById('settingsMenu');
if (menu && menu.classList.contains('show')) {
menu.classList.remove('show');
}
}
});

16
static/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "ScanLook Inventory",
"short_name": "ScanLook",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#06b6d4",
"icons": [
{
"src": "https://www.scanlook.net/static/icon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
]
}

5
static/sw.js Normal file
View File

@@ -0,0 +1,5 @@
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register("{{ url_for('static', filename='sw.js') }}");
}
</script>

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Mode Selector -->
<div class="mode-selector">
<button class="mode-btn mode-btn-active" onclick="window.location.href='{{ url_for('dashboard') }}'">
👔 Admin Console
</button>
<button class="mode-btn" onclick="window.location.href='{{ url_for('staff_mode') }}'">
📦 Scanning Mode
</button>
</div>
<div class="dashboard-header">
<h1 class="page-title">Admin Dashboard</h1>
<a href="{{ url_for('create_session') }}" class="btn btn-primary">
<span class="btn-icon">+</span> New Session
</a>
</div>
{% if sessions %}
<div class="sessions-grid">
{% for session in sessions %}
<div class="session-card">
<div class="session-card-header">
<h3 class="session-name">{{ session.session_name }}</h3>
<span class="session-type-badge session-type-{{ session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
</span>
</div>
<div class="session-stats">
<div class="stat-item">
<div class="stat-value">{{ session.total_locations or 0 }}</div>
<div class="stat-label">Total Locations</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.completed_locations or 0 }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ session.in_progress_locations or 0 }}</div>
<div class="stat-label">In Progress</div>
</div>
</div>
<div class="session-meta">
<div class="meta-item">
<span class="meta-label">Created:</span>
<span class="meta-value">{{ session.created_timestamp[:16] }}</span>
</div>
<div class="meta-item">
<span class="meta-label">By:</span>
<span class="meta-value">{{ session.created_by_name }}</span>
</div>
</div>
<div class="session-actions">
<a href="{{ url_for('session_detail', session_id=session.session_id) }}" class="btn btn-secondary btn-block">
View Details
</a>
</div>
</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>
{% endblock %}

67
templates/base.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{% block title %}ScanLook{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
{% if session.user_id %}
<nav class="navbar">
<div class="nav-content">
<div class="nav-left">
<a href="{{ url_for('dashboard') }}" class="logo">
<span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
</a>
</div>
<div class="nav-right">
<span class="user-badge">{{ session.full_name }} <span class="role-pill">{{ session.role }}</span></span>
{% if session.role in ['owner', 'admin'] %}
<div class="settings-dropdown">
<button class="btn-settings" onclick="toggleSettings()">⚙️</button>
<div id="settingsMenu" class="settings-menu">
<a href="{{ url_for('manage_users') }}" class="settings-item">
<span class="settings-icon">👥</span> Manage Users
</a>
</div>
</div>
{% endif %}
<a href="{{ url_for('logout') }}" class="btn-logout">Logout</a>
</div>
</div>
</nav>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">
<span class="flash-icon">
{% if category == 'success' %}✓{% elif category == 'danger' %}✕{% elif category == 'warning' %}⚠{% else %}{% endif %}
</span>
<span class="flash-message">{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="main-content">
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
<footer class="footer">
<div class="footer-content">
<p>&copy; 2026 Javier Torres. All Rights Reserved.</p>
</div>
</footer>
</html>

View File

@@ -0,0 +1,582 @@
{% extends "base.html" %}
{% block title %}Counting {{ location.location_name }}{% endblock %}
{% block content %}
<div class="count-location-container">
<div class="location-header">
<div class="location-info">
<div class="location-label">Counting Location</div>
<h1 class="location-name">{{ location.location_name }}</h1>
<div class="location-stats">
<span class="stat-pill">Expected: {{ location.expected_lots_master }}</span>
<span class="stat-pill">Found: <span id="foundCount">{{ location.lots_found }}</span></span>
</div>
</div>
</div>
<div class="scan-card scan-card-active">
<div class="scan-header">
<h2 class="scan-title">Scan Lot Barcode</h2>
</div>
<form id="lotScanForm" class="scan-form">
<div class="scan-input-group">
<input type="text"
name="lot_number"
id="lotInput"
inputmode="none"
class="scan-input"
placeholder="Scan Lot Number"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
autofocus>
<button type="submit" style="display: none;" aria-hidden="true"></button>
</div>
</form>
</div>
<div id="duplicateModal" class="modal">
<div class="modal-content modal-duplicate">
<div class="duplicate-lot-number" id="duplicateLotNumber"></div>
<h3 class="duplicate-title">Already Scanned</h3>
<p class="duplicate-message">Do you wish to Resubmit?</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary btn-lg" onclick="cancelDuplicate()">No</button>
<button type="button" class="btn btn-primary btn-lg" onclick="confirmDuplicate()">Yes</button>
</div>
</div>
</div>
<div id="weightModal" class="modal">
<div class="modal-content">
<h3 class="modal-title">Enter Weight</h3>
<div class="modal-lot-info">
<div id="modalLotNumber" class="modal-lot-number"></div>
<div id="modalItemDesc" class="modal-item-desc"></div>
</div>
<form id="weightForm" class="weight-form">
<input
type="number"
id="weightInput"
class="weight-input"
placeholder="0.0"
step="0.01"
min="0"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
required
>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="cancelWeight()">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 Lots (<span id="scanListCount">{{ scans|length }}</span>)</h3>
</div>
<div id="scansList" class="scans-grid">
{% for scan in scans %}
{# LOGIC FIX: Determine CSS class based on weight difference #}
{% set row_class = scan.master_status %}
{% if scan.duplicate_status and scan.duplicate_status != '00' %}
{% set row_class = 'duplicate-' + scan.duplicate_status %}
{% elif scan.master_status == 'match' and scan.master_expected_weight and (scan.actual_weight - scan.master_expected_weight)|abs >= 0.01 %}
{# If it is a match but weight is off, force class to weight_discrepancy #}
{% set row_class = 'weight_discrepancy' %}
{% endif %}
<div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}"
onclick="openScanDetail({{ scan.entry_id }})">
<div class="scan-row-lot">{{ scan.lot_number }}</div>
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
<div class="scan-row-status">
{% if scan.duplicate_status == '01' or scan.duplicate_status == '04' %}
<span class="status-dot status-dot-blue"></span> Duplicate
{% elif scan.duplicate_status == '03' %}
<span class="status-dot status-dot-orange"></span> Dup (Other Loc)
{% elif row_class == 'weight_discrepancy' %}
<span class="status-dot status-dot-orange"></span> Weight Off
{% elif scan.master_status == 'match' %}
<span class="status-dot status-dot-green"></span> Match
{% elif scan.master_status == 'wrong_location' %}
<span class="status-dot status-dot-yellow"></span> Wrong Loc
{% elif scan.master_status == 'ghost_lot' %}
<span class="status-dot status-dot-purple"></span> Ghost
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% if session_type == 'cycle_count' and expected_lots %}
<div class="expected-section" id="expectedSection">
<div class="scans-header">
<h3 class="scans-title expected-title">Expected Items (<span id="expectedCount">{{ expected_lots|length }}</span>)</h3>
</div>
<div class="scans-grid" id="expectedList">
{% for lot in expected_lots %}
<div class="scan-row expected-row"
id="expected-{{ lot.lot_number }}"
onclick="scanExpectedLot('{{ lot.lot_number }}')">
<div class="scan-row-lot">{{ lot.lot_number }}</div>
<div class="scan-row-item">{{ lot.item }}</div>
<div class="scan-row-weight">{{ lot.total_weight }} lbs</div>
<div class="scan-row-status">
<span class="status-dot status-dot-gray"></span> Pending
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<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('my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
← Back to My Counts
</a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
✓ Finish Location
</button>
</div>
</div>
</div>
<script>
let currentLotNumber = '';
let isDuplicateConfirmed = false;
let isProcessing = false;
// Lot scan handler
document.getElementById('lotScanForm').addEventListener('submit', function(e) {
e.preventDefault();
if (isProcessing) return;
const scannedValue = document.getElementById('lotInput').value.trim();
if (!scannedValue) return;
isProcessing = true;
currentLotNumber = scannedValue;
document.getElementById('lotInput').value = '';
checkDuplicate();
});
function checkDuplicate() {
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
lot_number: currentLotNumber,
weight: 0,
check_only: true
})
})
.then(response => response.json())
.then(data => {
if (data.needs_confirmation) {
document.getElementById('duplicateLotNumber').textContent = currentLotNumber;
document.getElementById('duplicateModal').style.display = 'flex';
} else {
showWeightModal();
}
})
.catch(error => {
console.error('Error checking duplicate:', error);
showWeightModal();
});
}
function confirmDuplicate() {
isDuplicateConfirmed = true;
document.getElementById('duplicateModal').style.display = 'none';
showWeightModal();
}
function cancelDuplicate() {
document.getElementById('duplicateModal').style.display = 'none';
document.getElementById('lotInput').focus();
isDuplicateConfirmed = false;
isProcessing = false;
}
function showWeightModal() {
if (!currentLotNumber) {
alert('Error: Lot number lost. Please scan again.');
document.getElementById('lotInput').focus();
return;
}
document.getElementById('modalLotNumber').textContent = currentLotNumber;
document.getElementById('modalItemDesc').textContent = 'Loading...';
document.getElementById('weightModal').style.display = 'flex';
document.getElementById('weightInput').value = '';
document.getElementById('weightInput').focus();
}
// Weight form handler
document.getElementById('weightForm').addEventListener('submit', function(e) {
e.preventDefault();
const weight = document.getElementById('weightInput').value;
if (!weight || weight <= 0) {
alert('Please enter a valid weight');
return;
}
submitScan(weight);
});
function submitScan(weight) {
if (!currentLotNumber) {
alert('Error: Lot number lost. Please scan again.');
document.getElementById('weightModal').style.display = 'none';
document.getElementById('lotInput').focus();
return;
}
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
lot_number: currentLotNumber.trim(),
weight: parseFloat(weight),
confirm_duplicate: isDuplicateConfirmed
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('weightModal').style.display = 'none';
isDuplicateConfirmed = false;
if (data.updated_entry_ids && data.updated_entry_ids.length > 0) {
updateExistingScansStatus(data.updated_entry_ids, data.duplicate_status);
}
addScanToList(data, weight);
// NEW: Instacart Logic - Remove from Expected List if it exists
const expectedRow = document.getElementById('expected-' + currentLotNumber);
if (expectedRow) {
expectedRow.remove();
// Update Expected count
const countSpan = document.getElementById('expectedCount');
if (countSpan) {
let currentCount = parseInt(countSpan.textContent);
if (currentCount > 0) {
countSpan.textContent = currentCount - 1;
}
// Hide section if empty
if (currentCount - 1 <= 0) {
const section = document.getElementById('expectedSection');
if (section) section.style.display = 'none';
}
}
}
currentLotNumber = '';
isProcessing = false;
document.getElementById('lotInput').focus();
} else {
alert(data.message || 'Error saving scan');
isProcessing = false;
}
})
.catch(error => {
alert('Error saving scan');
console.error(error);
isProcessing = false;
});
}
// Function to handle clicking an Expected Lot
function scanExpectedLot(lotNumber) {
if (isProcessing) return;
// Set the lot number as if it was scanned
currentLotNumber = lotNumber;
// Visual feedback (optional, but nice)
console.log('Clicked expected lot:', lotNumber);
// Proceed to check logic (just like a normal scan)
// We go through checkDuplicate just in case, consistency is safer
checkDuplicate();
}
function cancelWeight() {
document.getElementById('weightModal').style.display = 'none';
document.getElementById('lotInput').focus();
isDuplicateConfirmed = false;
isProcessing = false;
}
function updateExistingScansStatus(entryIds, duplicateStatus) {
entryIds.forEach(entryId => {
const scanRow = document.querySelector(`[data-entry-id="${entryId}"]`);
if (scanRow) {
const statusElement = scanRow.querySelector('.scan-row-status');
if (statusElement) {
let statusText = 'Duplicate';
let statusDot = 'blue';
if (duplicateStatus === '03') {
statusText = 'Dup (Other Loc)';
statusDot = 'orange';
}
statusElement.innerHTML = `<span class="status-dot status-dot-${statusDot}"></span> ${statusText}`;
}
scanRow.className = 'scan-row scan-row-duplicate-' + duplicateStatus;
}
});
}
function addScanToList(data, weight) {
const scansList = document.getElementById('scansList');
let statusClass = '';
let statusText = '';
let statusDot = '';
if (data.duplicate_status && data.duplicate_status !== '00') {
statusClass = 'duplicate-' + data.duplicate_status;
if (data.duplicate_status === '01' || data.duplicate_status === '04') {
statusText = 'Duplicate';
statusDot = 'blue';
} else if (data.duplicate_status === '03') {
statusText = 'Dup (Other Loc)';
statusDot = 'orange';
}
} else if (data.master_status === 'match') {
if (data.master_expected_weight && Math.abs(weight - data.master_expected_weight) >= 0.01) {
statusClass = 'weight_discrepancy';
statusText = 'Weight Off';
statusDot = 'orange';
} else {
statusClass = 'match';
statusText = 'Match';
statusDot = 'green';
}
} else if (data.master_status === 'wrong_location') {
statusClass = 'wrong_location';
statusText = 'Wrong Loc';
statusDot = 'yellow';
} else if (data.master_status === 'ghost_lot') {
statusClass = 'ghost_lot';
statusText = 'Ghost';
statusDot = 'purple';
}
const scanRow = document.createElement('div');
scanRow.className = 'scan-row scan-row-' + statusClass;
scanRow.setAttribute('data-entry-id', data.entry_id);
scanRow.onclick = function() { openScanDetail(data.entry_id); };
scanRow.innerHTML = `
<div class="scan-row-lot">${currentLotNumber}</div>
<div class="scan-row-item">${data.item || 'N/A'}</div>
<div class="scan-row-weight">${weight} lbs</div>
<div class="scan-row-status">
<span class="status-dot status-dot-${statusDot}"></span> ${statusText}
</div>
`;
scansList.insertBefore(scanRow, scansList.firstChild);
// Update Scanned Lots count
const countSpan = document.getElementById('scanListCount');
if (countSpan) {
countSpan.textContent = scansList.children.length;
}
// Update Header Found Count
const foundStat = document.getElementById('foundCount');
if (foundStat) {
foundStat.textContent = scansList.children.length;
}
}
function openScanDetail(entryId) {
fetch('/scan/' + entryId + '/details').then(r => r.json()).then(data => {
if (data.success) displayScanDetail(data.scan);
else alert('Error loading scan details');
});
}
function displayScanDetail(scan) {
const content = document.getElementById('scanDetailContent');
// LOGIC FIX: Check weight variance for the badge
let statusBadge = '';
// Check for weight discrepancy (Tolerance 0.01)
let isWeightOff = false;
if (scan.master_status === 'match' && scan.master_expected_weight) {
if (Math.abs(scan.actual_weight - scan.master_expected_weight) >= 0.01) {
isWeightOff = true;
}
}
if (scan.duplicate_status === '01' || scan.duplicate_status === '04') {
statusBadge = '<span class="badge badge-duplicate">🔵 Duplicate</span>';
} else if (scan.duplicate_status === '03') {
statusBadge = '<span class="badge badge-orange">🟠 Duplicate (Other Location)</span>';
} else if (isWeightOff) {
// NEW BADGE FOR WEIGHT OFF
statusBadge = '<span class="badge badge-warning">🟠 Weight Discrepancy</span>';
} else if (scan.master_status === 'match') {
statusBadge = '<span class="badge badge-success">✓ Match</span>';
} else if (scan.master_status === 'wrong_location') {
statusBadge = '<span class="badge badge-warning">⚠ Wrong Location</span>';
} else if (scan.master_status === 'ghost_lot') {
statusBadge = '<span class="badge badge-purple">🟣 Ghost Lot</span>';
}
// ... (rest of the function continues as normal)
content.innerHTML = `
<div class="detail-section">
<div class="detail-row">
<span class="detail-label">Lot Number:</span>
<span class="detail-value detail-lot">${scan.lot_number}</span>
</div>
<div class="detail-row">
<span class="detail-label">Description:</span>
<span class="detail-value">${scan.description || 'N/A'}</span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span class="detail-value">${statusBadge}</span>
</div>
${scan.duplicate_info ? `<div class="detail-row"><span class="detail-label">Info:</span><span class="detail-value detail-info">${scan.duplicate_info}</span></div>` : ''}
<div class="detail-row">
<span class="detail-label">Scanned:</span>
<span class="detail-value">${scan.scan_timestamp}</span>
</div>
</div>
<div class="detail-section">
<h4 class="detail-section-title">Edit Scan</h4>
<div class="detail-form">
<div class="form-group">
<label class="form-label">Item Number</label>
<input type="text" id="editItem" class="form-input" value="${scan.item || ''}" placeholder="Item/SKU">
</div>
<div class="form-group">
<label class="form-label">Weight (lbs)</label>
<input type="number" id="editWeight" class="form-input" value="${scan.actual_weight}" step="0.01" min="0" inputmode="decimal">
</div>
<div class="form-group">
<label class="form-label">Comment</label>
<textarea id="editComment" class="form-textarea" rows="3" placeholder="Add a comment...">${scan.comment || ''}</textarea>
</div>
</div>
</div>
<div class="detail-actions">
<button class="btn btn-secondary" onclick="closeScanDetail()">Cancel</button>
<button class="btn btn-danger" onclick="deleteFromDetail(${scan.entry_id})">Delete</button>
<button class="btn btn-primary" onclick="saveScanEdit(${scan.entry_id})">Save Changes</button>
</div>
`;
document.getElementById('scanDetailModal').style.display = 'flex';
}
function closeScanDetail() {
document.getElementById('scanDetailModal').style.display = 'none';
}
function saveScanEdit(entryId) {
const item = document.getElementById('editItem').value.trim();
const weight = document.getElementById('editWeight').value;
const comment = document.getElementById('editComment').value;
if (!weight || weight <= 0) {
alert('Please enter a valid weight');
return;
}
fetch('/scan/' + entryId + '/update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ item: item, weight: parseFloat(weight), comment: comment })
})
.then(r => r.json())
.then(data => {
if (data.success) {
closeScanDetail();
location.reload();
} else {
alert(data.message || 'Error updating scan');
}
});
}
function deleteFromDetail(entryId) {
if (!confirm('Delete this scan?')) return;
fetch('/scan/' + entryId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
closeScanDetail();
location.reload();
} else {
alert(data.message);
}
});
}
function finishLocation() {
if (!confirm('Are you finished counting this location?')) return;
fetch('{{ url_for("finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(r => r.json())
.then(data => {
if (data.success) window.location.href = data.redirect;
else alert('Error finishing location');
});
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.getElementById('weightModal').style.display = 'none';
document.getElementById('duplicateModal').style.display = 'none';
document.getElementById('lotInput').focus();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Count - {{ session.session_name }}{% endblock %}
{% block content %}
<div class="count-container">
<div class="count-header">
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back</a>
<h1 class="page-title">{{ session.session_name }}</h1>
</div>
<div class="scan-card">
<div class="scan-header">
<h2 class="scan-title">Scan Location Barcode</h2>
<p class="scan-subtitle">Point scanner at location label and trigger</p>
</div>
<form id="locationScanForm" class="scan-form">
<div class="scan-input-group">
<input
type="text"
id="locationInput"
class="scan-input"
placeholder="Scan or type location code..."
autocomplete="off"
autofocus
>
<button type="submit" class="btn-scan">START</button>
</div>
</form>
<div class="scan-help">
<div class="help-item">
<span class="help-icon">📱</span>
<span class="help-text">Hold scanner trigger to scan barcode</span>
</div>
<div class="help-item">
<span class="help-icon">⌨️</span>
<span class="help-text">Or manually type location code</span>
</div>
</div>
</div>
</div>
<script>
document.getElementById('locationScanForm').addEventListener('submit', function(e) {
e.preventDefault();
const locationCode = document.getElementById('locationInput').value.trim().toUpperCase();
if (!locationCode) {
alert('Please scan or enter a location code');
return;
}
// Show loading state
const btn = this.querySelector('.btn-scan');
const originalText = btn.textContent;
btn.textContent = 'LOADING...';
btn.disabled = true;
fetch('{{ url_for("start_location", session_id=session.session_id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'location_code=' + encodeURIComponent(locationCode)
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
alert(data.message);
btn.textContent = originalText;
btn.disabled = false;
document.getElementById('locationInput').value = '';
document.getElementById('locationInput').focus();
}
})
.catch(error => {
alert('Error starting location count');
console.error(error);
btn.textContent = originalText;
btn.disabled = false;
});
});
// Auto-uppercase input
document.getElementById('locationInput').addEventListener('input', function(e) {
this.value = this.value.toUpperCase();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Create Session - ScanLook{% endblock %}
{% block content %}
<div class="form-container">
<div class="form-card">
<div class="form-header">
<h1 class="form-title">Create New Count Session</h1>
</div>
<form method="POST" class="standard-form">
<div class="form-group">
<label for="session_name" class="form-label">Session Name *</label>
<input
type="text"
id="session_name"
name="session_name"
class="form-input"
placeholder="e.g., January 17, 2026 - Daily Cycle Count"
required
autofocus
>
<small class="form-help">Descriptive name for this count session</small>
</div>
<div class="form-group">
<label for="session_type" class="form-label">Session Type *</label>
<select id="session_type" name="session_type" class="form-select" required>
<option value="cycle_count">Cycle Count</option>
<option value="full_physical">Full Physical Inventory</option>
</select>
<small class="form-help">Choose the type of inventory count</small>
</div>
<div class="form-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Create Session</button>
</div>
</form>
</div>
</div>
{% endblock %}

54
templates/login.html Normal file
View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Login - ScanLook{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1 class="login-title">
<span class="logo-scan">SCAN</span><span class="logo-look">LOOK</span>
</h1>
<p class="login-subtitle">Inventory Management System</p>
</div>
<form method="POST" class="login-form" autocomplete="off">
<div class="form-group">
<label for="username" class="form-label">Username</label>
<input
type="text"
id="username"
name="username"
class="form-input"
required
autofocus
autocomplete="off"
>
</div>
<div class="form-group">
<label for="password" class="form-label">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
required
autocomplete="off"
>
</div>
<button type="submit" class="btn btn-primary btn-block">
Sign In
</button>
</form>
<div class="login-footer">
<p>
<strong>Created By:</strong><br>
The STUFF Networks
</p>
</div>
</div>
</div>
{% endblock %}

281
templates/manage_users.html Normal file
View File

@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}Manage Users - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="dashboard-header">
<div class="header-left">
<h1 class="page-title">Manage Users</h1>
<label class="filter-toggle">
<input type="checkbox" id="showInactive" onchange="toggleInactiveUsers()">
<span class="filter-label">Show Inactive Users</span>
</label>
</div>
<button class="btn btn-primary" onclick="openAddUser()">
<span class="btn-icon">+</span> Add User
</button>
</div>
{% if users %}
<div class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Role</th>
<th>Location</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'inactive-user' if not user.is_active else '' }}">
<td><strong>{{ user.username }}</strong></td>
<td>{{ user.full_name }}</td>
<td>{{ user.email or '-' }}</td>
<td><span class="role-pill role-{{ user.role }}">{{ user.role }}</span></td>
<td>{{ user.branch }}</td>
<td>
{% if user.is_active %}
<span class="status-badge status-success">Active</span>
{% else %}
<span class="status-badge status-neutral">Inactive</span>
{% endif %}
</td>
<td>
<div class="action-buttons">
<button class="btn-action btn-edit" onclick="openEditUser({{ user.user_id }})" title="Edit">✏️</button>
{% if user.user_id != session.user_id %}
<button class="btn-action btn-delete" onclick="deleteUser({{ user.user_id }}, '{{ user.username }}')" title="Delete">🗑️</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">👥</div>
<h2 class="empty-title">No Users Found</h2>
<p class="empty-text">Add your first user to get started</p>
</div>
{% endif %}
</div>
<!-- Add/Edit User Modal -->
<div id="userModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header-bar">
<h3 class="modal-title" id="modalTitle">Add User</h3>
<button type="button" class="btn-close-modal" onclick="closeUserModal()"></button>
</div>
<form id="userForm" class="user-form">
<input type="hidden" id="userId" value="">
<div class="form-row">
<div class="form-group">
<label class="form-label">Username *</label>
<input type="text" id="username" class="form-input" required autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">Password <span id="passwordOptional" style="display:none;">(leave blank to keep current)</span></label>
<input type="password" id="password" class="form-input" autocomplete="new-password">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">First Name *</label>
<input type="text" id="firstName" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Last Name *</label>
<input type="text" id="lastName" class="form-input" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" id="email" class="form-input">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Role *</label>
<select id="role" class="form-select" required>
{% if session.role == 'owner' %}
<option value="owner">Owner</option>
<option value="admin">Admin</option>
{% endif %}
<option value="staff">Staff</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Location</label>
<input type="text" id="branch" class="form-input" value="Main">
</div>
</div>
<div class="form-group" id="activeToggleGroup" style="display: none;">
<label class="form-label">
<input type="checkbox" id="isActive" checked> Active
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save User</button>
</div>
</form>
</div>
</div>
<script>
let editingUserId = null;
// Hide inactive users by default on page load
document.addEventListener('DOMContentLoaded', function() {
toggleInactiveUsers();
});
function toggleInactiveUsers() {
const showInactive = document.getElementById('showInactive').checked;
const inactiveRows = document.querySelectorAll('tr.inactive-user');
inactiveRows.forEach(row => {
row.style.display = showInactive ? '' : 'none';
});
}
function openAddUser() {
editingUserId = null;
document.getElementById('modalTitle').textContent = 'Add User';
document.getElementById('userForm').reset();
document.getElementById('userId').value = '';
document.getElementById('passwordOptional').style.display = 'none';
document.getElementById('password').required = true;
document.getElementById('activeToggleGroup').style.display = 'none';
document.getElementById('userModal').style.display = 'flex';
}
function openEditUser(userId) {
editingUserId = userId;
document.getElementById('modalTitle').textContent = 'Edit User';
document.getElementById('passwordOptional').style.display = 'inline';
document.getElementById('password').required = false;
document.getElementById('activeToggleGroup').style.display = 'block';
// Fetch user data
fetch('/settings/users/' + userId)
.then(response => response.json())
.then(data => {
if (data.success) {
const user = data.user;
document.getElementById('userId').value = user.user_id;
document.getElementById('username').value = user.username;
document.getElementById('firstName').value = user.first_name;
document.getElementById('lastName').value = user.last_name;
document.getElementById('email').value = user.email || '';
document.getElementById('role').value = user.role;
document.getElementById('branch').value = user.branch;
document.getElementById('isActive').checked = user.is_active == 1;
// If editing yourself, disable role change
const isEditingSelf = user.user_id === {{ session.user_id }};
document.getElementById('role').disabled = isEditingSelf;
document.getElementById('userModal').style.display = 'flex';
} else {
alert(data.message);
}
})
.catch(error => {
alert('Error loading user data');
console.error(error);
});
}
function closeUserModal() {
document.getElementById('userModal').style.display = 'none';
document.getElementById('userForm').reset();
}
document.getElementById('userForm').addEventListener('submit', function(e) {
e.preventDefault();
const userId = document.getElementById('userId').value;
const userData = {
username: document.getElementById('username').value,
password: document.getElementById('password').value,
first_name: document.getElementById('firstName').value,
last_name: document.getElementById('lastName').value,
email: document.getElementById('email').value,
role: document.getElementById('role').value,
branch: document.getElementById('branch').value,
is_active: document.getElementById('isActive').checked ? 1 : 0
};
const url = userId ? `/settings/users/${userId}/update` : '/settings/users/add';
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeUserModal();
location.reload();
} else {
alert(data.message);
}
})
.catch(error => {
alert('Error saving user');
console.error(error);
});
});
function deleteUser(userId, username) {
if (!confirm(`Delete user "${username}"? This will deactivate their account.`)) {
return;
}
fetch(`/settings/users/${userId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message);
}
})
.catch(error => {
alert('Error deleting user');
console.error(error);
});
}
// Close modal on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeUserModal();
}
});
</script>
{% endblock %}

193
templates/my_counts.html Normal file
View File

@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% block title %}My Counts - {{ count_session.session_name }} - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="page-header">
<div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">My Active Counts</h1>
<p class="page-subtitle">{{ count_session.session_name }}</p>
</div>
<button class="btn btn-primary" onclick="showStartBinModal()">
<span class="btn-icon">+</span> Start New Bin
</button>
</div>
<!-- Active Bins -->
{% if active_bins %}
<div class="section-card">
<h2 class="section-title">🔄 Active Bins (In Progress)</h2>
<div class="bins-grid">
{% for bin in active_bins %}
<div class="bin-card bin-active">
<div class="bin-header">
<h3 class="bin-name">{{ bin.location_name }}</h3>
<span class="bin-status status-progress">In Progress</span>
</div>
<div class="bin-stats">
<div class="bin-stat">
<span class="stat-label">Scanned:</span>
<span class="stat-value">{{ bin.scan_count or 0 }}</span>
</div>
<div class="bin-stat">
<span class="stat-label">Started:</span>
<span class="stat-value">{{ bin.start_timestamp[11:16] if bin.start_timestamp else '-' }}</span>
</div>
</div>
<div class="bin-actions">
<a href="{{ url_for('count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
Resume Counting
</a>
<div class="bin-actions-row">
<button class="btn btn-secondary" onclick="markComplete({{ bin.location_count_id }})">
✓ Mark Complete
</button>
<button class="btn btn-danger" onclick="deleteBinCount({{ bin.location_count_id }}, '{{ bin.location_name }}')">
🗑️ Delete
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Completed Bins -->
{% if completed_bins %}
<div class="section-card">
<h2 class="section-title">✅ Completed Bins</h2>
<div class="bins-grid">
{% for bin in completed_bins %}
<div class="bin-card bin-completed">
<div class="bin-header">
<h3 class="bin-name">{{ bin.location_name }}</h3>
<span class="bin-status status-success">Completed</span>
</div>
<div class="bin-stats">
<div class="bin-stat">
<span class="stat-label">Scanned:</span>
<span class="stat-value">{{ bin.scan_count or 0 }}</span>
</div>
<div class="bin-stat">
<span class="stat-label">Completed:</span>
<span class="stat-value">{{ bin.end_timestamp[11:16] if bin.end_timestamp else '-' }}</span>
</div>
</div>
<div class="bin-actions">
<button class="btn btn-secondary btn-block" disabled title="This bin has been finalized">
🔒 Finalized (Read-Only)
</button>
<p class="bin-note">Contact an admin to view details</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not active_bins and not completed_bins %}
<div class="empty-state">
<div class="empty-icon">📦</div>
<h2 class="empty-title">No Bins Started Yet</h2>
<p class="empty-text">Click "Start New Bin" to begin counting</p>
</div>
{% endif %}
</div>
<!-- Start New Bin Modal -->
<div id="startBinModal" class="modal">
<div class="modal-content">
<div class="modal-header-bar">
<h3 class="modal-title">Start New Bin</h3>
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button>
</div>
<form id="startBinForm" action="{{ url_for('start_bin_count', session_id=count_session.session_id) }}" method="POST">
<div class="form-group">
<label class="form-label">Bin Number *</label>
<input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeStartBinModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Start Counting</button>
</div>
</form>
</div>
</div>
<script>
function showStartBinModal() {
document.getElementById('startBinModal').style.display = 'flex';
document.querySelector('#startBinForm input[name="location_name"]').focus();
}
function closeStartBinModal() {
document.getElementById('startBinModal').style.display = 'none';
document.getElementById('startBinForm').reset();
}
function markComplete(locationCountId) {
if (!confirm('Mark this bin as complete? You can still view it later.')) {
return;
}
fetch(`/location/${locationCountId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error marking bin as complete');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function deleteBinCount(locationCountId, binName) {
if (!confirm(`Delete ALL counts for bin "${binName}"?\n\nThis will remove all scanned entries for this bin. This action cannot be undone.`)) {
return;
}
// Double confirmation for safety
if (!confirm('Are you absolutely sure? All data for this bin will be lost.')) {
return;
}
fetch(`/location/${locationCountId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || 'Error deleting bin count');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
// Close modal on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeStartBinModal();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,660 @@
{% extends "base.html" %}
{% block title %}{{ count_session.session_name }} - ScanLook{% endblock %}
{% block content %}
<div class="session-detail-container">
<div class="session-detail-header">
<div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">{{ count_session.session_name }}</h1>
<span class="session-type-badge session-type-{{ count_session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
</span>
</div>
</div>
<!-- Baseline Upload Section -->
<div class="section-card">
<h2 class="section-title">Baseline Data</h2>
<div class="baseline-grid">
<div class="baseline-item">
<div class="baseline-label">MASTER Baseline</div>
<div class="baseline-status">
{% if count_session.master_baseline_timestamp %}
<span class="status-badge status-success">✓ Uploaded</span>
<small class="baseline-time">{{ count_session.master_baseline_timestamp[:16] }}</small>
{% else %}
<span class="status-badge status-warning">⚠ Not Uploaded</span>
{% endif %}
</div>
{% if not count_session.master_baseline_timestamp %}
<form method="POST" action="{{ url_for('upload_baseline', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="baseline_type" value="master">
<input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button>
</form>
{% endif %}
</div>
<div class="baseline-item">
<div class="baseline-label">CURRENT Baseline (Optional)</div>
<div class="baseline-status">
{% if count_session.current_baseline_timestamp %}
<span class="status-badge status-success">Last Updated:
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div>
{% else %}
<span class="status-badge status-neutral">Not Uploaded</span>
{% endif %}
</div>
{% if count_session.master_baseline_timestamp %}
<form method="POST" action="{{ url_for('upload_baseline', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<input type="hidden" name="baseline_type" value="current">
<input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-secondary btn-sm">
{{ 'Refresh CURRENT' if count_session.current_baseline_timestamp else 'Upload CURRENT' }}
</button>
</form>
{% endif %}
</div>
</div>
</div>
<!-- Statistics Section -->
<div class="section-card">
<h2 class="section-title">Real-Time Statistics</h2>
<div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div>
</div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div>
</div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div>
</div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div>
</div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div>
</div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing', {{ count_session.session_id }})">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div>
</div>
</div>
</div>
<!-- Active Counters Section -->
{% if active_counters %}
<div class="section-card">
<h2 class="section-title">Active Counters</h2>
<div class="counter-list">
{% for counter in active_counters %}
<div class="counter-item">
<div class="counter-avatar">{{ counter.full_name[0] }}</div>
<div class="counter-info">
<div class="counter-name">{{ counter.full_name }}</div>
<div class="counter-location">📍 {{ counter.location_name }}</div>
</div>
<div class="counter-time">{{ counter.start_timestamp[11:16] }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Location Progress Section -->
{% if locations %}
<div class="section-card">
<h2 class="section-title">Location Progress</h2>
<div class="location-table">
<table>
<thead>
<tr>
<th>Location</th>
<th>Status</th>
<th>Counter</th>
<th>Expected</th>
<th>Found</th>
<th>Missing</th>
</tr>
</thead>
<tbody>
{% for loc in locations %}
<tr class="location-row-clickable" onclick="showLocationDetails({{ loc.location_count_id }}, '{{ loc.location_name }}', '{{ loc.status }}')">
<td><strong>{{ loc.location_name }}</strong></td>
<td>
<span class="status-badge status-{{ loc.status }}">
{% if loc.status == 'completed' %}✓ Done
{% elif loc.status == 'in_progress' %}⏳ Counting
{% else %}○ Pending{% endif %}
</span>
</td>
<td>{{ loc.counter_name or '-' }}</td>
<td>{{ loc.expected_lots_master }}</td>
<td>{{ loc.lots_found }}</td>
<td>{{ loc.lots_missing }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
<!-- Status Details Modal -->
<div id="statusModal" class="modal">
<div class="modal-content modal-xl">
<div class="modal-header-bar">
<h3 class="modal-title" id="statusModalTitle">Details</h3>
<div class="modal-header-actions">
<button class="btn btn-secondary btn-sm" onclick="exportStatusToCSV()">
📥 Export CSV
</button>
<button type="button" class="btn-close-modal" onclick="closeStatusModal()"></button>
</div>
</div>
<div id="statusDetailContent" class="status-detail-content">
<div class="loading-spinner">Loading...</div>
</div>
</div>
</div>
<!-- Location Detail Modal -->
<div id="locationModal" class="modal">
<div class="modal-content modal-xl">
<div class="modal-header-bar">
<h3 class="modal-title" id="locationModalTitle">Location Details</h3>
<div class="modal-header-actions">
<button id="finalizeLocationBtn" class="btn btn-success btn-sm" style="display: none;" onclick="showFinalizeConfirm()">
✓ Finalize Location
</button>
<button id="reopenLocationBtn" class="btn btn-warning btn-sm" style="display: none;" onclick="showReopenConfirm()">
🔓 Reopen Location
</button>
<button class="btn btn-secondary btn-sm" onclick="exportLocationToCSV()">
📥 Export CSV
</button>
<button type="button" class="btn-close-modal" onclick="closeLocationModal()"></button>
</div>
</div>
<div id="locationDetailContent" class="status-detail-content">
<div class="loading-spinner">Loading...</div>
</div>
</div>
</div>
<!-- Finalize Confirmation Modal -->
<div id="finalizeConfirmModal" class="modal">
<div class="modal-content modal-small">
<div class="modal-header-bar">
<h3 class="modal-title">Finalize Location?</h3>
<button type="button" class="btn-close-modal" onclick="closeFinalizeConfirm()"></button>
</div>
<div class="confirm-modal-content">
<div class="confirm-icon">⚠️</div>
<p class="confirm-text">
Are you sure you want to finalize <strong id="finalizeBinName"></strong>?
</p>
<p class="confirm-subtext">
This will mark the location as completed. The counter can no longer add scans to this location.
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeFinalizeConfirm()">Cancel</button>
<button class="btn btn-success" onclick="confirmFinalize()">✓ Yes, Finalize</button>
</div>
</div>
</div>
<!-- Reopen Confirmation Modal -->
<div id="reopenConfirmModal" class="modal">
<div class="modal-content modal-small">
<div class="modal-header-bar">
<h3 class="modal-title">Reopen Location?</h3>
<button type="button" class="btn-close-modal" onclick="closeReopenConfirm()"></button>
</div>
<div class="confirm-modal-content">
<div class="confirm-icon">🔓</div>
<p class="confirm-text">
Are you sure you want to reopen <strong id="reopenBinName"></strong>?
</p>
<p class="confirm-subtext">
This will mark the location as in progress. The counter will be able to add more scans to this location.
</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeReopenConfirm()">Cancel</button>
<button class="btn btn-warning" onclick="confirmReopen()">🔓 Yes, Reopen</button>
</div>
</div>
</div>
<script>
function showStatusDetails(status, sessionId) {
document.getElementById('statusModal').style.display = 'flex';
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
// Set modal title
const titles = {
'match': '✓ Matched Lots',
'duplicates': '🔵 Duplicate Lots',
'weight_discrepancy': '⚖️ Weight Discrepancy',
'wrong_location': '⚠ Wrong Location',
'ghost_lot': '🟣 Ghost Lots',
'missing': '🔴 Missing Lots'
};
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details
fetch(`/session/${sessionId}/status-details/${status}`)
.then(response => response.json())
.then(data => {
if (data.success) {
currentStatusData = data.items;
currentStatusType = status;
displayStatusDetails(data.items, status);
} else {
document.getElementById('statusDetailContent').innerHTML =
`<div class="empty-state"><p>Error: ${data.message || 'Unknown error'}</p></div>`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('statusDetailContent').innerHTML =
`<div class="empty-state"><p>Network error: ${error.message}</p></div>`;
});
}
function displayStatusDetails(items, status) {
const content = document.getElementById('statusDetailContent');
if (!items || items.length === 0) {
content.innerHTML = '<div class="empty-state"><p>No items found</p></div>';
return;
}
let html = '<div class="detail-table-container"><table class="detail-table"><thead><tr>';
// Table headers based on status type
if (status === 'missing') {
html += `
<th colspan="4" class="section-header section-counted">EXPECTED (MASTER)</th>
</tr><tr>
<th>LOT #</th>
<th>SKU #</th>
<th>E Bin</th>
<th>E Weight</th>
`;
} else {
html += `
<th colspan="4" class="section-header section-counted">COUNTED</th>
<th colspan="2" class="section-header section-expected">EXPECTED (MASTER)</th>
<th colspan="2" class="section-header section-current">CURRENT</th>
<th colspan="2" class="section-header section-counted">INFO</th>
</tr><tr>
<th class="col-counted">LOT #</th>
<th class="col-counted">SKU #</th>
<th class="col-counted">Counted Bin</th>
<th class="col-counted">Weight</th>
<th class="col-expected">E Bin</th>
<th class="col-expected">E Weight</th>
<th class="col-current">C Bin</th>
<th class="col-current">C Weight</th>
<th class="col-counted">Scanned By</th>
<th class="col-counted">Scanned At</th>
`;
}
html += '</tr></thead><tbody>';
// Table rows
items.forEach(item => {
html += '<tr>';
if (status === 'missing') {
html += `
<td><strong>${item.lot_number}</strong></td>
<td>${item.item || '-'}</td>
<td>${item.system_bin || '-'}</td>
<td>${item.system_quantity || '-'} lbs</td>
`;
} else {
html += `
<td class="col-counted"><strong>${item.lot_number}</strong></td>
<td class="col-counted">${item.item || '-'}</td>
<td class="col-counted">${item.scanned_location || '-'}</td>
<td class="col-counted">${item.actual_weight || '-'} lbs</td>
<td class="col-expected">${item.master_expected_location || '-'}</td>
<td class="col-expected">${item.master_expected_weight ? item.master_expected_weight + ' lbs' : '-'}</td>
<td class="col-current">${item.current_system_location || '-'}</td>
<td class="col-current">${item.current_system_weight ? item.current_system_weight + ' lbs' : '-'}</td>
<td class="col-counted">${item.scanned_by_name || '-'}</td>
<td class="col-counted">${item.scan_timestamp ? item.scan_timestamp.substring(0, 19) : '-'}</td>
`;
}
html += '</tr>';
});
html += '</tbody></table></div>';
content.innerHTML = html;
}
function closeStatusModal() {
document.getElementById('statusModal').style.display = 'none';
}
let currentStatusData = null;
let currentStatusType = null;
function exportStatusToCSV() {
if (!currentStatusData || currentStatusData.length === 0) {
alert('No data to export');
return;
}
// Build CSV content
let csv = '';
// Headers based on status type
if (currentStatusType === 'missing') {
csv = 'LOT #,SKU #,E Bin,E Weight\n';
currentStatusData.forEach(item => {
csv += `"${item.lot_number}",`;
csv += `"${item.item || ''}",`;
csv += `"${item.system_bin || ''}",`;
csv += `"${item.system_quantity || ''} lbs"\n`;
});
} else {
csv = 'LOT #,SKU #,Counted Bin,Weight,E Bin,E Weight,C Bin,C Weight,Scanned By,Scanned At\n';
currentStatusData.forEach(item => {
csv += `"${item.lot_number}",`;
csv += `"${item.item || ''}",`;
csv += `"${item.scanned_location || ''}",`;
csv += `"${item.actual_weight || ''} lbs",`;
csv += `"${item.master_expected_location || ''}",`;
csv += `"${item.master_expected_weight ? item.master_expected_weight + ' lbs' : ''}",`;
csv += `"${item.current_system_location || ''}",`;
csv += `"${item.current_system_weight ? item.current_system_weight + ' lbs' : ''}",`;
csv += `"${item.scanned_by_name || ''}",`;
csv += `"${item.scan_timestamp ? item.scan_timestamp.substring(0, 19) : ''}"\n`;
});
}
// Create download link
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// Generate filename with status type and timestamp
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
const statusName = document.getElementById('statusModalTitle').textContent.replace(/[^a-z0-9]/gi, '_');
link.setAttribute('href', url);
link.setAttribute('download', `${statusName}_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
let currentLocationId = null;
let currentLocationName = '';
let currentLocationStatus = '';
let currentLocationData = null;
function showLocationDetails(locationCountId, locationName, status) {
currentLocationId = locationCountId;
currentLocationName = locationName;
currentLocationStatus = status;
document.getElementById('locationModal').style.display = 'flex';
document.getElementById('locationModalTitle').textContent = `${locationName} - All Scans`;
document.getElementById('locationDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
// Show finalize or reopen button based on status
const finalizeBtn = document.getElementById('finalizeLocationBtn');
const reopenBtn = document.getElementById('reopenLocationBtn');
if (status === 'in_progress') {
finalizeBtn.style.display = 'block';
reopenBtn.style.display = 'none';
} else if (status === 'completed') {
finalizeBtn.style.display = 'none';
reopenBtn.style.display = 'block';
} else {
finalizeBtn.style.display = 'none';
reopenBtn.style.display = 'none';
}
// Fetch all scans for this location
fetch(`/location/${locationCountId}/scans`)
.then(response => response.json())
.then(data => {
if (data.success) {
currentLocationData = data.scans;
displayLocationScans(data.scans);
} else {
document.getElementById('locationDetailContent').innerHTML =
`<div class="empty-state"><p>Error: ${data.message || 'Unknown error'}</p></div>`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('locationDetailContent').innerHTML =
`<div class="empty-state"><p>Network error: ${error.message}</p></div>`;
});
}
function displayLocationScans(scans) {
const content = document.getElementById('locationDetailContent');
if (!scans || scans.length === 0) {
content.innerHTML = '<div class="empty-state"><p>No scans found for this location</p></div>';
return;
}
let html = '<div class="detail-table-container"><table class="detail-table"><thead><tr>';
html += `
<th colspan="4" class="section-header section-counted">COUNTED</th>
<th colspan="2" class="section-header section-expected">EXPECTED (MASTER)</th>
<th colspan="2" class="section-header section-current">CURRENT</th>
<th colspan="2" class="section-header section-counted">INFO</th>
</tr><tr>
<th class="col-counted">LOT #</th>
<th class="col-counted">SKU #</th>
<th class="col-counted">Counted Bin</th>
<th class="col-counted">Weight</th>
<th class="col-expected">E Bin</th>
<th class="col-expected">E Weight</th>
<th class="col-current">C Bin</th>
<th class="col-current">C Weight</th>
<th class="col-counted">Status</th>
<th class="col-counted">Scanned At</th>
`;
html += '</tr></thead><tbody>';
scans.forEach(scan => {
// Determine status badge
let statusBadge = '';
if (scan.duplicate_status === '01' || scan.duplicate_status === '04') {
statusBadge = '🔵 Duplicate';
} else if (scan.duplicate_status === '03') {
statusBadge = '🟠 Dup (Other)';
} else if (scan.master_status === 'match' && Math.abs(scan.actual_weight - (scan.master_expected_weight || 0)) >= 0.01) {
statusBadge = '⚖️ Weight Off';
} else if (scan.master_status === 'match') {
statusBadge = '✓ Match';
} else if (scan.master_status === 'wrong_location') {
statusBadge = '⚠ Wrong Loc';
} else if (scan.master_status === 'ghost_lot') {
statusBadge = '🟣 Ghost';
}
html += '<tr>';
html += `
<td class="col-counted"><strong>${scan.lot_number}</strong></td>
<td class="col-counted">${scan.item || '-'}</td>
<td class="col-counted">${scan.scanned_location || '-'}</td>
<td class="col-counted">${scan.actual_weight || '-'} lbs</td>
<td class="col-expected">${scan.master_expected_location || '-'}</td>
<td class="col-expected">${scan.master_expected_weight ? scan.master_expected_weight + ' lbs' : '-'}</td>
<td class="col-current">${scan.current_system_location || '-'}</td>
<td class="col-current">${scan.current_system_weight ? scan.current_system_weight + ' lbs' : '-'}</td>
<td class="col-counted">${statusBadge}</td>
<td class="col-counted">${scan.scan_timestamp ? scan.scan_timestamp.substring(0, 19) : '-'}</td>
`;
html += '</tr>';
});
html += '</tbody></table></div>';
content.innerHTML = html;
}
function closeLocationModal() {
document.getElementById('locationModal').style.display = 'none';
currentLocationId = null;
currentLocationData = null;
}
function exportLocationToCSV() {
if (!currentLocationData || currentLocationData.length === 0) {
alert('No data to export');
return;
}
let csv = 'LOT #,SKU #,Counted Bin,Weight,E Bin,E Weight,C Bin,C Weight,Status,Scanned At\n';
currentLocationData.forEach(scan => {
let status = '';
if (scan.duplicate_status === '01' || scan.duplicate_status === '04') {
status = 'Duplicate';
} else if (scan.duplicate_status === '03') {
status = 'Dup (Other)';
} else if (scan.master_status === 'match' && Math.abs(scan.actual_weight - (scan.master_expected_weight || 0)) >= 0.01) {
status = 'Weight Off';
} else if (scan.master_status === 'match') {
status = 'Match';
} else if (scan.master_status === 'wrong_location') {
status = 'Wrong Loc';
} else if (scan.master_status === 'ghost_lot') {
status = 'Ghost';
}
csv += `"${scan.lot_number}",`;
csv += `"${scan.item || ''}",`;
csv += `"${scan.scanned_location || ''}",`;
csv += `"${scan.actual_weight || ''} lbs",`;
csv += `"${scan.master_expected_location || ''}",`;
csv += `"${scan.master_expected_weight ? scan.master_expected_weight + ' lbs' : ''}",`;
csv += `"${scan.current_system_location || ''}",`;
csv += `"${scan.current_system_weight ? scan.current_system_weight + ' lbs' : ''}",`;
csv += `"${status}",`;
csv += `"${scan.scan_timestamp ? scan.scan_timestamp.substring(0, 19) : ''}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
link.setAttribute('href', url);
link.setAttribute('download', `${currentLocationName}_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function showFinalizeConfirm() {
document.getElementById('finalizeBinName').textContent = currentLocationName;
document.getElementById('finalizeConfirmModal').style.display = 'flex';
}
function closeFinalizeConfirm() {
document.getElementById('finalizeConfirmModal').style.display = 'none';
}
function confirmFinalize() {
fetch(`/location/${currentLocationId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeFinalizeConfirm();
closeLocationModal();
location.reload(); // Reload to show updated status
} else {
alert(data.message || 'Error finalizing location');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function showReopenConfirm() {
document.getElementById('reopenBinName').textContent = currentLocationName;
document.getElementById('reopenConfirmModal').style.display = 'flex';
}
function closeReopenConfirm() {
document.getElementById('reopenConfirmModal').style.display = 'none';
}
function confirmReopen() {
fetch(`/location/${currentLocationId}/reopen`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
closeReopenConfirm();
closeLocationModal();
location.reload(); // Reload to show updated status
} else {
alert(data.message || 'Error reopening location');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
// Close modal on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeStatusModal();
closeLocationModal();
closeFinalizeConfirm();
closeReopenConfirm();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Staff Dashboard - ScanLook{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Mode Selector (only for admins) -->
{% if session.role in ['owner', 'admin'] %}
<div class="mode-selector">
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'">
👔 Admin Console
</button>
<button class="mode-btn mode-btn-active">
📦 Scanning Mode
</button>
</div>
{% endif %}
<div class="dashboard-header">
<h1 class="page-title">Select Count Session</h1>
</div>
{% if sessions %}
<div class="sessions-list">
{% for session in sessions %}
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item">
<div class="session-list-info">
<h3 class="session-list-name">{{ session.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span>
</div>
<div class="session-list-action">
<span class="arrow-icon"></span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📋</div>
<h2 class="empty-title">No Active Sessions</h2>
<p class="empty-text">No count sessions are currently active. Please contact your administrator.</p>
</div>
{% endif %}
</div>
{% endblock %}