5 Commits

Author SHA1 Message Date
Javier
2a649fdbcc V0.15.0 - Not done yet 2026-02-01 16:22:59 -06:00
Javier
89be88566f Excel Template working better, still not finished. 2026-02-01 01:35:02 -06:00
Javier
1359e036d5 Update 2026-01-31 22:20:10 -06:00
Javier
ad071438cc Merge branch 'Refractor--Changing-how-counting-works' 2026-01-31 20:32:18 -06:00
Javier
5604686630 update: added files to gitignore 2026-01-31 20:30:12 -06:00
11 changed files with 468 additions and 204 deletions

View File

@@ -4,20 +4,7 @@ You are helping build a project called **Scanlook**.
## Scanlook (current product summary) ## Scanlook (current product summary)
Scanlook is a web app for warehouse counting workflows. Scanlook is a web app for warehouse counting workflows.
- Admin creates a **Count Session** (e.g., “Jan 24 2026 - First Shift”) and uploads a **Master Inventory list**. Scanlook is modular.
- Staff select the active Count Session, enter a **Location/BIN**, and the app shows the **Expected** lots/items/weights that should be there (Cycle Count mode).
- Staff **scan lot numbers**, enter **weights**, and each scan moves from **Expected → Scanned**.
- System flags:
- duplicates
- wrong location
- “ghost” lots (physically found but not in system/master list)
- Staff can **Finalize** a BIN; once finalized, it should clearly report **missing items/lots**.
- Admin sees live progress in an **Admin Dashboard**.
- Multiple Count Sessions can exist even on the same day (e.g., First Shift vs Second Shift) and must be completely isolated.
There are two types of counts:
1) **Cycle Count**: shows Expected list for the BIN.
2) **Physical Inventory**: same workflow but **blind** (does NOT show Expected list; only scanned results, then missing is determined after).
Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable. Long-term goal: evolve into a WMS, but right now focus on making this workflow reliable.
@@ -44,7 +31,7 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r
## Scanlook (current product summary) ## Scanlook (current product summary)
Scanlook is a web app for warehouse counting workflows built with Flask + SQLite. Scanlook is a web app for warehouse counting workflows built with Flask + SQLite.
**Current Version:** 0.13.0 **Current Version:** 0.14.0
**Tech Stack:** **Tech Stack:**
- Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation) - Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation)
@@ -79,13 +66,9 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
- Consumption Sheets module (production lot tracking with Excel export) - Consumption Sheets module (production lot tracking with Excel export)
- Database migration system (auto-applies schema changes on startup) - Database migration system (auto-applies schema changes on startup)
**Two count types:**
1. Cycle Count: shows Expected list for the BIN
2. Physical Inventory: blind count (no Expected list shown)
**Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production. **Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production.
**Module System (v0.13.0):** **Module System (v0.14.0):**
- Modules table defines available modules (module_key used for routing) - Modules table defines available modules (module_key used for routing)
- UserModules table tracks per-user access - UserModules table tracks per-user access
- Home page (/home) shows module cards based on user's access - Home page (/home) shows module cards based on user's access
@@ -104,5 +87,5 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite
- Scanner viewport: 320px wide (MC9300) - Scanner viewport: 320px wide (MC9300)
- Mobile breakpoint: 360-767px - Mobile breakpoint: 360-767px
- Desktop: 768px+ - Desktop: 768px+
- Git remote: http://10.44.44.33:3000/stuff/ScanLook.git - Git remote: https://tsngit.tsnx.net/stuff/ScanLook.git
- Docker registry: 10.44.44.33:3000/stuff/scanlook - Docker registry: 10.44.44.33:3000/stuff/scanlook

2
app.py
View File

@@ -38,7 +38,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1)
# 1. Define the version # 1. Define the version
APP_VERSION = '0.14.0' APP_VERSION = '0.15.0'
# 2. Inject it into all templates automatically # 2. Inject it into all templates automatically
@app.context_processor @app.context_processor

View File

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

View File

@@ -210,6 +210,46 @@ def migration_006_add_is_deleted_to_locationcounts():
conn.commit() conn.commit()
conn.close() conn.close()
def migration_007_add_detail_end_row():
"""Add detail_end_row column to cons_processes table"""
conn = get_db()
if table_exists('cons_processes'):
if not column_exists('cons_processes', 'detail_end_row'):
conn.execute('ALTER TABLE cons_processes ADD COLUMN detail_end_row INTEGER')
print(" Added detail_end_row column to cons_processes")
conn.commit()
conn.close()
def migration_008_add_page_height():
"""Add page_height column to cons_processes table"""
conn = get_db()
if table_exists('cons_processes'):
if not column_exists('cons_processes', 'page_height'):
conn.execute('ALTER TABLE cons_processes ADD COLUMN page_height INTEGER')
print(" Added page_height column to cons_processes")
conn.commit()
conn.close()
def migration_009_add_print_columns():
"""Add print_start_col and print_end_col to cons_processes"""
conn = get_db()
if table_exists('cons_processes'):
if not column_exists('cons_processes', 'print_start_col'):
conn.execute('ALTER TABLE cons_processes ADD COLUMN print_start_col TEXT DEFAULT "A"')
print(" Added print_start_col")
if not column_exists('cons_processes', 'print_end_col'):
conn.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT')
print(" Added print_end_col")
conn.commit()
conn.close()
# List of all migrations in order # List of all migrations in order
MIGRATIONS = [ MIGRATIONS = [
(1, 'add_modules_tables', migration_001_add_modules_tables), (1, 'add_modules_tables', migration_001_add_modules_tables),
@@ -218,6 +258,9 @@ MIGRATIONS = [
(4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins), (4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins),
(5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key), (5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key),
(6, 'add_is_deleted_to_locationcounts', migration_006_add_is_deleted_to_locationcounts), (6, 'add_is_deleted_to_locationcounts', migration_006_add_is_deleted_to_locationcounts),
(7, 'add_detail_end_row', migration_007_add_detail_end_row),
(8, 'add_page_height', migration_008_add_page_height),
(9, 'add_print_columns', migration_009_add_print_columns),
] ]

View File

@@ -1,3 +1,4 @@
Flask==3.1.2 Flask==3.1.2
Werkzeug==3.1.5 Werkzeug==3.1.5
openpyxl openpyxl
Pillow

View File

@@ -2283,3 +2283,22 @@ body {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-bg); color: var(--color-bg);
} }
/* ==================== ICON BUTTONS ==================== */
.btn-icon-only {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 8px;
transition: var(--transition);
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon-only:hover {
color: var(--color-danger);
transform: scale(1.1);
}

View File

@@ -11,8 +11,21 @@
</a> </a>
<div> <div>
<h1 class="page-title" style="margin-bottom: 0;">Consumption Sheets</h1> <h1 class="page-title" style="margin-bottom: 0; {{ 'color: var(--color-danger);' if showing_archived else '' }}">
<p class="page-subtitle" style="margin-bottom: 0;">Manage process types and templates</p> {{ 'Archived Processes' if showing_archived else 'Consumption Sheets' }}
</h1>
<p class="page-subtitle" style="margin-bottom: var(--space-xs);">Manage process types and templates</p>
{% if showing_archived %}
<a href="{{ url_for('cons_sheets.admin_processes') }}" style="font-size: 0.85rem; color: var(--color-primary); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-eye"></i> Return to Active List
</a>
{% else %}
<a href="{{ url_for('cons_sheets.admin_processes', archived=1) }}" style="font-size: 0.85rem; color: var(--color-text-muted); display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-box-archive"></i> View Archived Processes
</a>
{% endif %}
</div> </div>
</div> </div>
@@ -25,13 +38,34 @@
<div class="sessions-grid"> <div class="sessions-grid">
{% for process in processes %} {% for process in processes %}
<div class="session-card"> <div class="session-card">
<div class="session-card-header"> <div class="session-card-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h3 class="session-name">{{ process.process_name }}</h3> <h3 class="session-name">{{ process.process_name }}</h3>
<span class="session-type-badge"> <span class="session-type-badge">
{{ process.field_count or 0 }} fields {{ process.field_count or 0 }} fields
</span> </span>
</div> </div>
{% if showing_archived %}
<form method="POST"
action="{{ url_for('cons_sheets.restore_process', process_id=process.id) }}"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Restore Process" style="color: var(--color-success);">
<i class="fa-solid fa-trash-arrow-up"></i>
</button>
</form>
{% else %}
<form method="POST"
action="{{ url_for('cons_sheets.delete_process', process_id=process.id) }}"
onsubmit="return confirm('Are you sure you want to delete {{ process.process_name }}?');"
style="margin: 0;">
<button type="submit" class="btn-icon-only" title="Delete Process">
<i class="fa-solid fa-trash"></i>
</button>
</form>
{% endif %}
</div>
<div class="session-meta"> <div class="session-meta">
<div class="meta-item"> <div class="meta-item">
<span class="meta-label">Key:</span> <span class="meta-label">Key:</span>
@@ -57,6 +91,8 @@
</a> </a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View File

@@ -50,16 +50,40 @@
<form method="POST" action="{{ url_for('cons_sheets.update_template_settings', process_id=process.id) }}"> <form method="POST" action="{{ url_for('cons_sheets.update_template_settings', process_id=process.id) }}">
<div class="form-group"> <div class="form-group">
<label for="rows_per_page" class="form-label">Rows Per Page</label> <label for="rows_per_page" class="form-label">Rows Per Page (Capacity)</label>
<input type="number" id="rows_per_page" name="rows_per_page" <input type="number" id="rows_per_page" name="rows_per_page"
value="{{ process.rows_per_page or 30 }}" min="1" max="500" class="form-input"> value="{{ process.rows_per_page or 30 }}" min="1" max="5000" class="form-input">
<p class="form-hint">Max detail rows before starting a new page</p> <p class="form-hint">How many items fit in the grid before we need a new page?</p>
</div> </div>
<div class="form-group" style="flex: 1;">
<label for="print_start_col" class="form-label">Print Start Column</label>
<input type="text" id="print_start_col" name="print_start_col"
value="{{ process.print_start_col or 'A' }}" class="form-input"
placeholder="e.g. A" pattern="[A-Za-z]+" title="Letters only">
<p class="form-hint">First column to print.</p>
</div>
<div class="form-group" style="flex: 1;">
<label for="print_end_col" class="form-label">Print End Column</label>
<input type="text" id="print_end_col" name="print_end_col"
value="{{ process.print_end_col or '' }}" class="form-input"
placeholder="e.g. K" pattern="[A-Za-z]+" title="Letters only">
<p class="form-hint">Last column to print (defines width).</p>
</div>
<div class="form-group">
<label for="page_height" class="form-label">Page Height (Total Rows)</label>
<input type="number" id="page_height" name="page_height"
value="{{ process.page_height or '' }}" min="1" class="form-input">
<p class="form-hint">The exact distance (in Excel rows) from the top of Page 1 to the top of Page 2.</p>
</div>
<div class="form-group"> <div class="form-group">
<label for="detail_start_row" class="form-label">Detail Start Row</label> <label for="detail_start_row" class="form-label">Detail Start Row</label>
<input type="number" id="detail_start_row" name="detail_start_row" <input type="number" id="detail_start_row" name="detail_start_row"
value="{{ process.detail_start_row or 10 }}" min="1" max="500" class="form-input"> value="{{ process.detail_start_row or 10 }}" min="1" max="5000" class="form-input">
<p class="form-hint">Excel row number where detail data begins</p> <p class="form-hint">Excel row number where detail data begins</p>
</div> </div>

View File

@@ -101,6 +101,11 @@
<div class="scans-header"> <div class="scans-header">
<h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3> <h3 class="scans-title">Scanned Items (<span id="scanListCount">{{ scans|length }}</span>)</h3>
</div> </div>
<div style="margin-top: 10px;">
<button type="button" class="btn btn-secondary btn-sm" onclick="document.getElementById('importModal').style.display='flex'">
<i class="fa-solid fa-file-import"></i> Bulk Import Excel
</button>
</div>
<div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};"> <div id="scansList" class="scans-grid" style="--field-count: {{ detail_fields|length }};">
{% for scan in scans %} {% for scan in scans %}
<div class="scan-row scan-row-{{ scan.duplicate_status }}" <div class="scan-row scan-row-{{ scan.duplicate_status }}"
@@ -137,6 +142,39 @@
</div> </div>
</div> </div>
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header-bar">
<h3 class="modal-title">Bulk Import Data</h3>
<button type="button" class="btn-close-modal" onclick="document.getElementById('importModal').style.display='none'">&times;</button>
</div>
<div class="modal-body" style="text-align: center;">
<p style="color: var(--color-text-muted); margin-bottom: 20px;">
Upload an Excel file (.xlsx) to automatically populate this session.
<br><strong>Warning:</strong> This bypasses all validation checks.
</p>
<div style="margin-bottom: 30px; padding: 15px; background: var(--color-bg); border-radius: 8px;">
<p style="font-size: 0.9rem; margin-bottom: 10px;">Step 1: Get the correct format</p>
<a href="{{ url_for('cons_sheets.download_import_template', session_id=session['id']) }}" class="btn btn-secondary btn-sm">
<i class="fa-solid fa-download"></i> Download Template
</a>
</div>
<form action="{{ url_for('cons_sheets.import_session_data', session_id=session['id']) }}" method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 20px;">
<input type="file" name="file" accept=".xlsx" class="file-input" required style="width: 100%;">
</div>
<button type="submit" class="btn btn-primary btn-block">
<i class="fa-solid fa-upload"></i> Upload & Process
</button>
</form>
</div>
</div>
</div>
<style> <style>
.header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; } .header-values { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin: var(--space-sm) 0; }
.header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); } .header-pill { background: var(--color-surface-elevated); padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--color-text-muted); }