From b11421a8f59da971d5dad64d92514525ffb3bcc9 Mon Sep 17 00:00:00 2001 From: Javier Date: Wed, 28 Jan 2026 12:53:59 -0600 Subject: [PATCH] Completed: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Admin: Process creation, field configuration, template upload ✅ Staff: Session list, new session (header form), scanning interface ✅ Duplicate detection (same session = blue, other session = orange) ✅ Weight entry popup, edit/delete scans --- AI Prompt.txt | 13 +- app.py | 2 + blueprints/cons_sheets.py | 700 ++++++++++++++++++++ database/init_db.py | 125 +++- static/css/style.css | 57 ++ templates/admin_dashboard.html | 19 +- templates/cons_sheets/add_field.html | 71 ++ templates/cons_sheets/admin_processes.html | 73 ++ templates/cons_sheets/create_process.html | 36 + templates/cons_sheets/edit_field.html | 70 ++ templates/cons_sheets/new_session.html | 99 +++ templates/cons_sheets/process_detail.html | 245 +++++++ templates/cons_sheets/process_fields.html | 157 +++++ templates/cons_sheets/process_template.html | 269 ++++++++ templates/cons_sheets/scan_session.html | 535 +++++++++++++++ templates/cons_sheets/staff_index.html | 166 +++++ 16 files changed, 2603 insertions(+), 34 deletions(-) create mode 100644 blueprints/cons_sheets.py create mode 100644 templates/cons_sheets/add_field.html create mode 100644 templates/cons_sheets/admin_processes.html create mode 100644 templates/cons_sheets/create_process.html create mode 100644 templates/cons_sheets/edit_field.html create mode 100644 templates/cons_sheets/new_session.html create mode 100644 templates/cons_sheets/process_detail.html create mode 100644 templates/cons_sheets/process_fields.html create mode 100644 templates/cons_sheets/process_template.html create mode 100644 templates/cons_sheets/scan_session.html create mode 100644 templates/cons_sheets/staff_index.html diff --git a/AI Prompt.txt b/AI Prompt.txt index 96de210..bfc4bc6 100644 --- a/AI Prompt.txt +++ b/AI Prompt.txt @@ -43,7 +43,7 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r ## Scanlook (current product summary) Scanlook is a web app for warehouse counting workflows built with Flask + SQLite. -**Current Version:** 0.11.3 +**Current Version:** 0.12.0 **Tech Stack:** - Backend: Python/Flask, raw SQL (no ORM) @@ -81,7 +81,16 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite **Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production. - +**Module System (v0.12.0):** +- Modules table defines available modules (module_key used for routing) +- UserModules table tracks per-user access +- Home page (/home) shows module cards based on user's access +- Each module needs: database entry, route with access check, home page card +- New modules should go in /modules/{module_name}/ with: + - __init__.py (blueprint registration) + - routes.py (all routes) + - templates/ (module-specific templates) + ## Quick Reference - Database: SQLite at /database/scanlook.db diff --git a/app.py b/app.py index 77b8c97..e1aabc4 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ from blueprints.users import users_bp from blueprints.sessions import sessions_bp from blueprints.admin_locations import admin_locations_bp from blueprints.counting import counting_bp +from blueprints.cons_sheets import cons_sheets_bp from utils import login_required # Register Blueprints @@ -26,6 +27,7 @@ app.register_blueprint(users_bp) app.register_blueprint(sessions_bp) app.register_blueprint(admin_locations_bp) app.register_blueprint(counting_bp) +app.register_blueprint(cons_sheets_bp) # V1.0: Use environment variable for production, fallback to demo key for development app.secret_key = os.environ.get('SCANLOOK_SECRET_KEY', 'scanlook-demo-key-replace-for-production') diff --git a/blueprints/cons_sheets.py b/blueprints/cons_sheets.py new file mode 100644 index 0000000..9142b5d --- /dev/null +++ b/blueprints/cons_sheets.py @@ -0,0 +1,700 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session +from db import query_db, execute_db +from utils import role_required + +cons_sheets_bp = Blueprint('cons_sheets', __name__) + + +@cons_sheets_bp.route('/admin/consumption-sheets') +@role_required('owner', 'admin') +def admin_processes(): + """List all consumption sheet process types""" + processes = query_db(''' + SELECT cp.*, u.full_name as created_by_name, + (SELECT COUNT(*) FROM cons_process_fields + WHERE process_id = cp.id AND is_active = 1) as field_count + FROM cons_processes cp + LEFT JOIN Users u ON cp.created_by = u.user_id + WHERE cp.is_active = 1 + ORDER BY cp.process_name + ''') + + return render_template('cons_sheets/admin_processes.html', processes=processes) + + +@cons_sheets_bp.route('/admin/consumption-sheets/create', methods=['GET', 'POST']) +@role_required('owner', 'admin') +def create_process(): + """Create a new process type""" + if request.method == 'POST': + process_name = request.form.get('process_name', '').strip() + + if not process_name: + flash('Process name is required', 'danger') + return redirect(url_for('cons_sheets.create_process')) + + # Generate process_key from name (lowercase, underscores) + process_key = process_name.lower().replace(' ', '_').replace('-', '_') + # Remove any non-alphanumeric characters except underscore + process_key = ''.join(c for c in process_key if c.isalnum() or c == '_') + + # Check for duplicate key + existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True) + if existing: + flash(f'A process with key "{process_key}" already exists', 'danger') + return redirect(url_for('cons_sheets.create_process')) + + process_id = execute_db(''' + INSERT INTO cons_processes (process_key, process_name, created_by) + VALUES (?, ?, ?) + ''', [process_key, process_name, session['user_id']]) + + flash(f'Process "{process_name}" created successfully!', 'success') + return redirect(url_for('cons_sheets.process_detail', process_id=process_id)) + + return render_template('cons_sheets/create_process.html') + + +@cons_sheets_bp.route('/admin/consumption-sheets/') +@role_required('owner', 'admin') +def process_detail(process_id): + """Process detail page - Database and Excel configuration""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get header fields + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + # Get detail fields + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_detail.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + +@cons_sheets_bp.route('/admin/consumption-sheets//fields') +@role_required('owner', 'admin') +def process_fields(process_id): + """Configure database fields for a process""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get header fields + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + # Get detail fields + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_fields.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + +@cons_sheets_bp.route('/admin/consumption-sheets//template') +@role_required('owner', 'admin') +def process_template(process_id): + """Configure Excel template for a process""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get all active fields for mapping display + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_template.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + +@cons_sheets_bp.route('/admin/consumption-sheets//template/upload', methods=['POST']) +@role_required('owner', 'admin') +def upload_template(process_id): + """Upload Excel template file""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if 'template_file' not in request.files: + flash('No file selected', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + file = request.files['template_file'] + + if file.filename == '': + flash('No file selected', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + if not file.filename.endswith('.xlsx'): + flash('Only .xlsx files are allowed', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + # Read file as binary + template_data = file.read() + filename = file.filename + + # Store in database + execute_db(''' + UPDATE cons_processes + SET template_file = ?, template_filename = ? + WHERE id = ? + ''', [template_data, filename, process_id]) + + flash(f'Template "{filename}" uploaded successfully!', 'success') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + +@cons_sheets_bp.route('/admin/consumption-sheets//template/settings', methods=['POST']) +@role_required('owner', 'admin') +def update_template_settings(process_id): + """Update template page settings""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + rows_per_page = request.form.get('rows_per_page', 30) + detail_start_row = request.form.get('detail_start_row', 10) + + try: + rows_per_page = int(rows_per_page) + detail_start_row = int(detail_start_row) + except ValueError: + flash('Invalid number values', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + execute_db(''' + UPDATE cons_processes + SET rows_per_page = ?, detail_start_row = ? + WHERE id = ? + ''', [rows_per_page, detail_start_row, process_id]) + + flash('Settings updated successfully!', 'success') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + +@cons_sheets_bp.route('/admin/consumption-sheets//template/download') +@role_required('owner', 'admin') +def download_template(process_id): + """Download the stored Excel template""" + from flask import Response + + process = query_db('SELECT template_file, template_filename FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process or not process['template_file']: + flash('No template found', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + return Response( + process['template_file'], + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename={process["template_filename"]}'} + ) + + +@cons_sheets_bp.route('/admin/consumption-sheets//fields/add/', methods=['GET', 'POST']) +@role_required('owner', 'admin') +def add_field(process_id, table_type): + """Add a new field to a process""" + if table_type not in ['header', 'detail']: + flash('Invalid table type', 'danger') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if request.method == 'POST': + field_label = request.form.get('field_label', '').strip() + field_type = request.form.get('field_type', 'TEXT') + max_length = request.form.get('max_length', '') + is_required = 1 if request.form.get('is_required') else 0 + excel_cell = request.form.get('excel_cell', '').strip().upper() + + if not field_label: + flash('Field label is required', 'danger') + return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) + + # Generate field_name from label (lowercase, underscores) + field_name = field_label.lower().replace(' ', '_').replace('-', '_') + field_name = ''.join(c for c in field_name if c.isalnum() or c == '_') + + # Check for duplicate field name in this process/table_type + existing = query_db(''' + SELECT id FROM cons_process_fields + WHERE process_id = ? AND table_type = ? AND field_name = ? AND is_active = 1 + ''', [process_id, table_type, field_name], one=True) + + if existing: + flash(f'A field with name "{field_name}" already exists', 'danger') + return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) + + # Get next sort_order + max_sort = query_db(''' + SELECT MAX(sort_order) as max_sort FROM cons_process_fields + WHERE process_id = ? AND table_type = ? + ''', [process_id, table_type], one=True) + sort_order = (max_sort['max_sort'] or 0) + 1 + + # Insert the field + execute_db(''' + INSERT INTO cons_process_fields + (process_id, table_type, field_name, field_label, field_type, max_length, is_required, sort_order, excel_cell) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [process_id, table_type, field_name, field_label, field_type, + int(max_length) if max_length else None, is_required, sort_order, excel_cell or None]) + + flash(f'Field "{field_label}" added successfully!', 'success') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + return render_template('cons_sheets/add_field.html', + process=process, + table_type=table_type) + + +@cons_sheets_bp.route('/admin/consumption-sheets//fields//edit', methods=['GET', 'POST']) +@role_required('owner', 'admin') +def edit_field(process_id, field_id): + """Edit an existing field""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + field = query_db('SELECT * FROM cons_process_fields WHERE id = ? AND process_id = ?', [field_id, process_id], one=True) + + if not process or not field: + flash('Process or field not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if request.method == 'POST': + field_label = request.form.get('field_label', '').strip() + field_type = request.form.get('field_type', 'TEXT') + max_length = request.form.get('max_length', '') + is_required = 1 if request.form.get('is_required') else 0 + excel_cell = request.form.get('excel_cell', '').strip().upper() + + if not field_label: + flash('Field label is required', 'danger') + return redirect(url_for('cons_sheets.edit_field', process_id=process_id, field_id=field_id)) + + execute_db(''' + UPDATE cons_process_fields + SET field_label = ?, field_type = ?, max_length = ?, is_required = ?, excel_cell = ? + WHERE id = ? + ''', [field_label, field_type, int(max_length) if max_length else None, is_required, excel_cell or None, field_id]) + + flash(f'Field "{field_label}" updated successfully!', 'success') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + return render_template('cons_sheets/edit_field.html', + process=process, + field=field) + + +@cons_sheets_bp.route('/admin/consumption-sheets//fields//delete', methods=['POST']) +@role_required('owner', 'admin') +def delete_field(process_id, field_id): + """Soft-delete a field (rename column, set is_active = 0)""" + field = query_db('SELECT * FROM cons_process_fields WHERE id = ? AND process_id = ?', [field_id, process_id], one=True) + + if not field: + return jsonify({'success': False, 'message': 'Field not found'}) + + # Soft delete: set is_active = 0 + execute_db('UPDATE cons_process_fields SET is_active = 0 WHERE id = ?', [field_id]) + + return jsonify({'success': True, 'message': f'Field "{field["field_label"]}" deleted'}) + + +# ============================================ +# STAFF-FACING ROUTES (Scanning Interface) +# ============================================ + +from utils import login_required + +@cons_sheets_bp.route('/cons-sheets') +@login_required +def index(): + """Consumption Sheets module landing - show user's sessions""" + user_id = session.get('user_id') + + # Check if user has access to this module + has_access = query_db(''' + SELECT 1 FROM UserModules um + JOIN Modules m ON um.module_id = m.module_id + WHERE um.user_id = ? AND m.module_key = 'cons_sheets' AND m.is_active = 1 + ''', [user_id], one=True) + + if not has_access: + flash('You do not have access to this module', 'danger') + return redirect(url_for('home')) + + # Get user's active sessions with process info + active_sessions = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key, + (SELECT COUNT(*) FROM cons_session_details WHERE session_id = cs.id AND is_deleted = 0) as scan_count + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.created_by = ? AND cs.status = 'active' + ORDER BY cs.created_at DESC + ''', [user_id]) + + # Get available process types for creating new sessions + processes = query_db(''' + SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name + ''') + + return render_template('cons_sheets/staff_index.html', + sessions=active_sessions, + processes=processes) + + +@cons_sheets_bp.route('/cons-sheets/new/', methods=['GET', 'POST']) +@login_required +def new_session(process_id): + """Create a new scanning session - enter header info""" + process = query_db('SELECT * FROM cons_processes WHERE id = ? AND is_active = 1', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + # Get header fields for this process + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + if request.method == 'POST': + # Validate required fields + missing_required = [] + for field in header_fields: + if field['is_required']: + value = request.form.get(field['field_name'], '').strip() + if not value: + missing_required.append(field['field_label']) + + if missing_required: + flash(f'Required fields missing: {", ".join(missing_required)}', 'danger') + return render_template('cons_sheets/new_session.html', + process=process, + header_fields=header_fields, + form_data=request.form) + + # Create the session + session_id = execute_db(''' + INSERT INTO cons_sessions (process_id, created_by) + VALUES (?, ?) + ''', [process_id, session['user_id']]) + + # Save header field values + for field in header_fields: + value = request.form.get(field['field_name'], '').strip() + if value: + execute_db(''' + INSERT INTO cons_session_header_values (session_id, field_id, field_value) + VALUES (?, ?, ?) + ''', [session_id, field['id'], value]) + + flash('Session created! Start scanning lots.', 'success') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + return render_template('cons_sheets/new_session.html', + process=process, + header_fields=header_fields, + form_data={}) + + +@cons_sheets_bp.route('/cons-sheets/session/') +@login_required +def scan_session(session_id): + """Main scanning interface for a session""" + # Get session with process info + sess = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + flash('Session not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + if sess['status'] == 'archived': + flash('This session has been archived', 'warning') + return redirect(url_for('cons_sheets.index')) + + # Get header values for display + header_values = query_db(''' + SELECT cpf.field_label, cpf.field_name, cshv.field_value + FROM cons_session_header_values cshv + JOIN cons_process_fields cpf ON cshv.field_id = cpf.id + WHERE cshv.session_id = ? + ORDER BY cpf.sort_order, cpf.id + ''', [session_id]) + + # Get scanned details + scans = query_db(''' + SELECT csd.*, u.full_name as scanned_by_name + FROM cons_session_details csd + JOIN Users u ON csd.scanned_by = u.user_id + WHERE csd.session_id = ? AND csd.is_deleted = 0 + ORDER BY csd.scanned_at DESC + ''', [session_id]) + + # Get detail fields for reference + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [sess['process_id']]) + + return render_template('cons_sheets/scan_session.html', + session=sess, + header_values=header_values, + scans=scans, + detail_fields=detail_fields) + + +@cons_sheets_bp.route('/cons-sheets/session//scan', methods=['POST']) +@login_required +def scan_lot(session_id): + """Process a lot scan with duplicate detection""" + sess = query_db('SELECT * FROM cons_sessions WHERE id = ? AND status = "active"', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found or archived'}) + + data = request.get_json() + lot_number = data.get('lot_number', '').strip() + item_number = data.get('item_number', '').strip() + weight = data.get('weight') + confirm_duplicate = data.get('confirm_duplicate', False) + check_only = data.get('check_only', False) + + if not lot_number: + return jsonify({'success': False, 'message': 'Lot number required'}) + + if not check_only and weight is None: + return jsonify({'success': False, 'message': 'Weight required'}) + + if not check_only: + try: + weight = float(weight) + except (ValueError, TypeError): + return jsonify({'success': False, 'message': 'Invalid weight value'}) + + # Check for duplicates in SAME session + same_session_dup = query_db(''' + SELECT * FROM cons_session_details + WHERE session_id = ? AND lot_number = ? AND is_deleted = 0 + ''', [session_id, lot_number], one=True) + + # Check for duplicates in OTHER sessions (with header info for context) + other_session_dup = query_db(''' + SELECT csd.*, cs.id as other_session_id, cs.created_at as other_session_date, + u.full_name as other_user, + (SELECT field_value FROM cons_session_header_values + WHERE session_id = cs.id AND field_id = ( + SELECT id FROM cons_process_fields + WHERE process_id = cs.process_id AND field_name LIKE '%wo%' AND is_active = 1 LIMIT 1 + )) as other_wo + FROM cons_session_details csd + JOIN cons_sessions cs ON csd.session_id = cs.id + JOIN Users u ON csd.scanned_by = u.user_id + WHERE csd.lot_number = ? AND csd.session_id != ? AND csd.is_deleted = 0 + ORDER BY csd.scanned_at DESC + LIMIT 1 + ''', [lot_number, session_id], one=True) + + duplicate_status = 'normal' + duplicate_info = None + needs_confirmation = False + + if same_session_dup: + duplicate_status = 'dup_same_session' + duplicate_info = 'Already scanned in this session' + needs_confirmation = True + elif other_session_dup: + duplicate_status = 'dup_other_session' + dup_date = other_session_dup['other_session_date'][:10] if other_session_dup['other_session_date'] else 'Unknown' + dup_user = other_session_dup['other_user'] or 'Unknown' + dup_wo = other_session_dup['other_wo'] or 'N/A' + duplicate_info = f"Previously scanned on {dup_date} by {dup_user} on WO {dup_wo}" + needs_confirmation = True + + # If just checking, return early + if check_only: + if needs_confirmation: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'duplicate_status': duplicate_status, + 'duplicate_info': duplicate_info, + 'message': duplicate_info + }) + return jsonify({'success': True, 'needs_confirmation': False}) + + # If needs confirmation and not confirmed, ask user + if needs_confirmation and not confirm_duplicate: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'duplicate_status': duplicate_status, + 'duplicate_info': duplicate_info, + 'message': duplicate_info + }) + + # Insert the scan + detail_id = execute_db(''' + INSERT INTO cons_session_details + (session_id, item_number, lot_number, weight, scanned_by, duplicate_status, duplicate_info) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', [session_id, item_number, lot_number, weight, session['user_id'], duplicate_status, duplicate_info]) + + # If this is a same-session duplicate, update the original scan too + updated_entry_ids = [] + if duplicate_status == 'dup_same_session' and same_session_dup: + execute_db(''' + UPDATE cons_session_details + SET duplicate_status = 'dup_same_session', duplicate_info = 'Duplicate lot' + WHERE id = ? + ''', [same_session_dup['id']]) + updated_entry_ids.append(same_session_dup['id']) + + return jsonify({ + 'success': True, + 'detail_id': detail_id, + 'duplicate_status': duplicate_status, + 'updated_entry_ids': updated_entry_ids + }) + + +@cons_sheets_bp.route('/cons-sheets/detail/') +@login_required +def get_detail(detail_id): + """Get detail info for editing""" + detail = query_db(''' + SELECT csd.*, u.full_name as scanned_by_name + FROM cons_session_details csd + JOIN Users u ON csd.scanned_by = u.user_id + WHERE csd.id = ? + ''', [detail_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + return jsonify({'success': True, 'detail': dict(detail)}) + + +@cons_sheets_bp.route('/cons-sheets/detail//update', methods=['POST']) +@login_required +def update_detail(detail_id): + """Update a scanned detail""" + detail = query_db('SELECT * FROM cons_session_details WHERE id = ?', [detail_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + # Check permission + if detail['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + data = request.get_json() + item_number = data.get('item_number', '').strip() + lot_number = data.get('lot_number', '').strip() + weight = data.get('weight') + comment = data.get('comment', '') + + if not lot_number: + return jsonify({'success': False, 'message': 'Lot number required'}) + + try: + weight = float(weight) + except (ValueError, TypeError): + return jsonify({'success': False, 'message': 'Invalid weight'}) + + execute_db(''' + UPDATE cons_session_details + SET item_number = ?, lot_number = ?, weight = ?, comment = ? + WHERE id = ? + ''', [item_number, lot_number, weight, comment, detail_id]) + + return jsonify({'success': True}) + + +@cons_sheets_bp.route('/cons-sheets/detail//delete', methods=['POST']) +@login_required +def delete_detail(detail_id): + """Soft-delete a scanned detail""" + detail = query_db('SELECT * FROM cons_session_details WHERE id = ?', [detail_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + # Check permission + if detail['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + execute_db('UPDATE cons_session_details SET is_deleted = 1 WHERE id = ?', [detail_id]) + + return jsonify({'success': True}) + + +@cons_sheets_bp.route('/cons-sheets/session//archive', methods=['POST']) +@login_required +def archive_session(session_id): + """Archive (soft-delete) a session""" + sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + # Check permission + if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + execute_db('UPDATE cons_sessions SET status = "archived" WHERE id = ?', [session_id]) + + return jsonify({'success': True}) \ No newline at end of file diff --git a/database/init_db.py b/database/init_db.py index 8409b28..dfd7bb1 100644 --- a/database/init_db.py +++ b/database/init_db.py @@ -31,36 +31,7 @@ def init_database(): created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ) ''') - - # Modules Table - defines available system modules - cursor.execute(''' - CREATE TABLE IF NOT EXISTS Modules ( - module_id INTEGER PRIMARY KEY AUTOINCREMENT, - module_name TEXT UNIQUE NOT NULL, - module_key TEXT UNIQUE NOT NULL, - description TEXT, - icon TEXT, - is_active INTEGER DEFAULT 1, - display_order INTEGER DEFAULT 0 - ) - ''') - - # UserModules Table - which modules each user can access - cursor.execute(''' - CREATE TABLE IF NOT EXISTS UserModules ( - user_module_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - module_id INTEGER NOT NULL, - granted_by INTEGER, - granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES Users(user_id), - FOREIGN KEY (module_id) REFERENCES Modules(module_id), - FOREIGN KEY (granted_by) REFERENCES Users(user_id), - UNIQUE(user_id, module_id) - ) - ''') - - + # CountSessions Table # NOTE: current_baseline_version removed - CURRENT is now global cursor.execute(''' @@ -195,6 +166,90 @@ def init_database(): ) ''') + # ============================================ + # CONSUMPTION SHEETS MODULE TABLES + # ============================================ + + # cons_processes - Master list of consumption sheet process types + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cons_processes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_key TEXT UNIQUE NOT NULL, + process_name TEXT NOT NULL, + template_file BLOB, + template_filename TEXT, + rows_per_page INTEGER DEFAULT 30, + detail_start_row INTEGER DEFAULT 10, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER NOT NULL, + is_active INTEGER DEFAULT 1, + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ) + ''') + + # cons_process_fields - Custom field definitions for each process + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cons_process_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_id INTEGER NOT NULL, + table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')), + field_name TEXT NOT NULL, + field_label TEXT NOT NULL, + field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')), + max_length INTEGER, + is_required INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + excel_cell TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (process_id) REFERENCES cons_processes(id) + ) + ''') + + # cons_sessions - Staff scanning sessions + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cons_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived')), + FOREIGN KEY (process_id) REFERENCES cons_processes(id), + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ) + ''') + + # cons_session_header_values - Flexible storage for header field values + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cons_session_header_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + field_id INTEGER NOT NULL, + field_value TEXT, + FOREIGN KEY (session_id) REFERENCES cons_sessions(id), + FOREIGN KEY (field_id) REFERENCES cons_process_fields(id) + ) + ''') + + # cons_session_details - Scanned lot details + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cons_session_details ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + item_number TEXT, + lot_number TEXT NOT NULL, + weight REAL, + scanned_by INTEGER NOT NULL, + scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + duplicate_status TEXT DEFAULT 'normal' CHECK(duplicate_status IN ('normal', 'dup_same_session', 'dup_other_session')), + duplicate_info TEXT, + comment TEXT, + is_deleted INTEGER DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES cons_sessions(id), + FOREIGN KEY (scanned_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)') @@ -211,6 +266,14 @@ def init_database(): # Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups + # Consumption Sheets indexes + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_session_details_session ON cons_session_details(session_id, is_deleted)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_session_details_lot ON cons_session_details(lot_number)') + conn.commit() conn.close() print(f"✅ Database initialized at: {DB_PATH}") @@ -248,4 +311,4 @@ def create_default_users(): if __name__ == '__main__': init_database() - create_default_users() + create_default_users() \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index a7319d6..ccb3bd0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2225,4 +2225,61 @@ body { .modal-content { max-height: none; margin: auto; +} + +/* ==================== ADMIN MODULES SECTION ==================== */ + +.modules-section { + margin-bottom: var(--space-2xl); + padding-bottom: var(--space-xl); + border-bottom: 2px solid var(--color-border); +} + +.section-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text-muted); + margin-bottom: var(--space-lg); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.modules-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--space-lg); +} + +.module-card-link { + text-decoration: none; +} + +.modules-grid .module-card { + padding: var(--space-lg); + border-radius: var(--radius-lg); +} + +.modules-grid .module-icon { + width: 50px; + height: 50px; + font-size: 1.5rem; + border-radius: var(--radius-md); +} + +.modules-grid .module-name { + font-size: 1.1rem; +} + +.modules-grid .module-desc { + font-size: 0.8rem; +} + +.module-card-active { + border-color: var(--color-primary); + background: var(--color-primary-glow); +} + +.module-card-active .module-icon { + background: var(--color-primary); + color: var(--color-bg); } \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 9768058..1ed1226 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -45,6 +45,23 @@ } + +
+

Modules

+
+
+
📋
+

Counts

+

Cycle counts & physical inventory

+
+ +
📝
+

Consumption Sheets

+

Production consumption tracking

+
+
+
+ {% if sessions %}
{% for session in sessions %} @@ -104,4 +121,4 @@
{% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/cons_sheets/add_field.html b/templates/cons_sheets/add_field.html new file mode 100644 index 0000000..964cddf --- /dev/null +++ b/templates/cons_sheets/add_field.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}Add {{ 'Header' if table_type == 'header' else 'Detail' }} Field - {{ process.process_name }} - ScanLook{% endblock %} + +{% block content %} +
+ + +
+

Add {{ 'Header' if table_type == 'header' else 'Detail' }} Field

+

{{ process.process_name }}

+ +
+
+ + +

Display name (field_name is auto-generated)

+
+ +
+ + +
+ +
+ + +

Only applies to TEXT fields

+
+ +
+ +
+ +
+ + +

+ {% if table_type == 'header' %} + Full cell reference (e.g., A3, B5) + {% else %} + Column letter only (e.g., A, B) — row is calculated + {% endif %} +

+
+ +
+ Cancel + +
+
+
+
+{% endblock %} diff --git a/templates/cons_sheets/admin_processes.html b/templates/cons_sheets/admin_processes.html new file mode 100644 index 0000000..f0f66de --- /dev/null +++ b/templates/cons_sheets/admin_processes.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Consumption Sheets - Admin - ScanLook{% endblock %} + +{% block content %} +
+ + + +
+
+

Consumption Sheets

+

Manage process types and templates

+
+ + + New Process + +
+ + {% if processes %} +
+ {% for process in processes %} +
+
+

{{ process.process_name }}

+ + {{ process.field_count or 0 }} fields + +
+ +
+
+ Key: + {{ process.process_key }} +
+
+ Created: + {{ process.created_at[:16] if process.created_at else 'N/A' }} +
+
+ By: + {{ process.created_by_name or 'Unknown' }} +
+
+ Template: + {{ '✅ Uploaded' if process.template_file else '❌ None' }} +
+
+ + +
+ {% endfor %} +
+ {% else %} +
+
📝
+

No Processes Defined

+

Create a process type to get started (e.g., "AD WIP")

+ + Create First Process + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/cons_sheets/create_process.html b/templates/cons_sheets/create_process.html new file mode 100644 index 0000000..c269190 --- /dev/null +++ b/templates/cons_sheets/create_process.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Create Process - Consumption Sheets - ScanLook{% endblock %} + +{% block content %} +
+ + +
+

Create New Process

+ +
+
+ + +

This will be displayed in menus and reports

+
+ +
+ Cancel + +
+
+
+
+{% endblock %} diff --git a/templates/cons_sheets/edit_field.html b/templates/cons_sheets/edit_field.html new file mode 100644 index 0000000..0dc0532 --- /dev/null +++ b/templates/cons_sheets/edit_field.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}Edit Field - {{ process.process_name }} - ScanLook{% endblock %} + +{% block content %} +
+ + +
+

Edit Field

+

+ {{ process.process_name }} — {{ 'Header' if field.table_type == 'header' else 'Detail' }} +

+ +
+
+ + +

Cannot be changed (used in database)

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ Cancel + +
+
+
+
+{% endblock %} diff --git a/templates/cons_sheets/new_session.html b/templates/cons_sheets/new_session.html new file mode 100644 index 0000000..a6968d7 --- /dev/null +++ b/templates/cons_sheets/new_session.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}New {{ process.process_name }} Session - ScanLook{% endblock %} + +{% block content %} +
+ + +
+

New {{ process.process_name }} Session

+

Enter header information to begin scanning

+ +
+ {% for field in header_fields %} +
+ + + {% if field.field_type == 'DATE' %} + + + {% elif field.field_type == 'DATETIME' %} + + + {% elif field.field_type == 'INTEGER' %} + + + {% elif field.field_type == 'REAL' %} + + + {% else %} + + {% endif %} +
+ {% endfor %} + + {% if not header_fields %} +
+

No header fields configured for this process.

+

Contact your administrator to set up fields.

+
+ {% endif %} + +
+ Cancel + +
+
+
+
+ + +{% endblock %} diff --git a/templates/cons_sheets/process_detail.html b/templates/cons_sheets/process_detail.html new file mode 100644 index 0000000..81d09c5 --- /dev/null +++ b/templates/cons_sheets/process_detail.html @@ -0,0 +1,245 @@ +{% extends "base.html" %} + +{% block title %}{{ process.process_name }} - Consumption Sheets - ScanLook{% endblock %} + +{% block content %} +
+ + +
+
+

{{ process.process_name }}

+

Key: {{ process.process_key }}

+
+
+ + +
+ +
+
+
🗄️
+

Database

+
+

Define fields for header and detail tables

+ +
+
+ {{ header_fields|length }} + Header Fields +
+
+ {{ detail_fields|length }} + Detail Fields +
+
+ + + Configure Fields + +
+ + +
+
+
📊
+

Excel Template

+
+

Upload template and map fields to cells

+ +
+
+ {% if process.template_file %} + + {{ process.template_filename or 'Uploaded' }} + {% else %} + + No template + {% endif %} +
+
+ {{ process.rows_per_page or 30 }} + Rows/Page +
+
+ + + Configure Template + +
+
+ + + {% if header_fields or detail_fields %} +
+

Field Preview

+ + {% if header_fields %} +
+

Header Fields

+
+ {% for field in header_fields %} +
+ {{ field.field_label }} + {{ field.field_type }} + {% if field.excel_cell %} + → {{ field.excel_cell }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if detail_fields %} +
+

Detail Fields

+
+ {% for field in detail_fields %} +
+ {{ field.field_label }} + {{ field.field_type }} + {% if field.excel_cell %} + → Col {{ field.excel_cell }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endif %} +
+ + +{% endblock %} diff --git a/templates/cons_sheets/process_fields.html b/templates/cons_sheets/process_fields.html new file mode 100644 index 0000000..c332996 --- /dev/null +++ b/templates/cons_sheets/process_fields.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} + +{% block title %}Fields - {{ process.process_name }} - ScanLook{% endblock %} + +{% block content %} +
+ + +
+
+

Database Fields

+

{{ process.process_name }}

+
+
+ + +
+
+

Header Fields

+ + + Add Field + +
+ + {% if header_fields %} +
+ + + + + + + + + + + + + {% for field in header_fields %} + + + + + + + + + {% endfor %} + +
Field NameLabelTypeRequiredExcel CellActions
{{ field.field_name }}{{ field.field_label }}{{ field.field_type }}{{ '✓' if field.is_required else '—' }}{{ field.excel_cell or '—' }} + Edit + +
+
+ {% else %} +
+

No header fields defined yet.

+
+ {% endif %} +
+ + +
+
+

Detail Fields

+ + + Add Field + +
+ + {% if detail_fields %} +
+ + + + + + + + + + + + + {% for field in detail_fields %} + + + + + + + + + {% endfor %} + +
Field NameLabelTypeRequiredExcel ColumnActions
{{ field.field_name }}{{ field.field_label }}{{ field.field_type }}{{ '✓' if field.is_required else '—' }}{{ field.excel_cell or '—' }} + Edit + +
+
+ {% else %} +
+

No detail fields defined yet.

+
+ {% endif %} +
+
+ + + + +{% endblock %} diff --git a/templates/cons_sheets/process_template.html b/templates/cons_sheets/process_template.html new file mode 100644 index 0000000..f1ee667 --- /dev/null +++ b/templates/cons_sheets/process_template.html @@ -0,0 +1,269 @@ +{% extends "base.html" %} + +{% block title %}Excel Template - {{ process.process_name }} - ScanLook{% endblock %} + +{% block content %} +
+ + +
+
+

Excel Template

+

{{ process.process_name }}

+
+
+ +
+ +
+

Template File

+ +
+ {% if process.template_filename %} +
+ 📄 + {{ process.template_filename }} + Download +
+ {% else %} +

No template uploaded yet

+ {% endif %} +
+ +
+
+ + +

Excel files (.xlsx) only

+
+ +
+
+ + +
+

Page Settings

+ +
+
+ + +

Max detail rows before starting a new page

+
+ +
+ + +

Excel row number where detail data begins

+
+ + +
+
+
+ + +
+

Field Mappings

+

Excel cell mappings are configured per-field in the Database section. Here's a summary:

+ + {% if header_fields or detail_fields %} +
+ {% if header_fields %} +
+

Header Fields

+ + + + + + + + + {% for field in header_fields %} + + + + + {% endfor %} + +
FieldExcel Cell
{{ field.field_label }} + {% if field.excel_cell %} + {{ field.excel_cell }} + {% else %} + Not mapped + {% endif %} +
+
+ {% endif %} + + {% if detail_fields %} +
+

Detail Fields

+

Columns only — rows start at {{ process.detail_start_row or 10 }}

+ + + + + + + + + {% for field in detail_fields %} + + + + + {% endfor %} + +
FieldExcel Column
{{ field.field_label }} + {% if field.excel_cell %} + {{ field.excel_cell }} + {% else %} + Not mapped + {% endif %} +
+
+ {% endif %} +
+ + + Edit Field Mappings + + {% else %} +
+

No fields defined yet. Add fields first.

+
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/cons_sheets/scan_session.html b/templates/cons_sheets/scan_session.html new file mode 100644 index 0000000..31c9360 --- /dev/null +++ b/templates/cons_sheets/scan_session.html @@ -0,0 +1,535 @@ +{% extends "base.html" %} + +{% block title %}Scanning - {{ session.process_name }} - ScanLook{% endblock %} + +{% block content %} +
+
+
+ ← Back to Sessions +
{{ session.process_name }}
+
+ {% for hv in header_values %} + {{ hv.field_label }}: {{ hv.field_value }} + {% endfor %} +
+
+ Scanned: {{ scans|length }} +
+
+
+ + +
+
+

Scan Lot Number

+
+ +
+
+ + +
+
+
+ + + + + + + + + + + +
+
+

Scanned Lots ({{ scans|length }})

+
+ +
+ {% for scan in scans %} +
+
{{ scan.lot_number }}
+
{{ scan.item_number or 'N/A' }}
+
{{ '%.1f'|format(scan.weight) if scan.weight else '-' }} lbs
+
+ {% if scan.duplicate_status == 'dup_same_session' %} + Duplicate + {% elif scan.duplicate_status == 'dup_other_session' %} + Warning + {% else %} + OK + {% endif %} +
+
+ {% endfor %} +
+
+ + + + + +
+
+ + ← Back to Sessions + + +
+
+
+ + + + +{% endblock %} diff --git a/templates/cons_sheets/staff_index.html b/templates/cons_sheets/staff_index.html new file mode 100644 index 0000000..d5b8a05 --- /dev/null +++ b/templates/cons_sheets/staff_index.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} + +{% block title %}Consumption Sheets - ScanLook{% endblock %} + +{% block content %} +
+ + + +
+

Start New Session

+
+ {% for p in processes %} + + + {{ p.process_name }} + + {% endfor %} + {% if not processes %} +

No process types configured yet. Contact your administrator.

+ {% endif %} +
+
+ + + {% if sessions %} +
+

📋 My Active Sessions

+ +
+ {% else %} +
+
📝
+

No Active Sessions

+

Start a new session by selecting a process type above

+
+ {% endif %} +
+ + + + +{% endblock %}