Initial commit: Quality App v2 - FG Scan Module with Reports

This commit is contained in:
Quality App Developer
2026-01-25 22:25:18 +02:00
commit 3c5a273a89
66 changed files with 15368 additions and 0 deletions

183
app/__init__.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Quality App v2 - Flask Application Factory
Robust, modular application with login, dashboard, and multiple modules
"""
from flask import Flask
from datetime import datetime, timedelta
import os
import logging
from logging.handlers import RotatingFileHandler
def create_app(config=None):
"""
Application factory function
Creates and configures the Flask application
"""
app = Flask(__name__)
# Load configuration
if config is None:
from app.config import Config
config = Config
app.config.from_object(config)
# Setup logging
setup_logging(app)
logger = logging.getLogger(__name__)
logger.info("=" * 80)
logger.info("Flask App Initialization Started")
logger.info("=" * 80)
# Configure session
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=8)
app.config['SESSION_COOKIE_SECURE'] = False # Set True in production with HTTPS
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Initialize database connection
logger.info("Initializing database connection...")
from app.database import init_db, close_db
init_db(app)
app.teardown_appcontext(close_db)
# Register blueprints
logger.info("Registering blueprints...")
register_blueprints(app)
# Register error handlers
logger.info("Registering error handlers...")
register_error_handlers(app)
# Add template globals
app.jinja_env.globals['now'] = datetime.now
# Add context processor for app name
@app.context_processor
def inject_app_settings():
"""Inject app settings into all templates"""
try:
from app.database import get_db
conn = get_db()
cursor = conn.cursor()
cursor.execute(
"SELECT setting_value FROM application_settings WHERE setting_key = %s",
('app_name',)
)
result = cursor.fetchone()
cursor.close()
app_name = result[0] if result else 'Quality App v2'
except:
app_name = 'Quality App v2'
return {'app_name': app_name}
# Add before_request handlers
register_request_handlers(app)
# Initialize backup scheduler
logger.info("Initializing backup scheduler...")
try:
from app.scheduler import init_scheduler
init_scheduler(app)
except Exception as e:
logger.error(f"Failed to initialize backup scheduler: {e}")
logger.info("=" * 80)
logger.info("Flask App Initialization Completed Successfully")
logger.info("=" * 80)
return app
def setup_logging(app):
"""Configure application logging"""
log_dir = app.config.get('LOG_DIR', '/app/data/logs')
# Create log directory if it doesn't exist
if not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
# Configure rotating file handler
log_file = os.path.join(log_dir, 'app.log')
handler = RotatingFileHandler(
log_file,
maxBytes=10485760, # 10MB
backupCount=10
)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
# Set logging level
log_level = app.config.get('LOG_LEVEL', 'INFO')
handler.setLevel(getattr(logging, log_level))
# Add handler to app logger
app.logger.addHandler(handler)
app.logger.setLevel(getattr(logging, log_level))
def register_blueprints(app):
"""Register application blueprints"""
from app.routes import main_bp
from app.modules.quality.routes import quality_bp
from app.modules.settings.routes import settings_bp
app.register_blueprint(main_bp)
app.register_blueprint(quality_bp, url_prefix='/quality')
app.register_blueprint(settings_bp, url_prefix='/settings')
app.logger.info("Blueprints registered: main, quality, settings")
def register_error_handlers(app):
"""Register error handlers"""
@app.errorhandler(404)
def page_not_found(e):
from flask import render_template
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(e):
from flask import render_template
app.logger.error(f"Internal error: {e}")
return render_template('errors/500.html'), 500
@app.errorhandler(403)
def forbidden(e):
from flask import render_template
return render_template('errors/403.html'), 403
def register_request_handlers(app):
"""Register before/after request handlers"""
@app.before_request
def before_request():
"""Handle pre-request logic"""
from flask import session, request, redirect, url_for
# Skip authentication check for login and static files
if request.endpoint and (
request.endpoint in ['static', 'main.login', 'main.index'] or
request.path.startswith('/static/')
):
return None
# Check if user is logged in
if 'user_id' not in session:
return redirect(url_for('main.login'))
return None
@app.after_request
def after_request(response):
"""Handle post-request logic"""
return response

279
app/access_control.py Normal file
View File

@@ -0,0 +1,279 @@
"""
Role-Based Access Control (RBAC) System
Defines roles, permissions, and access control decorators
"""
from functools import wraps
from flask import session, redirect, url_for, flash
# Role Definitions
ROLES = {
'superadmin': {
'name': 'Super Administrator',
'description': 'Full system access to all modules and features',
'level': 100,
'modules': ['quality', 'settings']
},
'manager': {
'name': 'Manager',
'description': 'Full access to assigned modules and quality control',
'level': 70,
'modules': ['quality']
},
'worker': {
'name': 'Worker',
'description': 'Limited access to view and create quality inspections',
'level': 50,
'modules': ['quality']
},
'admin': {
'name': 'Administrator',
'description': 'Administrative access - can manage users and system configuration',
'level': 90,
'modules': ['quality', 'settings']
}
}
# Module Permissions Structure
MODULE_PERMISSIONS = {
'quality': {
'name': 'Quality Control Module',
'sections': {
'inspections': {
'name': 'Quality Inspections',
'actions': {
'view': 'View inspections',
'create': 'Create new inspection',
'edit': 'Edit inspections',
'delete': 'Delete inspections'
},
'superadmin': ['view', 'create', 'edit', 'delete'],
'admin': ['view', 'create', 'edit', 'delete'],
'manager': ['view', 'create', 'edit', 'delete'],
'worker': ['view', 'create']
},
'reports': {
'name': 'Quality Reports',
'actions': {
'view': 'View reports',
'export': 'Export reports',
'download': 'Download reports'
},
'superadmin': ['view', 'export', 'download'],
'admin': ['view', 'export', 'download'],
'manager': ['view', 'export', 'download'],
'worker': ['view']
}
}
},
'settings': {
'name': 'Settings Module',
'sections': {
'general': {
'name': 'General Settings',
'actions': {
'view': 'View settings',
'edit': 'Edit settings'
},
'superadmin': ['view', 'edit'],
'admin': ['view', 'edit'],
'manager': [],
'worker': []
},
'users': {
'name': 'User Management',
'actions': {
'view': 'View users',
'create': 'Create users',
'edit': 'Edit users',
'delete': 'Delete users'
},
'superadmin': ['view', 'create', 'edit', 'delete'],
'admin': ['view', 'create', 'edit', 'delete'],
'manager': [],
'worker': []
},
'database': {
'name': 'Database Settings',
'actions': {
'view': 'View database settings',
'edit': 'Edit database settings'
},
'superadmin': ['view', 'edit'],
'admin': ['view', 'edit'],
'manager': [],
'worker': []
}
}
}
}
def check_permission(user_role, module, section, action):
"""
Check if a user has permission to perform an action
Args:
user_role (str): User's role
module (str): Module name
section (str): Section within module
action (str): Action to perform
Returns:
bool: True if user has permission, False otherwise
"""
if not user_role or user_role not in ROLES:
return False
# Superadmin has all permissions
if user_role == 'superadmin':
return True
# Check if module exists
if module not in MODULE_PERMISSIONS:
return False
# Check if section exists
if section not in MODULE_PERMISSIONS[module]['sections']:
return False
# Get allowed actions for this role in this section
section_config = MODULE_PERMISSIONS[module]['sections'][section]
allowed_actions = section_config.get(user_role, [])
return action in allowed_actions
def has_module_access(user_role, module):
"""
Check if user has access to a module
Args:
user_role (str): User's role
module (str): Module name
Returns:
bool: True if user can access module, False otherwise
"""
if not user_role or user_role not in ROLES:
return False
if user_role == 'superadmin':
return True
return module in ROLES[user_role].get('modules', [])
def requires_role(*allowed_roles):
"""
Decorator to require specific roles for a route
Usage:
@requires_role('superadmin', 'admin')
def admin_page():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('main.login'))
user_role = session.get('role', 'worker')
if user_role not in allowed_roles:
flash('Access denied: You do not have permission to access this page.', 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
def requires_module_permission(module, section, action):
"""
Decorator to require specific module/section/action permission
Usage:
@requires_module_permission('quality', 'inspections', 'edit')
def edit_inspection():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('main.login'))
user_role = session.get('role', 'worker')
if not check_permission(user_role, module, section, action):
flash('Access denied: You do not have permission to perform this action.', 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
def requires_module_access(module):
"""
Decorator to require access to a specific module
Usage:
@requires_module_access('quality')
def quality_page():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('main.login'))
user_role = session.get('role', 'worker')
if not has_module_access(user_role, module):
flash(f'Access denied: You do not have access to the {module} module.', 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
return decorator
def get_user_permissions(user_role):
"""
Get all permissions for a user role
Args:
user_role (str): User's role
Returns:
dict: Dictionary of all permissions for the role
"""
permissions = {}
if not user_role or user_role not in ROLES:
return permissions
# Superadmin gets all permissions
if user_role == 'superadmin':
for module, module_data in MODULE_PERMISSIONS.items():
permissions[module] = {}
for section, section_data in module_data['sections'].items():
permissions[module][section] = list(section_data['actions'].keys())
return permissions
# Get specific role permissions
for module, module_data in MODULE_PERMISSIONS.items():
if module in ROLES[user_role].get('modules', []):
permissions[module] = {}
for section, section_data in module_data['sections'].items():
allowed_actions = section_data.get(user_role, [])
if allowed_actions:
permissions[module][section] = allowed_actions
return permissions

129
app/auth.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Authentication utilities for login and session management
"""
import hashlib
import logging
from app.database import execute_query, execute_update
logger = logging.getLogger(__name__)
def hash_password(password):
"""Hash a password using SHA256"""
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(plain_password, hashed_password):
"""Verify a plain password against a hashed password"""
return hash_password(plain_password) == hashed_password
def authenticate_user(username, password):
"""
Authenticate a user by username and password
Args:
username: User's username
password: User's password (plain text)
Returns:
User dict if authentication successful, None otherwise
"""
try:
query = """
SELECT id, username, email, role, is_active, full_name
FROM users
WHERE username = %s AND is_active = 1
"""
result = execute_query(query, (username,), fetch_one=True)
if not result:
logger.warning(f"Login attempt for non-existent user: {username}")
return None
user_id, user_username, email, role, is_active, full_name = result
# Get stored password hash
password_query = "SELECT password_hash FROM user_credentials WHERE user_id = %s"
password_result = execute_query(password_query, (user_id,), fetch_one=True)
if not password_result:
logger.warning(f"No password hash found for user: {username}")
return None
password_hash = password_result[0]
if not verify_password(password, password_hash):
logger.warning(f"Invalid password for user: {username}")
return None
logger.info(f"User authenticated successfully: {username}")
return {
'id': user_id,
'username': user_username,
'email': email,
'role': role,
'full_name': full_name
}
except Exception as e:
logger.error(f"Authentication error: {e}")
return None
def get_user_by_id(user_id):
"""Get user information by user ID"""
try:
query = """
SELECT id, username, email, role, is_active, full_name
FROM users
WHERE id = %s
"""
result = execute_query(query, (user_id,), fetch_one=True)
if result:
user_id, username, email, role, is_active, full_name = result
return {
'id': user_id,
'username': username,
'email': email,
'role': role,
'full_name': full_name
}
return None
except Exception as e:
logger.error(f"Error getting user by ID: {e}")
return None
def create_user(username, email, password, full_name, role='user'):
"""Create a new user"""
try:
password_hash = hash_password(password)
# Insert into users table
user_query = """
INSERT INTO users (username, email, full_name, role, is_active)
VALUES (%s, %s, %s, %s, 1)
"""
execute_update(user_query, (username, email, full_name, role))
# Get the inserted user ID
get_id_query = "SELECT id FROM users WHERE username = %s"
result = execute_query(get_id_query, (username,), fetch_one=True)
user_id = result[0]
# Insert password hash
cred_query = """
INSERT INTO user_credentials (user_id, password_hash)
VALUES (%s, %s)
"""
execute_update(cred_query, (user_id, password_hash))
logger.info(f"User created successfully: {username}")
return user_id
except Exception as e:
logger.error(f"Error creating user: {e}")
return None

82
app/config.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Application Configuration
"""
import os
from datetime import timedelta
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config:
"""Base configuration"""
# Flask
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = False
TESTING = False
# Session
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
# Database
DB_HOST = os.getenv('DB_HOST', 'localhost')
DB_PORT = int(os.getenv('DB_PORT', 3306))
DB_USER = os.getenv('DB_USER', 'quality_user')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'password')
DB_NAME = os.getenv('DB_NAME', 'quality_db')
# Database pool settings
DB_POOL_SIZE = 10
DB_POOL_TIMEOUT = 30
DB_POOL_RECYCLE = 3600
# Application
APP_PORT = int(os.getenv('APP_PORT', 8080))
APP_HOST = os.getenv('APP_HOST', '0.0.0.0')
# Logging
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
LOG_DIR = os.getenv('LOG_DIR', './data/logs')
# Upload settings
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB max upload
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', './data/uploads')
# Ensure directories exist
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
TESTING = False
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
TESTING = False
SESSION_COOKIE_SECURE = True
class TestingConfig(Config):
"""Testing configuration"""
DEBUG = True
TESTING = True
DB_NAME = 'quality_db_test'
# Select configuration based on environment
env = os.getenv('FLASK_ENV', 'production')
if env == 'development':
ConfigClass = DevelopmentConfig
elif env == 'testing':
ConfigClass = TestingConfig
else:
ConfigClass = ProductionConfig

141
app/database.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Database connection management and initialization
Uses connection pooling to manage database connections efficiently
"""
import pymysql
import logging
from dbutils.pooled_db import PooledDB
from flask import g
logger = logging.getLogger(__name__)
# Global database pool
db_pool = None
def init_db(app):
"""Initialize database connection pool"""
global db_pool
try:
db_pool = PooledDB(
creator=pymysql,
maxconnections=app.config.get('DB_POOL_SIZE', 10),
mincached=2,
maxcached=5,
maxshared=3,
blocking=True,
maxusage=None,
setsession=[],
ping=1,
# PyMySQL connection parameters
user=app.config['DB_USER'],
password=app.config['DB_PASSWORD'],
host=app.config['DB_HOST'],
port=app.config['DB_PORT'],
database=app.config['DB_NAME'],
)
logger.info(f"Database pool initialized: {app.config['DB_HOST']}:{app.config['DB_PORT']}/{app.config['DB_NAME']}")
except Exception as e:
logger.error(f"Failed to initialize database pool: {e}")
raise
def get_db():
"""Get database connection from pool"""
if db_pool is None:
raise RuntimeError("Database pool not initialized")
if 'db' not in g:
try:
g.db = db_pool.connection()
logger.debug("Database connection obtained from pool")
except Exception as e:
logger.error(f"Failed to get database connection: {e}")
raise
return g.db
def close_db(e=None):
"""Close database connection"""
db = g.pop('db', None)
if db is not None:
try:
db.close()
logger.debug("Database connection closed")
except Exception as e:
logger.error(f"Error closing database connection: {e}")
def execute_query(query, params=None, fetch_one=False, fetch_all=True):
"""
Execute a database query
Args:
query: SQL query string
params: Query parameters (tuple or list)
fetch_one: Fetch only one row
fetch_all: Fetch all rows (if fetch_one is False)
Returns:
Query result or None
"""
try:
db = get_db()
cursor = db.cursor()
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
if fetch_one:
result = cursor.fetchone()
elif fetch_all:
result = cursor.fetchall()
else:
result = None
cursor.close()
return result
except Exception as e:
logger.error(f"Database query error: {e}\nQuery: {query}\nParams: {params}")
raise
def execute_update(query, params=None):
"""
Execute an UPDATE, INSERT, or DELETE query
Args:
query: SQL query string
params: Query parameters (tuple or list)
Returns:
Number of affected rows
"""
try:
db = get_db()
cursor = db.cursor()
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
affected_rows = cursor.rowcount
db.commit()
cursor.close()
logger.debug(f"Query executed. Affected rows: {affected_rows}")
return affected_rows
except Exception as e:
logger.error(f"Database update error: {e}\nQuery: {query}\nParams: {params}")
raise
def init_app(app):
"""Initialize database with Flask app"""
init_db(app)
app.teardown_appcontext(close_db)

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Models Package

View File

@@ -0,0 +1 @@
# Quality Module Package

View File

@@ -0,0 +1,341 @@
"""
Quality Module Business Logic
Handles database operations and business logic for the quality module
"""
from app.database import get_db
from flask import flash
import logging
logger = logging.getLogger(__name__)
def ensure_scanfg_orders_table():
"""Ensure the scanfg_orders table exists with proper schema"""
try:
db = get_db()
cursor = db.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS scanfg_orders (
Id INT AUTO_INCREMENT PRIMARY KEY,
operator_code VARCHAR(4) NOT NULL,
CP_full_code VARCHAR(15) NOT NULL,
OC1_code VARCHAR(4) NOT NULL,
OC2_code VARCHAR(4) NOT NULL,
quality_code TINYINT(3) NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
approved_quantity INT DEFAULT 0,
rejected_quantity INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cp (CP_full_code),
INDEX idx_date (date),
INDEX idx_operator (operator_code),
UNIQUE KEY unique_cp_date (CP_full_code, date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""")
db.commit()
logger.info("Table 'scanfg_orders' ready")
return True
except Exception as e:
logger.error(f"Error ensuring scanfg_orders table: {e}")
raise
def save_fg_scan(operator_code, cp_code, oc1_code, oc2_code, defect_code, date, time):
"""
Save a finish goods scan to the database
Args:
operator_code: Operator code (e.g., OP0001)
cp_code: CP full code (e.g., CP00002042-0001)
oc1_code: OC1 code (e.g., OC0001)
oc2_code: OC2 code (e.g., OC0002)
defect_code: Quality code / defect code (e.g., 000 for approved)
date: Scan date
time: Scan time
Returns:
tuple: (success: bool, approved_count: int, rejected_count: int)
"""
try:
db = get_db()
cursor = db.cursor()
# Insert a new entry - each scan is a separate record
insert_query = """
INSERT INTO scanfg_orders (operator_code, CP_full_code, OC1_code, OC2_code, quality_code, date, time)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_query, (operator_code, cp_code, oc1_code, oc2_code, defect_code, date, time))
db.commit()
# Get the quantities from the table for feedback
cursor.execute("""
SELECT COUNT(*) as total_scans,
SUM(CASE WHEN quality_code = '000' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN quality_code != '000' THEN 1 ELSE 0 END) as rejected_count
FROM scanfg_orders
WHERE CP_full_code = %s
""", (cp_code,))
result = cursor.fetchone()
approved_count = result[1] if result and result[1] else 0
rejected_count = result[2] if result and result[2] else 0
logger.info(f"Scan saved successfully: {cp_code} by {operator_code}")
return True, approved_count, rejected_count
except Exception as e:
logger.error(f"Error saving finish goods scan data: {e}")
raise
def get_latest_scans(limit=25):
"""
Fetch the latest scan records from the database
Args:
limit: Maximum number of scans to fetch (default: 25)
Returns:
list: List of scan dictionaries with calculated approved/rejected counts
"""
scan_groups = []
try:
db = get_db()
cursor = db.cursor()
# Get all scans ordered by date/time descending
cursor.execute("""
SELECT Id, operator_code, CP_full_code as cp_code, OC1_code as oc1_code, OC2_code as oc2_code,
quality_code as defect_code, date, time, created_at
FROM scanfg_orders
ORDER BY created_at DESC, Id DESC
LIMIT %s
""", (limit,))
results = cursor.fetchall()
if results:
# Convert result tuples to dictionaries for template access
columns = ['id', 'operator_code', 'cp_code', 'oc1_code', 'oc2_code', 'defect_code', 'date', 'time', 'created_at']
scan_groups = [dict(zip(columns, row)) for row in results]
# Now calculate approved and rejected counts for each CP code
for scan in scan_groups:
cp_code = scan['cp_code']
cursor.execute("""
SELECT
SUM(CASE WHEN quality_code = 0 OR quality_code = '000' THEN 1 ELSE 0 END) as approved_qty,
SUM(CASE WHEN quality_code != 0 AND quality_code != '000' THEN 1 ELSE 0 END) as rejected_qty
FROM scanfg_orders
WHERE CP_full_code = %s
""", (cp_code,))
count_result = cursor.fetchone()
scan['approved_qty'] = count_result[0] if count_result[0] else 0
scan['rejected_qty'] = count_result[1] if count_result[1] else 0
logger.info(f"Fetched {len(scan_groups)} scan records for display")
else:
logger.info("No scan records found in database")
except Exception as e:
logger.error(f"Error fetching finish goods scan data: {e}")
raise
return scan_groups
# Report Generation Functions
def get_fg_report(report_type, filter_date=None, start_date=None, end_date=None):
"""
Generate FG scan reports based on report type and filters
Args:
report_type: Type of report ('daily', 'select-day', 'date-range', '5-day',
'defects-today', 'defects-date', 'defects-range',
'defects-5day', 'all')
filter_date: Specific date filter (YYYY-MM-DD format)
start_date: Start date for range (YYYY-MM-DD format)
end_date: End date for range (YYYY-MM-DD format)
Returns:
dict: {
'success': bool,
'title': str,
'data': list of dicts,
'summary': {'approved_count': int, 'rejected_count': int}
}
"""
try:
db = get_db()
cursor = db.cursor()
# Build query based on report type
query = """
SELECT Id as id, operator_code, CP_full_code as cp_code, OC1_code as oc1_code,
OC2_code as oc2_code, quality_code as defect_code, date, time, created_at
FROM scanfg_orders
"""
params = []
title = "FG Scan Report"
is_defects_only = False
# Build WHERE clause based on report type
if report_type == 'daily':
title = "Today's FG Scans Report"
query += " WHERE DATE(date) = CURDATE()"
elif report_type == 'select-day':
title = f"FG Scans Report for {filter_date}"
query += " WHERE DATE(date) = %s"
params.append(filter_date)
elif report_type == 'date-range':
title = f"FG Scans Report ({start_date} to {end_date})"
query += " WHERE DATE(date) >= %s AND DATE(date) <= %s"
params.extend([start_date, end_date])
elif report_type == '5-day':
title = "Last 5 Days FG Scans Report"
query += " WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 4 DAY)"
elif report_type == 'defects-today':
title = "Today's FG Defects Report"
query += " WHERE DATE(date) = CURDATE() AND quality_code != '000' AND quality_code != 0"
is_defects_only = True
elif report_type == 'defects-date':
title = f"FG Defects Report for {filter_date}"
query += " WHERE DATE(date) = %s AND quality_code != '000' AND quality_code != 0"
params.append(filter_date)
is_defects_only = True
elif report_type == 'defects-range':
title = f"FG Defects Report ({start_date} to {end_date})"
query += " WHERE DATE(date) >= %s AND DATE(date) <= %s AND quality_code != '000' AND quality_code != 0"
params.extend([start_date, end_date])
is_defects_only = True
elif report_type == 'defects-5day':
title = "Last 5 Days FG Defects Report"
query += " WHERE DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 4 DAY) AND quality_code != '000' AND quality_code != 0"
is_defects_only = True
elif report_type == 'all':
title = "Complete FG Scans Database Report"
# No additional WHERE clause
# Add ORDER BY
query += " ORDER BY date DESC, time DESC"
# Execute query
cursor.execute(query, params)
results = cursor.fetchall()
# Convert to list of dicts and convert datetime objects to strings
columns = ['id', 'operator_code', 'cp_code', 'oc1_code', 'oc2_code', 'defect_code', 'date', 'time', 'created_at']
data = []
for row in results:
row_dict = dict(zip(columns, row))
# Convert date/time/datetime objects to strings for JSON serialization
for key in ['date', 'time', 'created_at']:
if row_dict[key] is not None:
row_dict[key] = str(row_dict[key])
data.append(row_dict)
# Calculate summary statistics
approved_count = sum(1 for row in data if row['defect_code'] == 0 or row['defect_code'] == '0' or str(row['defect_code']) == '000')
rejected_count = len(data) - approved_count
logger.info(f"Generated {report_type} report: {len(data)} records")
return {
'success': True,
'title': title,
'data': data,
'summary': {
'approved_count': approved_count,
'rejected_count': rejected_count
}
}
except Exception as e:
logger.error(f"Error generating FG report ({report_type}): {e}")
return {
'success': False,
'message': f"Error generating report: {str(e)}",
'data': [],
'summary': {'approved_count': 0, 'rejected_count': 0}
}
def get_daily_statistics():
"""
Get today's statistics for dashboard/summary
Returns:
dict: {'total': int, 'approved': int, 'rejected': int}
"""
try:
db = get_db()
cursor = db.cursor()
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN quality_code = '000' OR quality_code = 0 THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN quality_code != '000' AND quality_code != 0 THEN 1 ELSE 0 END) as rejected
FROM scanfg_orders
WHERE DATE(date) = CURDATE()
""")
result = cursor.fetchone()
if result:
return {
'total': result[0] or 0,
'approved': result[1] or 0,
'rejected': result[2] or 0
}
return {'total': 0, 'approved': 0, 'rejected': 0}
except Exception as e:
logger.error(f"Error getting daily statistics: {e}")
return {'total': 0, 'approved': 0, 'rejected': 0}
def get_cp_statistics(cp_code):
"""
Get statistics for a specific CP code
Args:
cp_code: The CP code to get statistics for
Returns:
dict: {'total': int, 'approved': int, 'rejected': int}
"""
try:
db = get_db()
cursor = db.cursor()
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN quality_code = '000' OR quality_code = 0 THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN quality_code != '000' AND quality_code != 0 THEN 1 ELSE 0 END) as rejected
FROM scanfg_orders
WHERE CP_full_code = %s
""", (cp_code,))
result = cursor.fetchone()
if result:
return {
'total': result[0] or 0,
'approved': result[1] or 0,
'rejected': result[2] or 0
}
return {'total': 0, 'approved': 0, 'rejected': 0}
except Exception as e:
logger.error(f"Error getting CP statistics for {cp_code}: {e}")
return {'total': 0, 'approved': 0, 'rejected': 0}

View File

@@ -0,0 +1,187 @@
"""
Quality Module Routes
"""
from flask import Blueprint, render_template, session, redirect, url_for, request, jsonify, flash
from app.modules.quality.quality import (
ensure_scanfg_orders_table,
save_fg_scan,
get_latest_scans,
get_fg_report,
get_daily_statistics,
get_cp_statistics
)
import logging
logger = logging.getLogger(__name__)
quality_bp = Blueprint('quality', __name__, url_prefix='/quality')
@quality_bp.route('/', methods=['GET'])
def quality_index():
"""Quality module main page"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
return render_template('modules/quality/index.html')
@quality_bp.route('/inspections', methods=['GET'])
def inspections():
"""View and manage quality inspections"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
return render_template('modules/quality/inspections.html')
@quality_bp.route('/reports', methods=['GET'])
def quality_reports():
"""Quality reports page - displays FG scan reports"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
# Ensure scanfg_orders table exists
ensure_scanfg_orders_table()
return render_template('modules/quality/fg_reports.html')
@quality_bp.route('/fg_scan', methods=['GET', 'POST'])
def fg_scan():
"""Finish goods scan page - POST saves scan data, GET displays form and latest scans"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
# Ensure scanfg_orders table exists
ensure_scanfg_orders_table()
if request.method == 'POST':
# Handle form submission
operator_code = request.form.get('operator_code')
cp_code = request.form.get('cp_code')
oc1_code = request.form.get('oc1_code')
oc2_code = request.form.get('oc2_code')
defect_code = request.form.get('defect_code')
date = request.form.get('date')
time = request.form.get('time')
try:
# Save the scan using business logic function
success, approved_count, rejected_count = save_fg_scan(
operator_code, cp_code, oc1_code, oc2_code, defect_code, date, time
)
# Flash appropriate message based on defect code
if int(defect_code) == 0 or defect_code == '000':
flash(f'✅ APPROVED scan recorded for {cp_code}. Total approved: {approved_count}')
else:
flash(f'❌ REJECTED scan recorded for {cp_code} (defect: {defect_code}). Total rejected: {rejected_count}')
except Exception as e:
logger.error(f"Error saving finish goods scan data: {e}")
flash(f"Error saving scan data: {str(e)}", 'error')
# Check if this is an AJAX request (for scan-to-boxes feature)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json':
# For AJAX requests, return JSON response without redirect
return jsonify({'success': True, 'message': 'Scan recorded successfully'})
# For normal form submissions, redirect to prevent form resubmission (POST-Redirect-GET pattern)
return redirect(url_for('quality.fg_scan'))
# GET request - Fetch and display latest scans
try:
scan_groups = get_latest_scans(limit=25)
except Exception as e:
logger.error(f"Error fetching latest scans: {e}")
flash(f"Error fetching scan data: {str(e)}", 'error')
scan_groups = []
return render_template('modules/quality/fg_scan.html', scan_groups=scan_groups)
# API Routes for AJAX requests
@quality_bp.route('/api/fg_report', methods=['POST'])
def api_fg_report():
"""
API endpoint for generating FG reports via AJAX
Expected JSON body:
{
'report_type': 'daily|select-day|date-range|5-day|defects-today|defects-date|defects-range|defects-5day|all',
'filter_date': 'YYYY-MM-DD' (optional, for select-day/defects-date),
'start_date': 'YYYY-MM-DD' (optional, for date-range/defects-range),
'end_date': 'YYYY-MM-DD' (optional, for date-range/defects-range)
}
"""
if 'user_id' not in session:
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
try:
data = request.get_json()
report_type = data.get('report_type')
filter_date = data.get('filter_date')
start_date = data.get('start_date')
end_date = data.get('end_date')
# Validate report type
valid_types = ['daily', 'select-day', 'date-range', '5-day', 'defects-today',
'defects-date', 'defects-range', 'defects-5day', 'all']
if report_type not in valid_types:
return jsonify({'success': False, 'message': 'Invalid report type'}), 400
# Generate report
result = get_fg_report(report_type, filter_date, start_date, end_date)
return jsonify(result)
except Exception as e:
logger.error(f"Error in API fg_report: {e}")
return jsonify({
'success': False,
'message': f'Error processing report: {str(e)}'
}), 500
@quality_bp.route('/api/daily_stats', methods=['GET'])
def api_daily_stats():
"""API endpoint for today's statistics"""
if 'user_id' not in session:
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
try:
stats = get_daily_statistics()
return jsonify({
'success': True,
'data': stats
})
except Exception as e:
logger.error(f"Error in API daily_stats: {e}")
return jsonify({
'success': False,
'message': f'Error fetching statistics: {str(e)}'
}), 500
@quality_bp.route('/api/cp_stats/<cp_code>', methods=['GET'])
def api_cp_stats(cp_code):
"""API endpoint for CP code statistics"""
if 'user_id' not in session:
return jsonify({'success': False, 'message': 'Unauthorized'}), 401
try:
stats = get_cp_statistics(cp_code)
return jsonify({
'success': True,
'data': stats
})
except Exception as e:
logger.error(f"Error in API cp_stats: {e}")
return jsonify({
'success': False,
'message': f'Error fetching CP statistics: {str(e)}'
}), 500

View File

@@ -0,0 +1 @@
# Settings Module Package

File diff suppressed because it is too large Load Diff

115
app/routes.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Main application routes (Login, Logout, Dashboard)
"""
from flask import (
Blueprint, render_template, request, session, redirect, url_for,
flash, current_app
)
from app.auth import authenticate_user, get_user_by_id
import logging
logger = logging.getLogger(__name__)
main_bp = Blueprint('main', __name__)
@main_bp.route('/', methods=['GET'])
def index():
"""Redirect to dashboard if logged in, otherwise to login"""
if 'user_id' in session:
return redirect(url_for('main.dashboard'))
return redirect(url_for('main.login'))
@main_bp.route('/login', methods=['GET', 'POST'])
def login():
"""Login page and authentication"""
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
if not username or not password:
flash('Username and password are required', 'error')
return render_template('login.html')
# Authenticate user
user = authenticate_user(username, password)
if user:
# Store user information in session
session.permanent = True
session['user_id'] = user['id']
session['username'] = user['username']
session['email'] = user['email']
session['role'] = user['role']
session['full_name'] = user['full_name']
logger.info(f"User {username} logged in successfully")
flash(f'Welcome, {user["full_name"]}!', 'success')
return redirect(url_for('main.dashboard'))
else:
flash('Invalid username or password', 'error')
logger.warning(f"Failed login attempt for user: {username}")
return render_template('login.html')
@main_bp.route('/dashboard', methods=['GET'])
def dashboard():
"""Main dashboard page"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
user_id = session.get('user_id')
user = get_user_by_id(user_id)
if not user:
session.clear()
flash('User session invalid', 'error')
return redirect(url_for('main.login'))
modules = [
{
'name': 'Quality Module',
'description': 'Manage quality checks and inspections',
'icon': 'fa-check-circle',
'color': 'primary',
'url': url_for('quality.quality_index')
},
{
'name': 'Settings',
'description': 'Configure application settings',
'icon': 'fa-cog',
'color': 'secondary',
'url': url_for('settings.settings_index')
}
]
return render_template('dashboard.html', user=user, modules=modules)
@main_bp.route('/logout', methods=['GET', 'POST'])
def logout():
"""Logout user"""
username = session.get('username', 'Unknown')
session.clear()
logger.info(f"User {username} logged out")
flash('You have been logged out successfully', 'success')
return redirect(url_for('main.login'))
@main_bp.route('/profile', methods=['GET'])
def profile():
"""User profile page"""
if 'user_id' not in session:
return redirect(url_for('main.login'))
user_id = session.get('user_id')
user = get_user_by_id(user_id)
if not user:
session.clear()
return redirect(url_for('main.login'))
return render_template('profile.html', user=user)

288
app/scheduler.py Normal file
View File

@@ -0,0 +1,288 @@
"""
Backup Scheduler Module
Handles automatic backup scheduling and execution
"""
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import logging
import os
import subprocess
from datetime import datetime, timedelta
import pymysql
logger = logging.getLogger(__name__)
# Database configuration
DB_HOST = os.getenv('DB_HOST', 'mariadb')
DB_PORT = int(os.getenv('DB_PORT', '3306'))
DB_USER = os.getenv('DB_USER', 'quality_user')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'quality_pass')
DB_NAME = os.getenv('DB_NAME', 'quality_db')
scheduler = None
def get_db():
"""Get database connection"""
try:
conn = pymysql.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME
)
return conn
except Exception as e:
logger.error(f"Database connection error: {e}")
return None
def execute_backup(schedule_id, schedule_name, backup_type='full'):
"""Execute a backup for a schedule"""
try:
logger.info(f"Executing scheduled backup: {schedule_name} (Type: {backup_type})")
backups_dir = '/app/data/backups'
if not os.path.exists(backups_dir):
os.makedirs(backups_dir)
# Create backup filename with timestamp
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
if backup_type == 'data_only':
filename = f'backup_data_{timestamp}.sql'
dump_cmd = f'mysqldump -h {DB_HOST} -u {DB_USER} -p{DB_PASSWORD} --skip-ssl --no-create-info {DB_NAME}'
else:
filename = f'backup_full_{timestamp}.sql'
dump_cmd = f'mysqldump -h {DB_HOST} -u {DB_USER} -p{DB_PASSWORD} --skip-ssl {DB_NAME}'
filepath = os.path.join(backups_dir, filename)
# Execute mysqldump
with open(filepath, 'w') as f:
result = subprocess.run(dump_cmd, shell=True, stdout=f, stderr=subprocess.PIPE)
if result.returncode != 0:
logger.error(f"Backup failed: {result.stderr.decode()}")
return False
# Update schedule last_run and next_run
conn = get_db()
if conn:
cursor = conn.cursor()
# Get schedule details
cursor.execute("""
SELECT frequency, day_of_week, time_of_day
FROM backup_schedules
WHERE id = %s
""", (schedule_id,))
result = cursor.fetchone()
if result:
frequency, day_of_week, time_of_day = result
# Calculate next run
now = datetime.now()
time_parts = str(time_of_day).split(':')
hour = int(time_parts[0])
minute = int(time_parts[1])
if frequency == 'daily':
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
next_run += timedelta(days=1)
else: # weekly
days_of_week = {
'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3,
'Friday': 4, 'Saturday': 5, 'Sunday': 6
}
target_day = days_of_week.get(day_of_week, 0)
current_day = now.weekday()
days_ahead = (target_day - current_day) % 7
if days_ahead == 0:
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
days_ahead = 7
else:
next_run = now + timedelta(days=days_ahead)
else:
next_run = now + timedelta(days=days_ahead)
next_run = next_run.replace(hour=hour, minute=minute, second=0, microsecond=0)
# Update schedule
cursor.execute("""
UPDATE backup_schedules
SET last_run = NOW(), next_run = %s
WHERE id = %s
""", (next_run, schedule_id))
conn.commit()
cursor.close()
conn.close()
# Check retention policy and delete old backups
cleanup_old_backups()
logger.info(f"Backup completed successfully: {filename}")
return True
except Exception as e:
logger.error(f"Error executing backup: {e}")
return False
def cleanup_old_backups():
"""Clean up old backups based on retention policy"""
try:
conn = get_db()
if not conn:
return
cursor = conn.cursor()
cursor.execute("""
SELECT setting_value FROM application_settings
WHERE setting_key = 'backup_retention_days'
""")
result = cursor.fetchone()
cursor.close()
retention_days = int(result[0]) if result else 30
# Calculate cutoff date
cutoff_date = datetime.now() - timedelta(days=retention_days)
# Get backups directory
backups_dir = '/app/data/backups'
if not os.path.exists(backups_dir):
conn.close()
return
# Delete old backups
deleted_count = 0
for filename in os.listdir(backups_dir):
filepath = os.path.join(backups_dir, filename)
if os.path.isfile(filepath):
file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
if file_mtime < cutoff_date:
try:
os.remove(filepath)
deleted_count += 1
logger.info(f"Deleted old backup: {filename}")
except Exception as e:
logger.warning(f"Failed to delete {filename}: {e}")
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} old backups")
conn.close()
except Exception as e:
logger.error(f"Error during cleanup: {e}")
def load_schedules():
"""Load all active schedules from database and register jobs"""
global scheduler
if scheduler is None:
return
try:
conn = get_db()
if not conn:
logger.error("Cannot connect to database for loading schedules")
return
cursor = conn.cursor(pymysql.cursors.DictCursor)
cursor.execute("""
SELECT id, schedule_name, frequency, day_of_week, time_of_day, backup_type, is_active
FROM backup_schedules
WHERE is_active = 1
""")
schedules = cursor.fetchall()
cursor.close()
conn.close()
# Remove existing jobs for schedules
for job in scheduler.get_jobs():
if job.name.startswith('backup_schedule_'):
scheduler.remove_job(job.id)
# Register new jobs
for schedule in schedules:
schedule_id = schedule['id']
schedule_name = schedule['schedule_name']
frequency = schedule['frequency']
day_of_week = schedule['day_of_week']
time_of_day = schedule['time_of_day']
backup_type = schedule['backup_type']
job_id = f"backup_schedule_{schedule_id}"
try:
time_parts = str(time_of_day).split(':')
hour = int(time_parts[0])
minute = int(time_parts[1])
if frequency == 'daily':
# Schedule daily at specific time
trigger = CronTrigger(hour=hour, minute=minute)
else: # weekly
# Map day name to cron day of week
days_map = {
'Monday': 'mon',
'Tuesday': 'tue',
'Wednesday': 'wed',
'Thursday': 'thu',
'Friday': 'fri',
'Saturday': 'sat',
'Sunday': 'sun'
}
cron_day = days_map.get(day_of_week, 'mon')
trigger = CronTrigger(day_of_week=cron_day, hour=hour, minute=minute)
scheduler.add_job(
execute_backup,
trigger,
id=job_id,
name=f"backup_schedule_{schedule_name}",
args=[schedule_id, schedule_name, backup_type],
replace_existing=True
)
logger.info(f"Registered schedule: {schedule_name} (ID: {schedule_id})")
except Exception as e:
logger.error(f"Failed to register schedule {schedule_name}: {e}")
except Exception as e:
logger.error(f"Error loading schedules: {e}")
def init_scheduler(app):
"""Initialize the scheduler with Flask app"""
global scheduler
if scheduler is None:
scheduler = BackgroundScheduler(daemon=True)
# Load schedules from database
with app.app_context():
load_schedules()
# Start scheduler
scheduler.start()
logger.info("Backup scheduler initialized and started")
def shutdown_scheduler():
"""Shutdown the scheduler"""
global scheduler
if scheduler:
scheduler.shutdown()
scheduler = None
logger.info("Backup scheduler shut down")

352
app/static/css/base.css Normal file
View File

@@ -0,0 +1,352 @@
/* Base Styles */
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--success-color: #48bb78;
--danger-color: #f56565;
--warning-color: #ed8936;
--info-color: #4299e1;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f7f9fc;
color: #333;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
height: 100%;
}
html {
height: 100%;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
/* Main Layout */
.main-content {
flex: 1;
display: block;
min-height: calc(100vh - 80px);
width: 100%;
padding-bottom: 120px;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
padding: 20px 0;
width: 100%;
z-index: 1000;
}
/* Navigation Bar */
.navbar-dark {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 700;
font-size: 20px;
letter-spacing: 0.5px;
}
.navbar-brand i {
margin-right: 8px;
}
/* Cards and Containers */
.card {
border: none;
border-radius: 12px;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Welcome Card */
.welcome-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.welcome-card h1 {
font-weight: 700;
margin-bottom: 10px;
}
/* Stat Cards */
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.stat-content h3 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: #333;
}
.stat-content p {
margin: 5px 0 0 0;
color: #999;
font-size: 14px;
}
/* Module Cards */
.module-card {
border: none;
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
}
.module-card:hover {
transform: translateY(-8px);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.15);
}
.module-icon {
font-size: 48px;
transition: all 0.3s ease;
}
.module-card:hover .module-icon {
transform: scale(1.1);
}
.module-card .card-title {
font-weight: 700;
color: #333;
margin-top: 10px;
}
.module-card .card-text {
font-size: 14px;
}
/* Alerts */
.alert {
border: none;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.alert-success {
background-color: #f0fdf4;
color: #166534;
border-left: 4px solid #22c55e;
}
.alert-danger {
background-color: #fef2f2;
color: #991b1b;
border-left: 4px solid #ef4444;
}
.alert-warning {
background-color: #fffbeb;
color: #92400e;
border-left: 4px solid #f59e0b;
}
.alert-info {
background-color: #f0f9ff;
color: #0c2d6b;
border-left: 4px solid #3b82f6;
}
/* Buttons */
.btn {
border-radius: 8px;
font-weight: 600;
padding: 10px 20px;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
color: white;
}
.btn-success {
background-color: #48bb78;
border: none;
color: white;
}
.btn-success:hover {
background-color: #38a169;
color: white;
}
/* Tables */
.table {
border-collapse: collapse;
}
.table thead th {
background-color: #f8f9fa;
font-weight: 600;
color: #555;
border: none;
padding: 15px;
}
.table tbody td {
padding: 15px;
border-bottom: 1px solid #e0e0e0;
}
.table tbody tr:hover {
background-color: #f9f9f9;
}
/* Forms */
.form-control, .form-select {
border-radius: 8px;
border: 1px solid #e0e0e0;
padding: 10px 15px;
font-size: 15px;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-label {
font-weight: 600;
color: #555;
margin-bottom: 8px;
}
/* List Groups */
.list-group-item {
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 8px;
padding: 12px 16px;
transition: all 0.3s ease;
}
.list-group-item:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.list-group-item:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.list-group-item.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
}
.list-group-item i {
margin-right: 10px;
width: 20px;
}
/* Hover Effects */
.hover-shadow {
transition: box-shadow 0.3s ease;
}
.hover-shadow:hover {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
min-height: calc(100vh - 60px);
padding-bottom: 60px;
}
.stat-card {
flex-direction: column;
text-align: center;
padding: 15px;
}
.module-card {
margin-bottom: 15px;
}
.navbar-brand {
font-size: 18px;
}
}
@media (max-width: 480px) {
.navbar {
padding: 0.5rem 0;
}
.container, .container-fluid {
padding: 10px;
}
.card {
border-radius: 8px;
}
.btn {
padding: 8px 16px;
font-size: 14px;
}
}

565
app/static/css/fg_scan.css Normal file
View File

@@ -0,0 +1,565 @@
/* FG Scan Module - Custom Styling with Theme Integration */
/* Page Container */
.scan-container {
width: 100%;
max-width: 100%;
margin: 0;
padding: 20px;
background-color: var(--bg-secondary);
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
min-height: calc(100vh - 100px);
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Card Base Styling */
.scan-form-card,
.scan-table-card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px var(--card-shadow);
padding: 20px;
transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
.scan-form-card h3,
.scan-table-card h3 {
margin: 0 0 15px 0;
font-size: 1.2em;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--primary-color);
padding-bottom: 10px;
transition: color 0.3s ease;
}
/* Scan Form Card */
.scan-form-card {
width: 500px;
max-width: 95%;
flex: 0 0 auto;
max-height: 750px;
overflow-y: auto;
padding: 15px;
}
.scan-form-card form {
display: grid;
grid-template-columns: 140px 1fr;
gap: 10px 12px;
align-items: center;
}
.scan-form-card label {
font-weight: 500;
font-size: 15px;
text-align: right;
padding-right: 8px;
color: var(--text-primary);
white-space: nowrap;
}
.scan-form-card input[type="text"],
.scan-form-card input[type="password"] {
padding: 8px 12px;
font-size: 15px;
border: 1px solid var(--input-border);
border-radius: 3px;
width: 100%;
box-sizing: border-box;
background-color: var(--input-bg);
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
.scan-form-card input[type="text"]:focus,
.scan-form-card input[type="password"]:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 5px var(--input-focus-shadow);
}
.form-buttons {
grid-column: 1 / -1;
display: flex;
gap: 10px;
justify-content: center;
margin-top: 15px;
}
.scan-form-card button[type="submit"],
.scan-form-card button[type="button"] {
padding: 10px 24px;
font-size: 15px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-submit {
background-color: var(--primary-color);
color: white;
}
.btn-submit:hover {
background-color: var(--secondary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-clear {
background-color: var(--warning-color);
color: white;
}
.btn-clear:hover {
background-color: #d97706;
transform: translateY(-2px);
}
.btn-secondary {
background-color: var(--info-color);
color: white;
padding: 10px 20px;
font-size: 14px;
}
.btn-secondary:hover {
background-color: #2563eb;
}
/* Form Options */
.form-options {
grid-column: 1 / -1;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin: 0;
color: var(--text-primary);
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
/* Quick Box Section */
.quick-box-section {
grid-column: 1 / -1;
margin-top: 15px;
}
.scan-form-card input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Scan Table Card */
.scan-table-card {
flex: 1 1 auto;
min-width: 600px;
max-width: 100%;
margin-top: 0;
}
.table-wrapper {
max-height: 600px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: 3px;
position: relative;
}
.scan-table {
width: 100%;
border-collapse: collapse;
margin-top: 0;
font-size: 12px;
}
.scan-table th,
.scan-table td {
border: 1px solid var(--border-color);
padding: 8px;
text-align: center;
vertical-align: middle;
color: var(--text-primary);
}
.scan-table th {
background-color: var(--bg-tertiary);
font-weight: bold;
position: sticky;
top: 0;
z-index: 5;
border-bottom: 2px solid var(--border-color);
}
.scan-table tbody tr:nth-child(even) {
background-color: var(--table-hover-bg);
}
.scan-table tbody tr:hover {
background-color: var(--bg-secondary);
}
.scan-table tbody td {
padding: 8px;
}
/* Responsive Design */
@media (max-width: 1200px) {
.scan-container {
flex-direction: column;
align-items: stretch;
}
.scan-form-card {
width: 100%;
max-width: 100%;
flex: 0 0 auto;
}
.scan-table-card {
flex: 0 0 auto;
min-width: auto;
}
}
@media (max-width: 768px) {
.scan-container {
padding: 10px;
gap: 15px;
}
.card {
padding: 15px;
}
.scan-form-card {
width: 100%;
}
.scan-form-card form {
grid-template-columns: 100%;
gap: 8px;
}
.scan-form-card label {
text-align: left;
padding-right: 0;
font-weight: 600;
}
.scan-table {
font-size: 11px;
}
.scan-table th,
.scan-table td {
padding: 5px;
}
}
/* Dark Mode Support using data-theme attribute */
[data-theme="dark"] .scan-container {
background-color: var(--bg-secondary);
}
[data-theme="dark"] .scan-form-card,
[data-theme="dark"] .scan-table-card {
background-color: var(--bg-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .scan-form-card h3,
[data-theme="dark"] .scan-table-card h3 {
color: var(--text-primary);
border-bottom-color: var(--primary-color);
}
[data-theme="dark"] .scan-form-card label {
color: var(--text-primary);
}
[data-theme="dark"] .scan-form-card input[type="text"],
[data-theme="dark"] .scan-form-card input[type="password"] {
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text-primary);
}
[data-theme="dark"] .scan-form-card input[type="text"]:focus,
[data-theme="dark"] .scan-form-card input[type="password"]:focus {
border-color: var(--input-focus-border);
box-shadow: 0 0 5px var(--input-focus-shadow);
}
[data-theme="dark"] .scan-table {
color: var(--text-primary);
}
[data-theme="dark"] .scan-table th {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .scan-table td {
border-color: var(--border-color);
color: var(--text-primary);
}
[data-theme="dark"] .scan-table tbody tr:nth-child(even) {
background-color: var(--table-hover-bg);
}
[data-theme="dark"] .scan-table tbody tr:hover {
background-color: var(--bg-secondary);
}
[data-theme="dark"] .table-wrapper {
border-color: var(--border-color);
}
[data-theme="dark"] .checkbox-label {
color: var(--text-primary);
}
/* Error Messages */
.error-message {
color: var(--danger-color);
font-size: 0.9em;
margin-top: 3px;
display: none;
grid-column: 2 / -1;
padding: 5px 0;
font-weight: 500;
}
.error-message.show {
display: block;
}
/* Floating Help Button */
.floating-help-btn {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #17a2b8, #138496);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.floating-help-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
.floating-help-btn a {
color: white;
text-decoration: none;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
body.dark-mode .floating-help-btn {
background: linear-gradient(135deg, #0dcaf0, #0aa2c0);
}
/* Notifications */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 4px;
color: white;
font-weight: bold;
z-index: 10001;
box-shadow: 0 4px 12px var(--card-shadow);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-success {
background-color: var(--success-color);
}
.notification-error {
background-color: var(--danger-color);
}
.notification-warning {
background-color: var(--warning-color);
color: #333;
}
.notification-info {
background-color: var(--info-color);
}
/* Box Modal Styling */
.box-modal {
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.box-modal-content {
background-color: var(--bg-primary);
padding: 0;
border: 1px solid var(--border-color);
width: 500px;
max-width: 90%;
border-radius: 8px;
box-shadow: 0 4px 20px var(--card-shadow-hover);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 15px 20px;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
color: white;
border: none;
padding: 0;
font-size: 1.1em;
}
.modal-close {
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
color: white;
transition: color 0.2s;
background: none;
border: none;
padding: 0;
}
.modal-close:hover {
color: #ccc;
}
.modal-body {
padding: 20px;
color: var(--text-primary);
}
.modal-body label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.modal-body input {
width: 100%;
padding: 8px;
margin-bottom: 15px;
border: 1px solid var(--input-border);
border-radius: 4px;
background-color: var(--input-bg);
color: var(--text-primary);
box-sizing: border-box;
}
.modal-body input:focus {
outline: none;
border-color: var(--input-focus-border);
box-shadow: 0 0 5px var(--input-focus-shadow);
}
.modal-footer {
padding: 15px 20px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-footer button {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
/* Additional form styling for consistency */
.form-centered {
margin: 0 auto;
}
input[type="text"]:invalid {
border-color: #dc3545;
}
input[type="text"]:valid {
border-color: #28a745;
}

143
app/static/css/login.css Normal file
View File

@@ -0,0 +1,143 @@
/* Login Page Styles */
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-container {
width: 100%;
max-width: 420px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}
.login-header i {
font-size: 48px;
margin-bottom: 15px;
}
.login-header h1 {
margin: 10px 0;
font-size: 28px;
font-weight: 700;
}
.login-form {
padding: 40px;
}
.login-form h2 {
margin: 0 0 30px 0;
font-size: 24px;
color: #333;
text-align: center;
}
.form-label {
font-weight: 600;
color: #555;
margin-bottom: 8px;
}
.form-control, .input-group-text {
border-radius: 8px;
border: 1px solid #e0e0e0;
padding: 12px;
font-size: 15px;
transition: all 0.3s ease;
}
.form-control {
border-left: none;
}
.input-group-text {
background: #f5f5f5;
border-right: none;
color: #667eea;
}
.input-group:focus-within .form-control {
border-color: #667eea;
box-shadow: none;
}
.input-group:focus-within .input-group-text {
border-color: #667eea;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 12px;
font-weight: 600;
font-size: 16px;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
}
.login-footer {
text-align: center;
}
/* Responsive Design */
@media (max-width: 480px) {
.login-card {
border-radius: 0;
}
.login-form {
padding: 30px 20px;
}
.login-header {
padding: 30px 20px;
}
.login-header i {
font-size: 36px;
}
.login-header h1 {
font-size: 22px;
}
}

68
app/static/css/scan.css Normal file
View File

@@ -0,0 +1,68 @@
/* Scan Module Specific Styles */
.scan-form-card {
width: 500px;
max-width: 500px;
margin: 0 auto 20px auto;
max-height: 700px;
overflow-y: auto;
padding: 12px 18px 12px 18px;
}
.scan-form-card h3 {
margin-top: 0;
margin-bottom: 8px;
}
.scan-form-card form {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
align-items: center;
}
.scan-form-card label {
font-weight: 500;
margin-bottom: 0;
margin-top: 0;
font-size: 13px;
text-align: left;
padding-right: 8px;
}
.scan-form-card input[type="text"] {
padding: 5px 10px;
font-size: 13px;
margin-bottom: 0;
}
.scan-form-card button[type="submit"],
.scan-form-card button[type="button"] {
grid-column: 1 / -1;
}
.scan-form-card > form > div {
grid-column: 1 / -1;
}
.scan-table-card {
overflow-x: auto;
}
.scan-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
font-size: 11px;
}
.scan-table th, .scan-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: center;
}
.scan-table th {
background-color: #f4f4f4;
font-weight: bold;
}

480
app/static/css/theme.css Normal file
View File

@@ -0,0 +1,480 @@
/* Light Mode (Default) - CSS Variables */
:root {
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--bg-primary: #ffffff;
--bg-secondary: #f7f9fc;
--bg-tertiary: #f8f9fa;
--border-color: #dee2e6;
--card-shadow: rgba(0, 0, 0, 0.08);
--card-shadow-hover: rgba(0, 0, 0, 0.12);
--navbar-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--welcome-card-color: #ffffff;
--table-hover-bg: #f9f9f9;
--input-bg: #ffffff;
--input-border: #e0e0e0;
--input-focus-border: #667eea;
--input-focus-shadow: rgba(102, 126, 234, 0.1);
}
/* Dark Mode - Override CSS Variables */
[data-theme="dark"] {
--text-primary: #e8e8e8;
--text-secondary: #b8b8b8;
--text-muted: #888888;
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3a3a3a;
--border-color: #444444;
--card-shadow: rgba(0, 0, 0, 0.3);
--card-shadow-hover: rgba(0, 0, 0, 0.5);
--navbar-bg: linear-gradient(135deg, #4a5f8f 0%, #5a3f7a 100%);
--welcome-card-color: #e8e8e8;
--table-hover-bg: #2d2d2d;
--input-bg: #2d2d2d;
--input-border: #444444;
--input-focus-border: #7b8fd9;
--input-focus-shadow: rgba(123, 143, 217, 0.2);
}
/* Apply Theme Variables to Elements */
body {
background-color: var(--bg-secondary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.footer {
background-color: var(--bg-tertiary);
border-top-color: var(--border-color);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.navbar-dark {
background: var(--navbar-bg) !important;
transition: background 0.3s ease;
}
.welcome-card {
color: var(--welcome-card-color);
transition: color 0.3s ease;
}
.welcome-card h1 {
color: #1a1a2e;
transition: color 0.3s ease;
font-weight: 700;
}
.welcome-card p {
color: rgba(255, 255, 255, 0.95);
}
[data-theme="dark"] .welcome-card h1 {
color: #ffffff;
}
.stat-card {
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 2px 8px var(--card-shadow);
transition: background 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
box-shadow: 0 8px 20px var(--card-shadow-hover);
}
.stat-content h3 {
color: var(--text-primary);
transition: color 0.3s ease;
}
.stat-content p {
color: var(--text-secondary);
transition: color 0.3s ease;
}
.card {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--border-color);
box-shadow: 0 2px 8px var(--card-shadow);
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 24px var(--card-shadow-hover);
}
.card-header {
background-color: var(--bg-tertiary);
border-bottom-color: var(--border-color);
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
.card-header.bg-light {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
.card-header h1,
.card-header h2,
.card-header h3,
.card-header h4,
.card-header h5,
.card-header h6 {
color: var(--text-primary);
transition: color 0.3s ease;
margin-bottom: 0;
}
.card-body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card-footer {
background-color: var(--bg-tertiary);
border-top-color: var(--border-color);
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
/* Ensure good contrast in dark mode for card content */
[data-theme="dark"] .card {
border-color: #444444;
}
[data-theme="dark"] .card-header {
background-color: #3a3a3a;
color: #e8e8e8;
}
[data-theme="dark"] .card-header h1,
[data-theme="dark"] .card-header h2,
[data-theme="dark"] .card-header h3,
[data-theme="dark"] .card-header h4,
[data-theme="dark"] .card-header h5,
[data-theme="dark"] .card-header h6 {
color: #e8e8e8;
}
[data-theme="dark"] .card-body {
background-color: #1e1e1e;
}
.module-card {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.module-card .card-title {
color: var(--text-primary);
transition: color 0.3s ease;
}
.module-card .card-text {
color: var(--text-secondary);
transition: color 0.3s ease;
}
.table {
color: var(--text-primary);
background-color: var(--bg-secondary);
transition: color 0.3s ease, background-color 0.3s ease;
}
.table thead th {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
border-bottom: 2px solid var(--border-color) !important;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
font-weight: 600;
}
.table tbody {
background-color: var(--bg-secondary);
transition: background-color 0.3s ease;
}
.table tbody td {
background-color: var(--bg-secondary);
border-bottom-color: var(--border-color);
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
.table tbody tr {
background-color: var(--bg-secondary);
transition: background-color 0.3s ease;
}
.table tbody tr:hover {
background-color: var(--table-hover-bg) !important;
transition: background-color 0.3s ease;
}
/* Empty table state */
.table tbody td.text-center {
color: var(--text-secondary);
}
.table tbody tr td .text-muted {
color: var(--text-secondary) !important;
}
/* Badge styling for tables */
.table .badge {
transition: all 0.3s ease;
}
.form-control,
.form-select {
background-color: var(--input-bg);
color: var(--text-primary);
border-color: var(--input-border);
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
.form-control::placeholder {
color: var(--text-muted);
transition: color 0.3s ease;
}
.form-control:disabled,
.form-select:disabled {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border-color: var(--input-border);
}
.form-control:focus,
.form-select:focus {
background-color: var(--input-bg);
color: var(--text-primary);
border-color: var(--input-focus-border);
box-shadow: 0 0 0 3px var(--input-focus-shadow);
}
.form-label {
color: var(--text-primary);
transition: color 0.3s ease;
font-weight: 600;
}
.form-text {
color: var(--text-secondary);
transition: color 0.3s ease;
}
.list-group-item {
background-color: var(--bg-primary);
color: var(--text-primary);
border-color: var(--border-color);
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
.list-group-item:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.list-group-item.active {
background-color: #667eea;
border-color: #667eea;
color: white;
}
[data-theme="dark"] .list-group-item.active {
background-color: #7b8fd9;
border-color: #7b8fd9;
}
/* Alert Styling */
.alert-success {
background-color: rgba(34, 197, 94, 0.15);
color: #22c55e;
border-left-color: #22c55e;
}
[data-theme="dark"] .alert-success {
background-color: rgba(34, 197, 94, 0.2);
color: #86efac;
border-left-color: #86efac;
}
.alert-danger {
background-color: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-left-color: #ef4444;
}
[data-theme="dark"] .alert-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border-left-color: #fca5a5;
}
.alert-warning {
background-color: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border-left-color: #f59e0b;
}
[data-theme="dark"] .alert-warning {
background-color: rgba(245, 158, 11, 0.2);
color: #fcd34d;
border-left-color: #fcd34d;
}
.alert-info {
background-color: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border-left-color: #3b82f6;
}
[data-theme="dark"] .alert-info {
background-color: rgba(59, 130, 246, 0.2);
color: #93c5fd;
border-left-color: #93c5fd;
}
/* Theme Toggle Button */
.theme-toggle {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 5px 10px;
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: rotate(20deg);
}
.theme-toggle:focus {
outline: none;
}
/* Dropdown Styling for Dark Mode */
[data-theme="dark"] .dropdown-menu {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
[data-theme="dark"] .dropdown-item {
color: var(--text-primary);
}
[data-theme="dark"] .dropdown-item:hover,
[data-theme="dark"] .dropdown-item:focus {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
[data-theme="dark"] .dropdown-divider {
border-color: var(--border-color);
}
/* Close Button for Dark Mode */
[data-theme="dark"] .btn-close {
filter: invert(1);
}
/* Empty State Message */
.empty-state-message {
color: var(--text-secondary);
transition: color 0.3s ease;
font-size: 16px;
}
.empty-state-message i {
opacity: 0.6;
transition: opacity 0.3s ease;
}
/* Text Utilities */
.text-muted {
color: var(--text-muted) !important;
transition: color 0.3s ease;
}
.text-secondary {
color: var(--text-secondary) !important;
transition: color 0.3s ease;
}
.text-primary {
color: var(--text-primary) !important;
transition: color 0.3s ease;
}
/* Code blocks and inline code */
code,
pre {
background-color: var(--input-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 2px 6px;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
pre {
padding: 10px;
overflow-x: auto;
}
pre code {
padding: 0;
border: none;
background-color: transparent;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
transition: color 0.3s ease;
}
/* Bootstrap Utility Classes - Dark Mode Overrides */
.bg-light {
background-color: var(--bg-secondary) !important;
transition: background-color 0.3s ease;
}
[data-theme="dark"] .bg-light {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
/* Ensure form elements in bg-light sections have proper contrast */
.bg-light .form-control,
.bg-light .form-select {
background-color: var(--input-bg);
color: var(--text-primary);
border-color: var(--input-border);
}
[data-theme="dark"] .bg-light .form-control,
[data-theme="dark"] .bg-light .form-select {
background-color: var(--input-bg);
color: var(--text-primary);
border-color: var(--input-border);
}
.bg-light .form-label {
color: var(--text-primary);
}
[data-theme="dark"] .bg-light .form-label {
color: var(--text-primary);
}

137
app/static/js/base.js Normal file
View File

@@ -0,0 +1,137 @@
/* Base JavaScript Utilities */
document.addEventListener('DOMContentLoaded', function() {
// Initialize tooltips and popovers
initializeBootstrap();
// Setup flash message auto-close
setupFlashMessages();
// Setup theme toggle if available
setupThemeToggle();
});
/**
* Initialize Bootstrap tooltips and popovers
*/
function initializeBootstrap() {
// Initialize all tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize all popovers
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
}
/**
* Auto-close flash messages after 5 seconds
*/
function setupFlashMessages() {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(function(alert) {
setTimeout(function() {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}, 5000);
});
}
/**
* Setup theme toggle functionality
*/
function setupThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
if (!themeToggle) return;
const currentTheme = localStorage.getItem('theme') || 'light';
applyTheme(currentTheme);
themeToggle.addEventListener('click', function() {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
localStorage.setItem('theme', newTheme);
});
}
/**
* Apply theme to document
*/
function applyTheme(theme) {
const html = document.documentElement;
if (theme === 'dark') {
html.setAttribute('data-bs-theme', 'dark');
} else {
html.removeAttribute('data-bs-theme');
}
}
/**
* Display a toast notification
*/
function showToast(message, type = 'info') {
const toastHtml = `
<div class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
const toastContainer = document.querySelector('.toast-container') || createToastContainer();
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastElement = toastContainer.lastElementChild;
const toast = new bootstrap.Toast(toastElement);
toast.show();
toastElement.addEventListener('hidden.bs.toast', function() {
toastElement.remove();
});
}
/**
* Create toast container if it doesn't exist
*/
function createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
/**
* Debounce function for input handlers
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Throttle function for scroll/resize handlers
*/
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}

94
app/static/js/theme.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* Theme Toggle - Light/Dark Mode
* Persists user preference in localStorage
*/
class ThemeToggle {
constructor() {
this.STORAGE_KEY = 'quality-app-theme';
this.DARK_THEME = 'dark';
this.LIGHT_THEME = 'light';
this.init();
}
init() {
// Load saved theme or default to light
const savedTheme = localStorage.getItem(this.STORAGE_KEY) || this.LIGHT_THEME;
this.setTheme(savedTheme);
// Setup toggle button listener
this.setupToggleButton();
console.log('Theme initialized:', savedTheme);
}
setTheme(theme) {
const isDark = theme === this.DARK_THEME;
if (isDark) {
document.body.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-theme', 'dark');
localStorage.setItem(this.STORAGE_KEY, this.DARK_THEME);
console.log('Dark theme set');
} else {
document.body.setAttribute('data-theme', 'light');
document.documentElement.setAttribute('data-theme', 'light');
localStorage.setItem(this.STORAGE_KEY, this.LIGHT_THEME);
console.log('Light theme set');
}
// Update toggle button icon
this.updateToggleIcon(isDark);
}
getCurrentTheme() {
return document.body.getAttribute('data-theme') || this.LIGHT_THEME;
}
toggleTheme() {
const currentTheme = this.getCurrentTheme();
const newTheme = currentTheme === this.DARK_THEME ? this.LIGHT_THEME : this.DARK_THEME;
console.log('Toggling from', currentTheme, 'to', newTheme);
this.setTheme(newTheme);
}
setupToggleButton() {
const toggleBtn = document.getElementById('themeToggleBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.preventDefault();
this.toggleTheme();
});
console.log('Theme toggle button setup complete');
} else {
console.error('Theme toggle button not found');
}
}
updateToggleIcon(isDark) {
const toggleBtn = document.getElementById('themeToggleBtn');
if (toggleBtn) {
const icon = toggleBtn.querySelector('i');
if (icon) {
if (isDark) {
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
toggleBtn.setAttribute('title', 'Switch to Light Mode');
} else {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
toggleBtn.setAttribute('title', 'Switch to Dark Mode');
}
}
}
}
}
// Initialize theme toggle when DOM is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
new ThemeToggle();
});
} else {
new ThemeToggle();
}

120
app/templates/base.html Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Base CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
<!-- Theme CSS (Light/Dark Mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation Header (hidden on login page) -->
{% if request.endpoint != 'main.login' %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-chart-bar"></i> {{ app_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-home"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('quality.quality_index') }}">
<i class="fas fa-check-circle"></i> Quality
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('settings.settings_index') }}">
<i class="fas fa-cog"></i> Settings
</a>
</li>
<li class="nav-item">
<button id="themeToggleBtn" class="theme-toggle" type="button" title="Switch to Dark Mode">
<i class="fas fa-moon"></i>
</button>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> {{ session.get('full_name', 'User') }}
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li>
<a class="dropdown-item" href="{{ url_for('main.profile') }}">
<i class="fas fa-user-circle"></i> Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('main.logout') }}">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show m-3" role="alert">
{% if category == 'error' %}
<i class="fas fa-exclamation-circle"></i>
{% elif category == 'success' %}
<i class="fas fa-check-circle"></i>
{% elif category == 'warning' %}
<i class="fas fa-exclamation-triangle"></i>
{% else %}
<i class="fas fa-info-circle"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Main Content -->
<main class="main-content">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
{% if request.endpoint != 'main.login' %}
<footer class="footer mt-5 py-3 bg-light text-center">
<div class="container">
<span class="text-muted">{{ app_name }} &copy; 2026. All rights reserved.</span>
</div>
</footer>
{% endif %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Theme Toggle JS -->
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
<!-- Base JS -->
<script src="{{ url_for('static', filename='js/base.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Dashboard - Quality App v2{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<!-- Welcome Section -->
<div class="row mb-5">
<div class="col-12">
<div class="welcome-card bg-gradient p-5 rounded">
<h1 class="mb-2">Welcome, {{ user.full_name }}!</h1>
<p class="text-muted mb-0">
<i class="fas fa-calendar-alt"></i>
Today is {{ now().strftime('%A, %B %d, %Y') }}
</p>
</div>
</div>
</div>
<!-- Quick Stats Section -->
<div class="row mb-5">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon bg-primary">
<i class="fas fa-chart-line"></i>
</div>
<div class="stat-content">
<h3>0</h3>
<p>Total Inspections</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon bg-success">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-content">
<h3>0</h3>
<p>Passed</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon bg-warning">
<i class="fas fa-exclamation-circle"></i>
</div>
<div class="stat-content">
<h3>0</h3>
<p>Warnings</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-icon bg-danger">
<i class="fas fa-times-circle"></i>
</div>
<div class="stat-content">
<h3>0</h3>
<p>Failed</p>
</div>
</div>
</div>
</div>
<!-- Modules Section -->
<div class="row">
<div class="col-12 mb-4">
<h2 class="mb-4">
<i class="fas fa-th"></i> Available Modules
</h2>
</div>
</div>
<div class="row">
{% for module in modules %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="module-card card h-100 shadow-sm hover-shadow">
<div class="card-body text-center">
<div class="module-icon mb-3">
<i class="fas {{ module.icon }} text-{{ module.color }}"></i>
</div>
<h5 class="card-title">{{ module.name }}</h5>
<p class="card-text text-muted">{{ module.description }}</p>
<a href="{{ module.url }}" class="btn btn-{{ module.color }} btn-sm">
<i class="fas fa-arrow-right"></i> Open Module
</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Recent Activity Section -->
<div class="row mt-5">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light border-bottom">
<h5 class="card-title mb-0">
<i class="fas fa-history"></i> Recent Activity
</h5>
</div>
<div class="card-body">
<p class="text-muted text-center py-4">
<i class="fas fa-inbox"></i> No recent activity
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Access Forbidden - Quality App v2{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row">
<div class="col-md-8 mx-auto text-center">
<h1 class="display-1 text-danger mb-4">403</h1>
<h2 class="mb-3">Access Forbidden</h2>
<p class="text-muted mb-4">
You do not have permission to access this resource.
</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Back to Dashboard
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Page Not Found - Quality App v2{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row">
<div class="col-md-8 mx-auto text-center">
<h1 class="display-1 text-danger mb-4">404</h1>
<h2 class="mb-3">Page Not Found</h2>
<p class="text-muted mb-4">
The page you are looking for could not be found. It may have been moved or deleted.
</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Back to Dashboard
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Server Error - Quality App v2{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row">
<div class="col-md-8 mx-auto text-center">
<h1 class="display-1 text-danger mb-4">500</h1>
<h2 class="mb-3">Internal Server Error</h2>
<p class="text-muted mb-4">
An unexpected error occurred. Please try again later or contact the administrator.
</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Back to Dashboard
</a>
</div>
</div>
</div>
{% endblock %}

71
app/templates/login.html Normal file
View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Login - Quality App v2{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
{% endblock %}
{% block content %}
<div class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<i class="fas fa-chart-bar"></i>
<h1>Quality App v2</h1>
</div>
<div class="login-form">
<h2>Sign In</h2>
<form method="POST" action="{{ url_for('main.login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-user"></i>
</span>
<input
type="text"
class="form-control"
id="username"
name="username"
placeholder="Enter your username"
required
autofocus
>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-lock"></i>
</span>
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="Enter your password"
required
>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-sign-in-alt"></i> Sign In
</button>
</form>
<div class="login-footer">
<p class="text-muted text-center mt-3">
<small>© 2026 Quality App v2. All rights reserved.</small>
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,866 @@
{% extends "base.html" %}
{% block title %}FG Scan Reports - {{ app_name }}{% endblock %}
{% block extra_css %}
<style>
/* FG Reports Page Styles */
.fg-reports-container {
max-width: 1400px;
margin: 2rem auto;
padding: 0 1rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
color: var(--text-primary);
font-weight: 600;
margin-bottom: 0.5rem;
}
.page-header p {
color: var(--text-secondary);
margin: 0;
}
/* Query Section */
.query-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.query-card h3 {
color: var(--text-primary);
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.query-card h3 i {
color: var(--primary-color);
}
.reports-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.report-option {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.report-option:hover {
border-color: var(--primary-color);
background: var(--bg-secondary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.report-option.active {
border-color: var(--primary-color);
background: var(--primary-color);
color: white;
}
.report-option.active .report-icon {
color: white;
}
.report-icon {
font-size: 1.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
transition: color 0.3s ease;
}
.report-option-title {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.report-option.active .report-option-title {
color: white;
}
.report-option-desc {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
}
.report-option.active .report-option-desc {
color: rgba(255, 255, 255, 0.9);
}
/* Filter Section */
.filter-section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
display: none;
}
.filter-section.active {
display: block;
}
.filter-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
color: var(--text-primary);
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.filter-group input,
.filter-group select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.6rem 0.8rem;
border-radius: 4px;
font-size: 0.95rem;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: var(--primary-color);
background: var(--bg-secondary);
color: var(--text-primary);
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.btn-query {
background: var(--primary-color);
color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-query:hover {
background: var(--primary-color-dark);
transform: translateY(-1px);
}
.btn-query:disabled {
background: var(--text-disabled);
cursor: not-allowed;
transform: none;
}
.btn-reset {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-reset:hover {
background: var(--bg-secondary);
}
/* Data Display Section */
.data-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.data-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.data-card h3 {
color: var(--text-primary);
font-weight: 600;
margin: 0;
flex: 1;
}
.data-stats {
display: flex;
gap: 1rem;
margin-left: 1rem;
}
.stat-box {
background: var(--bg-primary);
padding: 0.75rem 1rem;
border-radius: 4px;
border-left: 3px solid var(--primary-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.stat-value {
color: var(--text-primary);
font-weight: 600;
font-size: 1.1rem;
}
.export-section {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-export {
background: var(--success-color);
color: white;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.btn-export:hover {
background: var(--success-color-dark);
transform: translateY(-1px);
}
.btn-export:disabled {
background: var(--text-disabled);
cursor: not-allowed;
}
/* Table Styles */
.report-table-container {
overflow-x: auto;
}
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.report-table thead {
background: var(--bg-primary);
border-bottom: 2px solid var(--border-color);
position: sticky;
top: 0;
}
.report-table th {
color: var(--text-primary);
padding: 1rem 0.8rem;
text-align: left;
font-weight: 600;
white-space: nowrap;
}
.report-table td {
color: var(--text-primary);
padding: 0.8rem;
border-bottom: 1px solid var(--border-color);
}
.report-table tbody tr:hover {
background: var(--bg-primary);
}
.status-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-approved {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
.status-rejected {
background: rgba(244, 67, 54, 0.2);
color: #F44336;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h4 {
color: var(--text-primary);
margin-bottom: 0.5rem;
}
/* Loading State */
.loading-spinner {
display: none;
text-align: center;
padding: 2rem;
}
.loading-spinner.active {
display: block;
}
.spinner {
border: 4px solid var(--bg-primary);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.spinner-text {
color: var(--text-secondary);
}
/* Success Message */
.success-message {
display: none;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #4CAF50;
padding: 0.8rem 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.success-message.active {
display: flex;
}
.success-message i {
font-size: 1.1rem;
}
/* Responsive */
@media (max-width: 768px) {
.reports-grid {
grid-template-columns: 1fr;
}
.filter-row {
grid-template-columns: 1fr;
}
.data-stats {
flex-direction: column;
gap: 0.5rem;
}
.button-row {
flex-direction: column;
}
.btn-query, .btn-reset {
width: 100%;
}
.data-card-header {
flex-direction: column;
align-items: flex-start;
}
.data-stats {
margin-left: 0;
width: 100%;
}
.report-table {
font-size: 0.85rem;
}
.report-table th,
.report-table td {
padding: 0.6rem 0.4rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="fg-reports-container">
<!-- Page Header -->
<div class="page-header">
<h1><i class="fas fa-file-alt"></i> FG Scan Reports</h1>
<p>Generate and export quality reports for finished goods scans</p>
</div>
<!-- Query Section -->
<div class="query-card">
<h3><i class="fas fa-filter"></i> Select Report Type</h3>
<div class="reports-grid">
<!-- Daily Report -->
<div class="report-option" data-report="daily">
<div class="report-icon"><i class="fas fa-calendar-day"></i></div>
<div class="report-option-title">Today's Report</div>
<div class="report-option-desc">All scans from today</div>
</div>
<!-- Select Day Report -->
<div class="report-option" data-report="select-day">
<div class="report-icon"><i class="fas fa-calendar"></i></div>
<div class="report-option-title">Select Day</div>
<div class="report-option-desc">Choose a specific date</div>
</div>
<!-- Date Range Report -->
<div class="report-option" data-report="date-range">
<div class="report-icon"><i class="fas fa-calendar-alt"></i></div>
<div class="report-option-title">Date Range</div>
<div class="report-option-desc">Custom date range</div>
</div>
<!-- 5-Day Report -->
<div class="report-option" data-report="5-day">
<div class="report-icon"><i class="fas fa-chart-line"></i></div>
<div class="report-option-title">Last 5 Days</div>
<div class="report-option-desc">Last 5 days of scans</div>
</div>
<!-- Defects Today -->
<div class="report-option" data-report="defects-today">
<div class="report-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="report-option-title">Defects Today</div>
<div class="report-option-desc">Rejected scans today</div>
</div>
<!-- Defects by Date -->
<div class="report-option" data-report="defects-date">
<div class="report-icon"><i class="fas fa-search"></i></div>
<div class="report-option-title">Defects by Date</div>
<div class="report-option-desc">Select date for defects</div>
</div>
<!-- Defects by Range -->
<div class="report-option" data-report="defects-range">
<div class="report-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="report-option-title">Defects Range</div>
<div class="report-option-desc">Date range for defects</div>
</div>
<!-- Defects 5-Day -->
<div class="report-option" data-report="defects-5day">
<div class="report-icon"><i class="fas fa-ban"></i></div>
<div class="report-option-title">Defects 5 Days</div>
<div class="report-option-desc">Last 5 days defects</div>
</div>
<!-- Complete Database -->
<div class="report-option" data-report="all">
<div class="report-icon"><i class="fas fa-database"></i></div>
<div class="report-option-title">All Data</div>
<div class="report-option-desc">Complete database</div>
</div>
</div>
<!-- Filter Section (appears based on report type) -->
<div class="filter-section" id="filterSection">
<div id="filterContent"></div>
</div>
</div>
<!-- Data Display Section -->
<div class="data-card">
<div class="data-card-header">
<h3 id="reportTitle">Select a report to view data</h3>
<div class="data-stats" id="dataStats" style="display: none;">
<div class="stat-box">
<div class="stat-label">Total Scans</div>
<div class="stat-value" id="statTotal">0</div>
</div>
<div class="stat-box">
<div class="stat-label">Approved</div>
<div class="stat-value" id="statApproved" style="border-left-color: #4CAF50;">0</div>
</div>
<div class="stat-box">
<div class="stat-label">Rejected</div>
<div class="stat-value" id="statRejected" style="border-left-color: #F44336;">0</div>
</div>
</div>
<div class="export-section" id="exportSection" style="display: none;">
<button class="btn-export" id="exportExcelBtn">
<i class="fas fa-file-excel"></i> Export Excel
</button>
<button class="btn-export" id="exportCsvBtn">
<i class="fas fa-file-csv"></i> Export CSV
</button>
</div>
</div>
<div class="success-message" id="successMessage">
<i class="fas fa-check-circle"></i>
<span id="successText">Report generated successfully</span>
</div>
<div class="loading-spinner" id="loadingSpinner">
<div class="spinner"></div>
<div class="spinner-text">Generating report...</div>
</div>
<div class="report-table-container">
<table class="report-table" id="reportTable" style="display: none;">
<thead>
<tr id="tableHead"></tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div class="empty-state" id="emptyState">
<i class="fas fa-inbox"></i>
<h4>No data to display</h4>
<p>Select a report type and filters above to view data</p>
</div>
</div>
</div>
</div>
<!-- Import SheetJS for Excel export -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<!-- Report Script -->
<script>
// Report generation logic
class FGReportManager {
constructor() {
this.currentReport = null;
this.currentData = [];
this.initializeEventListeners();
}
initializeEventListeners() {
// Report option selection
document.querySelectorAll('.report-option').forEach(option => {
option.addEventListener('click', () => this.selectReport(option.dataset.report));
});
// Export buttons
document.getElementById('exportExcelBtn').addEventListener('click', () => this.exportExcel());
document.getElementById('exportCsvBtn').addEventListener('click', () => this.exportCSV());
}
selectReport(reportType) {
this.currentReport = reportType;
// Update UI
document.querySelectorAll('.report-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.report === reportType);
});
// Show/hide filters based on report type
this.updateFilters(reportType);
}
updateFilters(reportType) {
const filterSection = document.getElementById('filterSection');
const filterContent = document.getElementById('filterContent');
let html = '';
let needsFilter = false;
switch(reportType) {
case 'select-day':
case 'defects-date':
html = `
<div class="filter-row">
<div class="filter-group">
<label for="filterDate">Select Date:</label>
<input type="date" id="filterDate" max="${new Date().toISOString().split('T')[0]}">
</div>
</div>
<div class="button-row">
<button class="btn-query" id="queryBtn"><i class="fas fa-search"></i> Generate Report</button>
<button class="btn-reset" id="resetBtn"><i class="fas fa-redo"></i> Reset</button>
</div>
`;
needsFilter = true;
break;
case 'date-range':
case 'defects-range':
html = `
<div class="filter-row">
<div class="filter-group">
<label for="filterStartDate">Start Date:</label>
<input type="date" id="filterStartDate" max="${new Date().toISOString().split('T')[0]}">
</div>
<div class="filter-group">
<label for="filterEndDate">End Date:</label>
<input type="date" id="filterEndDate" max="${new Date().toISOString().split('T')[0]}">
</div>
</div>
<div class="button-row">
<button class="btn-query" id="queryBtn"><i class="fas fa-search"></i> Generate Report</button>
<button class="btn-reset" id="resetBtn"><i class="fas fa-redo"></i> Reset</button>
</div>
`;
needsFilter = true;
break;
default:
// Auto-generate for daily, 5-day, defects-today, defects-5day, all
html = `
<div class="button-row">
<button class="btn-query" id="queryBtn"><i class="fas fa-search"></i> Generate Report</button>
<button class="btn-reset" id="resetBtn"><i class="fas fa-redo"></i> Reset</button>
</div>
`;
}
filterContent.innerHTML = html;
filterSection.classList.toggle('active', needsFilter || ['daily', '5-day', 'defects-today', 'defects-5day', 'all'].includes(reportType));
// Attach button listeners
document.getElementById('queryBtn')?.addEventListener('click', () => this.generateReport());
document.getElementById('resetBtn')?.addEventListener('click', () => this.resetReport());
}
async generateReport() {
const filterDate = document.getElementById('filterDate')?.value;
const startDate = document.getElementById('filterStartDate')?.value;
const endDate = document.getElementById('filterEndDate')?.value;
// Show loading
this.showLoading(true);
try {
const response = await fetch('/quality/api/fg_report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
report_type: this.currentReport,
filter_date: filterDate,
start_date: startDate,
end_date: endDate
})
});
const data = await response.json();
if (data.success) {
this.currentData = data.data;
this.displayReport(data);
this.showSuccess('Report generated successfully');
} else {
this.showError(data.message || 'Failed to generate report');
}
} catch (error) {
console.error('Error:', error);
this.showError('Error generating report: ' + error.message);
} finally {
this.showLoading(false);
}
}
displayReport(data) {
const reportTable = document.getElementById('reportTable');
const tableHead = document.getElementById('tableHead');
const tableBody = document.getElementById('tableBody');
const emptyState = document.getElementById('emptyState');
const reportTitle = document.getElementById('reportTitle');
const dataStats = document.getElementById('dataStats');
const exportSection = document.getElementById('exportSection');
if (!data.data || data.data.length === 0) {
reportTable.style.display = 'none';
emptyState.style.display = 'block';
dataStats.style.display = 'none';
exportSection.style.display = 'none';
reportTitle.textContent = 'No data found for this report';
return;
}
// Build table headers
const headers = Object.keys(data.data[0]);
tableHead.innerHTML = headers.map(h => `<th>${this.formatHeader(h)}</th>`).join('');
// Build table body
tableBody.innerHTML = data.data.map(row => {
return `<tr>${headers.map(h => {
let value = row[h];
let cellClass = '';
if (h === 'quality_code' || h === 'defect_code') {
const isApproved = value === 0 || value === '0' || value === '000';
cellClass = isApproved ? 'status-approved' : 'status-rejected';
const statusText = isApproved ? 'APPROVED' : 'REJECTED';
return `<td><span class="status-badge ${cellClass}">${statusText}</span></td>`;
}
return `<td>${value}</td>`;
}).join('')}</tr>`;
}).join('');
// Update stats
const total = data.data.length;
const approved = data.summary.approved_count;
const rejected = data.summary.rejected_count;
document.getElementById('statTotal').textContent = total;
document.getElementById('statApproved').textContent = approved;
document.getElementById('statRejected').textContent = rejected;
// Update title and show sections
reportTitle.textContent = data.title;
reportTable.style.display = 'table';
emptyState.style.display = 'none';
dataStats.style.display = 'flex';
exportSection.style.display = 'flex';
}
formatHeader(header) {
return header
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
exportExcel() {
if (this.currentData.length === 0) {
this.showError('No data to export');
return;
}
const worksheet = XLSX.utils.json_to_sheet(this.currentData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'FG Scans');
XLSX.writeFile(workbook, `fg_report_${new Date().toISOString().split('T')[0]}.xlsx`);
this.showSuccess('Report exported to Excel');
}
exportCSV() {
if (this.currentData.length === 0) {
this.showError('No data to export');
return;
}
const csv = [
Object.keys(this.currentData[0]).join(','),
...this.currentData.map(row =>
Object.values(row).map(v => `"${v}"`).join(',')
)
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fg_report_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
this.showSuccess('Report exported to CSV');
}
resetReport() {
document.getElementById('filterDate').value = '';
document.getElementById('filterStartDate').value = '';
document.getElementById('filterEndDate').value = '';
document.getElementById('reportTable').style.display = 'none';
document.getElementById('emptyState').style.display = 'block';
document.getElementById('dataStats').style.display = 'none';
document.getElementById('exportSection').style.display = 'none';
document.getElementById('reportTitle').textContent = 'Select a report to view data';
}
showLoading(show) {
document.getElementById('loadingSpinner').classList.toggle('active', show);
}
showSuccess(message) {
const successMsg = document.getElementById('successMessage');
document.getElementById('successText').textContent = message;
successMsg.classList.add('active');
setTimeout(() => successMsg.classList.remove('active'), 3000);
}
showError(message) {
console.error(message);
alert(message);
}
}
// Initialize report manager when page loads
document.addEventListener('DOMContentLoaded', () => {
new FGReportManager();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,910 @@
{% extends "base.html" %}
{% block title %}FG Scan - Quality App{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fg_scan.css') }}">
<script src="https://cdn.jsdelivr.net/npm/qz-tray@2.1.0/qz-tray.js"></script>
{% endblock %}
{% block content %}
<div class="scan-container">
<!-- Form Card -->
<div class="scan-form-card">
<h3 class="form-title">FG Scan Entry</h3>
<form id="scanForm" method="POST" class="scan-form">
<label for="operator_code">Operator Code:</label>
<input type="text" id="operator_code" name="operator_code" required>
<div class="error-message" id="error_operator_code"></div>
<label for="cp_code">CP Code:</label>
<input type="text" id="cp_code" name="cp_code" required>
<div class="error-message" id="error_cp_code"></div>
<label for="oc1_code">OC1 Code:</label>
<input type="text" id="oc1_code" name="oc1_code">
<div class="error-message" id="error_oc1_code"></div>
<label for="oc2_code">OC2 Code:</label>
<input type="text" id="oc2_code" name="oc2_code">
<div class="error-message" id="error_oc2_code"></div>
<label for="defect_code">Defect Code:</label>
<input type="text" id="defect_code" name="defect_code" maxlength="3">
<div class="error-message" id="error_defect_code"></div>
<label for="date_time">Date/Time:</label>
<input type="text" id="date_time" name="date_time" readonly>
<div class="form-buttons">
<button type="submit" class="btn-submit">Submit Scan</button>
<button type="button" class="btn-clear" id="clearOperator">Clear Quality Operator</button>
</div>
<div class="form-options">
<label class="checkbox-label">
<input type="checkbox" id="scanToBoxes" name="scan_to_boxes">
Scan To Boxes
</label>
</div>
<div id="quickBoxSection" style="display: none;" class="quick-box-section">
<button type="button" class="btn-secondary" id="quickBoxLabel">Quick Box Label Creation</button>
</div>
</form>
</div>
<!-- Latest Scans Table -->
<div class="scan-table-card">
<h3 class="table-title">Latest Scans</h3>
<div class="table-wrapper">
<table class="scan-table">
<thead>
<tr>
<th>ID</th>
<th>Op Code</th>
<th>CP Code</th>
<th>OC1</th>
<th>OC2</th>
<th>Defect Code</th>
<th>Date</th>
<th>Time</th>
<th>Approved Qty</th>
<th>Rejected Qty</th>
</tr>
</thead>
<tbody id="scansTableBody">
{% if scan_groups %}
{% for scan_group in scan_groups %}
<tr>
<td>{{ scan_group.id }}</td>
<td>{{ scan_group.operator_code }}</td>
<td>{{ scan_group.cp_code }}</td>
<td>{{ scan_group.oc1_code or '-' }}</td>
<td>{{ scan_group.oc2_code or '-' }}</td>
<td>{{ scan_group.defect_code or '-' }}</td>
<td>{{ scan_group.date }}</td>
<td>{{ scan_group.time }}</td>
<td>{{ scan_group.approved_qty }}</td>
<td>{{ scan_group.rejected_qty }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="10" style="text-align: center; padding: 20px; color: var(--text-secondary);">
<i class="fas fa-inbox"></i> No scans recorded yet. Submit a scan to see results here.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Box Assignment Modal -->
<div id="boxAssignmentModal" class="box-modal" style="display: none;">
<div class="box-modal-content">
<div class="modal-header">
<h2>Assign to Box</h2>
<button type="button" class="modal-close" id="closeModal">&times;</button>
</div>
<div class="modal-body">
<label for="boxNumber">Box Number:</label>
<input type="text" id="boxNumber" placeholder="Enter box number">
<label for="boxQty">Quantity:</label>
<input type="number" id="boxQty" placeholder="Enter quantity" min="1">
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" id="cancelModal">Cancel</button>
<button type="button" class="btn-submit" id="assignToBox">Assign</button>
</div>
</div>
</div>
<script>
// Global variables
let scanToBoxesEnabled = false;
let currentCpCode = '';
let qzTrayReady = false;
let cpCodeLastInputTime = null;
// Get form input references FIRST (before using them) - with null safety check
const operatorCodeInput = document.getElementById('operator_code');
const cpCodeInput = document.getElementById('cp_code');
const oc1CodeInput = document.getElementById('oc1_code');
const oc2CodeInput = document.getElementById('oc2_code');
const defectCodeInput = document.getElementById('defect_code');
// Safety check - ensure all form inputs are available
if (!operatorCodeInput || !cpCodeInput || !oc1CodeInput || !oc2CodeInput || !defectCodeInput) {
console.error('❌ Error: Required form inputs not found in DOM');
console.log('operatorCodeInput:', operatorCodeInput);
console.log('cpCodeInput:', cpCodeInput);
console.log('oc1CodeInput:', oc1CodeInput);
console.log('oc2CodeInput:', oc2CodeInput);
console.log('defectCodeInput:', defectCodeInput);
}
// Initialize QZ Tray only when needed (lazy loading)
// Don't connect on page load - only connect when user enables "Scan To Boxes"
function initializeQzTray() {
if (typeof qz === 'undefined') {
console.log(' QZ Tray library not loaded');
return false;
}
try {
qz.security.setSignaturePromise(function(toSign) {
return new Promise(function(resolve, reject) {
// For development, we'll allow unsigned requests
resolve();
});
});
qz.websocket.connect().then(function() {
qzTrayReady = true;
console.log('✅ QZ Tray connected successfully');
}).catch(function(err) {
console.log(' QZ Tray connection failed:', err);
qzTrayReady = false;
});
return true;
} catch(err) {
console.log(' QZ Tray initialization error:', err);
return false;
}
}
// Update date/time display
function updateDateTime() {
const now = new Date();
const dateStr = now.toLocaleDateString('en-US');
const timeStr = now.toLocaleTimeString('en-US', { hour12: true });
const dateTimeInput = document.getElementById('date_time');
if (dateTimeInput) {
dateTimeInput.value = dateStr + ' ' + timeStr;
}
}
updateDateTime();
setInterval(updateDateTime, 1000);
// Load operator code from localStorage
function loadOperatorCode() {
if (!operatorCodeInput) return;
const saved = localStorage.getItem('quality_operator_code');
if (saved) {
operatorCodeInput.value = saved;
}
}
// Check if we need to clear fields after a successful submission
const shouldClearAfterSubmit = localStorage.getItem('fg_scan_clear_after_submit');
if (shouldClearAfterSubmit === 'true' && cpCodeInput && oc1CodeInput && oc2CodeInput && defectCodeInput) {
// Clear the flag
localStorage.removeItem('fg_scan_clear_after_submit');
localStorage.removeItem('fg_scan_last_cp');
localStorage.removeItem('fg_scan_last_defect');
// Clear CP code, OC1, OC2, and defect code for next scan (NOT operator code)
cpCodeInput.value = '';
oc1CodeInput.value = '';
oc2CodeInput.value = '';
defectCodeInput.value = '';
// Show success indicator
setTimeout(function() {
// Focus on CP code field for next scan
if (cpCodeInput) {
cpCodeInput.focus();
}
// Add visual feedback
const successIndicator = document.createElement('div');
successIndicator.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #4CAF50;
color: white;
padding: 10px 20px;
border-radius: 5px;
z-index: 1000;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
font-weight: bold;
`;
successIndicator.textContent = '✅ Scan recorded! Ready for next scan';
document.body.appendChild(successIndicator);
// Remove success indicator after 3 seconds
setTimeout(function() {
if (successIndicator.parentNode) {
successIndicator.parentNode.removeChild(successIndicator);
}
}, 3000);
}, 100);
}
// Focus on the first empty required field (only if not clearing after submit)
if (shouldClearAfterSubmit !== 'true' && operatorCodeInput && cpCodeInput && oc1CodeInput && oc2CodeInput && defectCodeInput) {
if (!operatorCodeInput.value) {
operatorCodeInput.focus();
} else if (!cpCodeInput.value) {
cpCodeInput.focus();
} else if (!oc1CodeInput.value) {
oc1CodeInput.focus();
} else if (!oc2CodeInput.value) {
oc2CodeInput.focus();
} else {
defectCodeInput.focus();
}
}
loadOperatorCode();
// Create error message elements
const operatorErrorMessage = document.createElement('div');
operatorErrorMessage.className = 'error-message';
operatorErrorMessage.id = 'operator-error';
operatorErrorMessage.textContent = 'Operator code must start with OP and be 4 characters';
operatorCodeInput.parentNode.insertBefore(operatorErrorMessage, operatorCodeInput.nextSibling);
const cpErrorMessage = document.createElement('div');
cpErrorMessage.className = 'error-message';
cpErrorMessage.id = 'cp-error';
cpErrorMessage.textContent = 'CP code must start with CP and be 15 characters';
cpCodeInput.parentNode.insertBefore(cpErrorMessage, cpCodeInput.nextSibling);
const oc1ErrorMessage = document.createElement('div');
oc1ErrorMessage.className = 'error-message';
oc1ErrorMessage.id = 'oc1-error';
oc1ErrorMessage.textContent = 'OC1 code must start with OC and be 4 characters';
oc1CodeInput.parentNode.insertBefore(oc1ErrorMessage, oc1CodeInput.nextSibling);
const oc2ErrorMessage = document.createElement('div');
oc2ErrorMessage.className = 'error-message';
oc2ErrorMessage.id = 'oc2-error';
oc2ErrorMessage.textContent = 'OC2 code must start with OC and be 4 characters';
oc2CodeInput.parentNode.insertBefore(oc2ErrorMessage, oc2CodeInput.nextSibling);
const defectErrorMessage = document.createElement('div');
defectErrorMessage.className = 'error-message';
defectErrorMessage.id = 'defect-error';
defectErrorMessage.textContent = 'Defect code must be a 3-digit number (e.g., 000, 001, 123)';
defectCodeInput.parentNode.insertBefore(defectErrorMessage, defectCodeInput.nextSibling);
// ===== CP CODE AUTO-COMPLETE LOGIC =====
let cpCodeAutoCompleteTimeout = null;
function autoCompleteCpCode() {
const value = cpCodeInput.value.trim().toUpperCase();
// Only process if it starts with "CP" but is not 15 characters
if (value.startsWith('CP') && value.length < 15 && value.length > 2) {
console.log('Auto-completing CP code:', value);
// Check if there's a hyphen in the value
if (value.includes('-')) {
// Split by hyphen: CP[base]-[suffix]
const parts = value.split('-');
if (parts.length === 2) {
const cpPrefix = parts[0]; // e.g., "CP00002042"
const suffix = parts[1]; // e.g., "1" or "12" or "123" or "3"
console.log('CP prefix:', cpPrefix, 'Suffix:', suffix);
// Always pad the suffix to exactly 4 digits
const paddedSuffix = suffix.padStart(4, '0');
// Construct the complete CP code
const completedCpCode = `${cpPrefix}-${paddedSuffix}`;
console.log('Completed CP code length:', completedCpCode.length, 'Code:', completedCpCode);
// Ensure it's exactly 15 characters
if (completedCpCode.length === 15) {
console.log('✅ Completed CP code:', completedCpCode);
cpCodeInput.value = completedCpCode;
// Show visual feedback
cpCodeInput.style.backgroundColor = '#e8f5e9';
setTimeout(() => {
cpCodeInput.style.backgroundColor = '';
}, 500);
// Move focus to next field (OC1 code)
setTimeout(() => {
oc1CodeInput.focus();
console.log('✅ Auto-completed CP Code and advanced to OC1');
}, 50);
// Show completion notification
showNotification(`✅ CP Code auto-completed: ${completedCpCode}`, 'success');
} else {
console.log('⚠️ Completed code length is not 15:', completedCpCode.length);
}
}
} else {
console.log('⏳ Waiting for hyphen to be entered before auto-completing');
}
} else {
if (value.length >= 15) {
console.log(' CP code is already complete (15 characters)');
}
}
}
cpCodeInput.addEventListener('input', function() {
cpCodeLastInputTime = Date.now();
const currentValue = this.value.trim().toUpperCase();
this.value = currentValue; // Convert to uppercase
// Clear existing timeout
if (cpCodeAutoCompleteTimeout) {
clearTimeout(cpCodeAutoCompleteTimeout);
}
console.log('CP Code input changed:', currentValue);
// Validate CP code prefix
if (currentValue.length >= 2 && !currentValue.startsWith('CP')) {
cpErrorMessage.classList.add('show');
this.setCustomValidity('Must start with CP');
} else {
cpErrorMessage.classList.remove('show');
this.setCustomValidity('');
// Auto-advance when field is complete and valid
if (currentValue.length === 15 && currentValue.startsWith('CP')) {
setTimeout(() => {
oc1CodeInput.focus();
console.log('✅ Auto-advanced from CP Code to OC1 Code');
}, 50);
}
}
// If hyphen is present and value is less than 15 chars, process immediately
if (currentValue.includes('-') && currentValue.length < 15) {
console.log('Hyphen detected, checking for auto-complete');
cpCodeAutoCompleteTimeout = setTimeout(() => {
console.log('Processing auto-complete after hyphen');
autoCompleteCpCode();
}, 500);
} else if (currentValue.length < 15 && currentValue.startsWith('CP')) {
// Set normal 2-second timeout only when no hyphen yet
cpCodeAutoCompleteTimeout = setTimeout(() => {
console.log('2-second timeout triggered for CP code');
autoCompleteCpCode();
}, 2000);
}
});
// Also trigger auto-complete when focus leaves the field (blur event)
cpCodeInput.addEventListener('blur', function() {
console.log('CP Code blur event triggered with value:', this.value);
if (cpCodeAutoCompleteTimeout) {
clearTimeout(cpCodeAutoCompleteTimeout);
}
autoCompleteCpCode();
});
// Prevent leaving CP code field if invalid
cpCodeInput.addEventListener('blur', function(e) {
if (this.value.length > 0 && !this.value.startsWith('CP')) {
cpErrorMessage.classList.add('show');
this.setCustomValidity('Must start with CP');
// Return focus to this field
setTimeout(() => {
this.focus();
this.select();
}, 0);
}
});
// Prevent Tab/Enter from moving to next field if CP code is invalid
cpCodeInput.addEventListener('keydown', function(e) {
if ((e.key === 'Tab' || e.key === 'Enter') && this.value.length > 0 && !this.value.startsWith('CP')) {
e.preventDefault();
cpErrorMessage.classList.add('show');
this.setCustomValidity('Must start with CP');
this.select();
}
});
// Prevent focusing on CP code if operator code is invalid
cpCodeInput.addEventListener('focus', function(e) {
if (operatorCodeInput.value.length > 0 && !operatorCodeInput.value.startsWith('OP')) {
e.preventDefault();
operatorErrorMessage.classList.add('show');
operatorCodeInput.focus();
operatorCodeInput.select();
}
});
// ===== OPERATOR CODE VALIDATION =====
operatorCodeInput.addEventListener('input', function() {
const value = this.value.toUpperCase();
this.value = value; // Convert to uppercase
if (value.length >= 2 && !value.startsWith('OP')) {
operatorErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OP');
} else {
operatorErrorMessage.classList.remove('show');
this.setCustomValidity('');
// Auto-advance when field is complete and valid
if (value.length === 4 && value.startsWith('OP')) {
setTimeout(() => {
cpCodeInput.focus();
console.log('✅ Auto-advanced from Operator Code to CP Code');
}, 50);
}
}
});
operatorCodeInput.addEventListener('blur', function(e) {
if (this.value.length > 0 && !this.value.startsWith('OP')) {
operatorErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OP');
setTimeout(() => {
this.focus();
this.select();
}, 0);
}
});
operatorCodeInput.addEventListener('keydown', function(e) {
if ((e.key === 'Tab' || e.key === 'Enter') && this.value.length > 0 && !this.value.startsWith('OP')) {
e.preventDefault();
operatorErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OP');
this.select();
}
});
// ===== OC1 CODE VALIDATION =====
oc1CodeInput.addEventListener('input', function() {
const value = this.value.toUpperCase();
this.value = value; // Convert to uppercase
if (value.length >= 2 && !value.startsWith('OC')) {
oc1ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
} else {
oc1ErrorMessage.classList.remove('show');
this.setCustomValidity('');
// Auto-advance when field is complete and valid
if (value.length === 4 && value.startsWith('OC')) {
setTimeout(() => {
oc2CodeInput.focus();
console.log('✅ Auto-advanced from OC1 Code to OC2 Code');
}, 50);
}
}
});
oc1CodeInput.addEventListener('blur', function(e) {
if (this.value.length > 0 && !this.value.startsWith('OC')) {
oc1ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
setTimeout(() => {
this.focus();
this.select();
}, 0);
}
});
oc1CodeInput.addEventListener('keydown', function(e) {
if ((e.key === 'Tab' || e.key === 'Enter') && this.value.length > 0 && !this.value.startsWith('OC')) {
e.preventDefault();
oc1ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
this.select();
}
});
oc1CodeInput.addEventListener('focus', function(e) {
if (cpCodeInput.value.length > 0 && !cpCodeInput.value.startsWith('CP')) {
e.preventDefault();
cpErrorMessage.classList.add('show');
cpCodeInput.focus();
cpCodeInput.select();
}
});
// ===== OC2 CODE VALIDATION =====
oc2CodeInput.addEventListener('input', function() {
const value = this.value.toUpperCase();
this.value = value; // Convert to uppercase
if (value.length >= 2 && !value.startsWith('OC')) {
oc2ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
} else {
oc2ErrorMessage.classList.remove('show');
this.setCustomValidity('');
// Auto-advance when field is complete and valid
if (value.length === 4 && value.startsWith('OC')) {
setTimeout(() => {
defectCodeInput.focus();
console.log('✅ Auto-advanced from OC2 Code to Defect Code');
}, 50);
}
}
});
oc2CodeInput.addEventListener('blur', function(e) {
if (this.value.length > 0 && !this.value.startsWith('OC')) {
oc2ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
setTimeout(() => {
this.focus();
this.select();
}, 0);
}
});
oc2CodeInput.addEventListener('keydown', function(e) {
if ((e.key === 'Tab' || e.key === 'Enter') && this.value.length > 0 && !this.value.startsWith('OC')) {
e.preventDefault();
oc2ErrorMessage.classList.add('show');
this.setCustomValidity('Must start with OC');
this.select();
}
});
oc2CodeInput.addEventListener('focus', function(e) {
if (cpCodeInput.value.length > 0 && !cpCodeInput.value.startsWith('CP')) {
e.preventDefault();
cpErrorMessage.classList.add('show');
cpCodeInput.focus();
cpCodeInput.select();
}
if (oc1CodeInput.value.length > 0 && !oc1CodeInput.value.startsWith('OC')) {
e.preventDefault();
oc1ErrorMessage.classList.add('show');
oc1CodeInput.focus();
oc1CodeInput.select();
}
});
// ===== DEFECT CODE VALIDATION =====
defectCodeInput.addEventListener('input', function() {
// Remove any non-digit characters
this.value = this.value.replace(/\D/g, '');
// Validate if it's a valid 3-digit number when length is 3
if (this.value.length === 3) {
const isValid = /^\d{3}$/.test(this.value);
if (!isValid) {
defectErrorMessage.classList.add('show');
this.setCustomValidity('Must be a 3-digit number');
} else {
defectErrorMessage.classList.remove('show');
this.setCustomValidity('');
}
} else {
defectErrorMessage.classList.remove('show');
this.setCustomValidity('');
}
// Auto-submit when 3 characters are entered and all validations pass
if (this.value.length === 3) {
// Validate operator code before submitting
if (!operatorCodeInput.value.startsWith('OP')) {
operatorErrorMessage.classList.add('show');
operatorCodeInput.focus();
operatorCodeInput.setCustomValidity('Must start with OP');
return;
}
// Validate CP code before submitting
if (!cpCodeInput.value.startsWith('CP') || cpCodeInput.value.length !== 15) {
cpErrorMessage.classList.add('show');
cpCodeInput.focus();
cpCodeInput.setCustomValidity('Must start with CP and be complete');
return;
}
// Validate OC1 code before submitting
if (!oc1CodeInput.value.startsWith('OC')) {
oc1ErrorMessage.classList.add('show');
oc1CodeInput.focus();
oc1CodeInput.setCustomValidity('Must start with OC');
return;
}
// Validate OC2 code before submitting
if (!oc2CodeInput.value.startsWith('OC')) {
oc2ErrorMessage.classList.add('show');
oc2CodeInput.focus();
oc2CodeInput.setCustomValidity('Must start with OC');
return;
}
// Validate defect code is a valid 3-digit number
const isValidDefectCode = /^\d{3}$/.test(this.value);
if (!isValidDefectCode) {
defectErrorMessage.classList.add('show');
this.focus();
this.setCustomValidity('Must be a 3-digit number');
return;
}
// Clear all custom validity states before submitting
operatorCodeInput.setCustomValidity('');
cpCodeInput.setCustomValidity('');
oc1CodeInput.setCustomValidity('');
oc2CodeInput.setCustomValidity('');
this.setCustomValidity('');
// ===== TIME FIELD AUTO-UPDATE (CRITICAL) =====
// Update time field to current time before submitting
const timeInput = document.getElementById('date_time');
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeValue = `${hours}:${minutes}:${seconds}`;
// Parse the current datetime display and update just the time part
const dateStr = timeInput.value.split(' ').slice(0, -1).join(' '); // Get date part
timeInput.value = dateStr + ' ' + timeValue;
console.log('✅ Time field updated to:', timeValue);
// Save current scan data to localStorage for clearing after reload
localStorage.setItem('fg_scan_clear_after_submit', 'true');
localStorage.setItem('fg_scan_last_cp', cpCodeInput.value);
localStorage.setItem('fg_scan_last_defect', defectCodeInput.value);
// Auto-submit the form
console.log('Auto-submitting form on 3-digit defect code');
document.getElementById('scanForm').submit();
}
});
defectCodeInput.addEventListener('focus', function(e) {
if (cpCodeInput.value.length > 0 && !cpCodeInput.value.startsWith('CP')) {
e.preventDefault();
cpErrorMessage.classList.add('show');
cpCodeInput.focus();
cpCodeInput.select();
}
if (oc1CodeInput.value.length > 0 && !oc1CodeInput.value.startsWith('OC')) {
e.preventDefault();
oc1ErrorMessage.classList.add('show');
oc1CodeInput.focus();
oc1CodeInput.select();
}
if (oc2CodeInput.value.length > 0 && !oc2CodeInput.value.startsWith('OC')) {
e.preventDefault();
oc2ErrorMessage.classList.add('show');
oc2CodeInput.focus();
oc2CodeInput.select();
}
});
// ===== CLEAR OPERATOR CODE BUTTON =====
document.getElementById('clearOperator').addEventListener('click', function(e) {
e.preventDefault();
operatorCodeInput.value = '';
localStorage.removeItem('quality_operator_code');
operatorCodeInput.focus();
showNotification('Quality Operator Code cleared', 'info');
});
// ===== SAVE OPERATOR CODE ON INPUT =====
operatorCodeInput.addEventListener('input', function() {
if (this.value.startsWith('OP') && this.value.length >= 3) {
localStorage.setItem('quality_operator_code', this.value);
}
});
// Form submission
document.getElementById('scanForm').addEventListener('submit', function(e) {
e.preventDefault();
if (!validateForm()) {
return;
}
// Save operator code
localStorage.setItem('quality_operator_code', document.getElementById('operator_code').value.trim());
const formData = new FormData(this);
// If AJAX is needed (scan to boxes)
if (scanToBoxesEnabled) {
fetch('{{ url_for("quality.fg_scan") }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Scan saved successfully!', 'success');
resetForm();
document.getElementById('boxAssignmentModal').style.display = 'flex';
} else {
showNotification('Error saving scan', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error saving scan', 'error');
});
} else {
// Regular form submission
this.submit();
}
});
// Scan to boxes functionality
document.getElementById('scanToBoxes').addEventListener('change', function() {
scanToBoxesEnabled = this.checked;
document.getElementById('quickBoxSection').style.display = this.checked ? 'block' : 'none';
if (this.checked) {
// Initialize QZ Tray when user enables the feature
console.log('Scan To Boxes enabled - initializing QZ Tray...');
initializeQzTray();
}
});
// Quick box label creation
document.getElementById('quickBoxLabel').addEventListener('click', function() {
if (!qzTrayReady) {
alert('QZ Tray is not connected. Please ensure QZ Tray is running.');
return;
}
const cpCode = document.getElementById('cp_code').value.trim();
if (!cpCode) {
alert('Please enter a CP code first');
return;
}
// Create label configuration for QZ Tray
const label = {
type: 'label',
cpCode: cpCode,
createdAt: new Date().toISOString()
};
// Send to printer via QZ Tray
qz.print({
type: 'label',
format: cpCode
}).catch(function(err) {
console.error('Print error:', err);
alert('Error printing label');
});
});
// Modal functionality
document.getElementById('closeModal').addEventListener('click', function() {
document.getElementById('boxAssignmentModal').style.display = 'none';
});
document.getElementById('cancelModal').addEventListener('click', function() {
document.getElementById('boxAssignmentModal').style.display = 'none';
});
document.getElementById('assignToBox').addEventListener('click', function() {
const boxNumber = document.getElementById('boxNumber').value.trim();
const boxQty = document.getElementById('boxQty').value.trim();
if (!boxNumber) {
alert('Please enter a box number');
return;
}
if (!boxQty || isNaN(boxQty) || parseInt(boxQty) < 1) {
alert('Please enter a valid quantity');
return;
}
// Submit box assignment
const data = {
box_number: boxNumber,
quantity: boxQty,
cp_code: currentCpCode
};
fetch('{{ url_for("quality.fg_scan") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Box assigned successfully!', 'success');
document.getElementById('boxAssignmentModal').style.display = 'none';
document.getElementById('boxNumber').value = '';
document.getElementById('boxQty').value = '';
}
})
.catch(error => console.error('Error:', error));
});
// Utility functions
function resetForm() {
document.getElementById('cp_code').value = '';
document.getElementById('oc1_code').value = '';
document.getElementById('oc2_code').value = '';
document.getElementById('defect_code').value = '';
currentCpCode = '';
document.getElementById('cp_code').focus();
}
function showNotification(message, type) {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.style.opacity = '1';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// Dark mode support
function applyDarkModeStyles() {
const isDarkMode = document.body.classList.contains('dark-mode');
if (isDarkMode) {
document.documentElement.style.setProperty('--bg-color', '#1e1e1e');
document.documentElement.style.setProperty('--text-color', '#e0e0e0');
}
}
// Check dark mode on page load
if (document.body.classList.contains('dark-mode')) {
applyDarkModeStyles();
}
// Listen for dark mode changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
applyDarkModeStyles();
}
});
});
observer.observe(document.body, { attributes: true });
</script>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Quality Module - Quality App v2{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-check-circle"></i> Quality Module
</h1>
<p class="text-muted">Manage quality checks and inspections</p>
</div>
</div>
<div class="row mb-4">
<div class="col-md-3">
<a href="{{ url_for('quality.fg_scan') }}" class="btn btn-success btn-lg w-100">
<i class="fas fa-barcode"></i><br>
FG Scan
</a>
</div>
<div class="col-md-3">
<a href="{{ url_for('quality.inspections') }}" class="btn btn-primary btn-lg w-100">
<i class="fas fa-clipboard-list"></i><br>
Inspections
</a>
</div>
<div class="col-md-3">
<a href="{{ url_for('quality.quality_reports') }}" class="btn btn-info btn-lg w-100">
<i class="fas fa-chart-bar"></i><br>
Reports
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Quality Overview</h5>
</div>
<div class="card-body">
<p class="text-muted text-center py-5">
<i class="fas fa-inbox"></i> No data available yet
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Inspections - Quality Module{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-clipboard-list"></i> Quality Inspections
</h1>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#newInspectionModal">
<i class="fas fa-plus"></i> New Inspection
</button>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Inspection Records</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Type</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-center text-muted py-4">
No inspections found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- New Inspection Modal -->
<div class="modal fade" id="newInspectionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Quality Inspection</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="newInspectionForm">
<div class="mb-3">
<label for="inspectionType" class="form-label">Inspection Type</label>
<select class="form-select" id="inspectionType" required>
<option value="">Select type...</option>
<option value="visual">Visual Check</option>
<option value="functional">Functional Test</option>
<option value="measurement">Measurement</option>
</select>
</div>
<div class="mb-3">
<label for="inspectionNote" class="form-label">Notes</label>
<textarea class="form-control" id="inspectionNote" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary">Create Inspection</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Reports - Quality Module{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-chart-bar"></i> Quality Reports
</h1>
</div>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">Total Inspections</h5>
<h2 class="text-primary">0</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">Pass Rate</h5>
<h2 class="text-success">0%</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">Issues Found</h5>
<h2 class="text-warning">0</h2>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Report Data</h5>
</div>
<div class="card-body">
<p class="text-muted text-center py-5">
<i class="fas fa-inbox"></i> No report data available
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,266 @@
{% extends "base.html" %}
{% block title %}App Keys Management{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-key"></i> App Keys Management
</h1>
<p class="text-muted mb-0">Manage API keys and printer pairing keys for QZ Tray</p>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<!-- QZ Tray Pairing Keys Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-print"></i> QZ Tray Printer Pairing Keys
</h5>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle"></i> {{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle"></i> {{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Generate New Pairing Key Form -->
<div class="mb-4 p-3 bg-light rounded">
<h6 class="mb-3">Generate New Pairing Key</h6>
<form method="POST" action="{{ url_for('settings.generate_pairing_key') }}" class="row g-3">
<div class="col-md-6">
<label for="printer_name" class="form-label">Printer Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="printer_name" name="printer_name"
placeholder="e.g., Label Printer 1" required>
</div>
<div class="col-md-3">
<label for="validity_days" class="form-label">Validity (days) <span class="text-danger">*</span></label>
<select class="form-select" id="validity_days" name="validity_days" required>
<option value="30">30 Days</option>
<option value="60">60 Days</option>
<option value="90" selected>90 Days</option>
<option value="180">180 Days</option>
<option value="365">1 Year</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-plus"></i> Generate Key
</button>
</div>
</form>
</div>
<!-- Active Pairing Keys Table -->
<h6 class="mb-3">Active Pairing Keys</h6>
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Printer Name</th>
<th>Pairing Key</th>
<th>Valid Until</th>
<th>Days Remaining</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if pairing_keys %}
{% for key in pairing_keys %}
<tr>
<td>
<strong>{{ key.printer_name }}</strong>
</td>
<td>
<code class="text-primary">{{ key.pairing_key }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2"
onclick="copyToClipboard('{{ key.pairing_key }}')">
<i class="fas fa-copy"></i>
</button>
</td>
<td>
<small>{{ key.valid_until }}</small>
</td>
<td>
{% set days_left = key.days_remaining %}
{% if days_left > 30 %}
<span class="badge bg-success">{{ days_left }} days</span>
{% elif days_left > 0 %}
<span class="badge bg-warning">{{ days_left }} days</span>
{% else %}
<span class="badge bg-danger">Expired</span>
{% endif %}
</td>
<td>
<form method="POST" action="{{ url_for('settings.delete_pairing_key', key_id=key.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this pairing key?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No pairing keys found. Create one to enable QZ Tray printing.</span>
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- App API Keys Section -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-code"></i> Application API Keys
</h5>
</div>
<div class="card-body">
<!-- Generate New API Key Form -->
<div class="mb-4 p-3 bg-light rounded">
<h6 class="mb-3">Generate New API Key</h6>
<form method="POST" action="{{ url_for('settings.generate_api_key') }}" class="row g-3">
<div class="col-md-6">
<label for="key_name" class="form-label">Key Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="key_name" name="key_name"
placeholder="e.g., External Service API" required>
</div>
<div class="col-md-3">
<label for="key_type" class="form-label">Key Type <span class="text-danger">*</span></label>
<select class="form-select" id="key_type" name="key_type" required>
<option value="app_key">App Key</option>
<option value="external_service">External Service</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-plus"></i> Generate Key
</button>
</div>
</form>
</div>
<!-- Active API Keys Table -->
<h6 class="mb-3">Active API Keys</h6>
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Key Name</th>
<th>Key Type</th>
<th>API Key</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if api_keys %}
{% for key in api_keys %}
<tr>
<td>
<strong>{{ key.key_name }}</strong>
</td>
<td>
<span class="badge bg-info">{{ key.key_type }}</span>
</td>
<td>
<code class="text-primary">{{ key.api_key[:20] }}...</code>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2"
onclick="copyToClipboard('{{ key.api_key }}')">
<i class="fas fa-copy"></i>
</button>
</td>
<td>
<small>{{ key.created_at.strftime('%Y-%m-%d %H:%M') if key.created_at else 'N/A' }}</small>
</td>
<td>
<form method="POST" action="{{ url_for('settings.delete_api_key', key_id=key.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete this API key?');">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No API keys found. Create one for external integrations.</span>
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="alert alert-info mt-3">
<i class="fas fa-info-circle"></i> <strong>Note:</strong> Keep your API keys secure and never share them publicly.
Regenerate keys if you suspect they have been compromised.
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('Key copied to clipboard!');
}).catch(err => {
console.error('Failed to copy:', err);
alert('Failed to copy key');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Database Settings{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-database"></i> Database Settings
</h1>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Database Configuration</h5>
</div>
<div class="card-body">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
Database configuration is managed through environment variables and Docker.
Please check the .env file for current settings.
</div>
<h6>Current Database Status</h6>
<table class="table table-hover">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Host</td>
<td><code>{{ config.DB_HOST }}</code></td>
</tr>
<tr>
<td>Port</td>
<td><code>{{ config.DB_PORT }}</code></td>
</tr>
<tr>
<td>Database</td>
<td><code>{{ config.DB_NAME }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,901 @@
{% extends "base.html" %}
{% block title %}Database Management{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-database"></i> Database Management
</h1>
<p class="text-muted mb-0">Backup, restore, and manage your database operations</p>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<!-- Display Messages -->
{% if error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle"></i> {{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle"></i> {{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<!-- Backup Retention Settings Section -->
<div class="card shadow-sm mb-4 border-info">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-calendar-times"></i> Backup Retention Policy
</h5>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Automatic Cleanup:</strong> Set how long backups should be kept on the server. Older backups will be automatically deleted.
</div>
<div class="row">
<div class="col-md-8">
<form method="POST" action="{{ url_for('settings.save_backup_retention') }}" id="retention-form">
<div class="mb-3">
<label for="backup-retention-days" class="form-label">Keep Backups For:</label>
<div class="input-group">
<input type="number" class="form-control" id="backup-retention-days" name="retention_days" min="1" max="365" value="{{ backup_retention_days or 30 }}" required>
<span class="input-group-text">days</span>
</div>
<small class="text-muted d-block mt-2">
Backups older than this will be automatically deleted when retention policy is applied.
</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="auto-cleanup-enabled" name="auto_cleanup" value="1" {{ 'checked' if auto_cleanup else '' }}>
<label class="form-check-label" for="auto-cleanup-enabled">
Enable automatic cleanup of old backups
</label>
</div>
<small class="text-muted d-block mt-2">
When enabled, backups exceeding the retention period will be automatically deleted.
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Retention Policy
</button>
</form>
</div>
<div class="col-md-4">
<div class="card bg-light border-info">
<div class="card-body">
<h6 class="card-title"><i class="fas fa-trash"></i> Manual Cleanup</h6>
<p class="text-muted small mb-2">Delete backups older than the retention period right now:</p>
<button type="button" class="btn btn-warning btn-sm w-100" id="cleanup-old-backups-btn">
<i class="fas fa-broom"></i> Clean Up Old Backups
</button>
</div>
</div>
</div>
</div>
<!-- Scheduled Backups Section -->
<hr class="my-4">
<h6 class="mb-3"><i class="fas fa-clock"></i> Scheduled Backups</h6>
<p class="text-muted small mb-3">Create automatic backups on a schedule that respect your retention policy.</p>
<!-- New Schedule Form -->
<div class="card bg-light mb-3">
<div class="card-body">
<h6 class="card-title mb-3">Create New Schedule</h6>
<form id="schedule-form">
<div class="row">
<div class="col-md-6 mb-2">
<label for="schedule-name" class="form-label">Schedule Name:</label>
<input type="text" class="form-control" id="schedule-name" placeholder="e.g., Daily Backup" required>
</div>
<div class="col-md-6 mb-2">
<label for="schedule-frequency" class="form-label">Frequency:</label>
<select class="form-select" id="schedule-frequency" required onchange="toggleDayOfWeek()">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-2">
<label for="schedule-time" class="form-label">Time:</label>
<input type="time" class="form-control" id="schedule-time" value="02:00" required>
</div>
<div class="col-md-6 mb-2" id="day-of-week-container" style="display: none;">
<label for="schedule-day" class="form-label">Day of Week:</label>
<select class="form-select" id="schedule-day">
<option value="Monday">Monday</option>
<option value="Tuesday">Tuesday</option>
<option value="Wednesday">Wednesday</option>
<option value="Thursday">Thursday</option>
<option value="Friday">Friday</option>
<option value="Saturday">Saturday</option>
<option value="Sunday">Sunday</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-2">
<label for="schedule-type" class="form-label">Backup Type:</label>
<select class="form-select" id="schedule-type" required>
<option value="full">Full Database Backup</option>
<option value="data_only">Data Only Backup</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label d-block">&nbsp;</label>
<button type="button" class="btn btn-primary w-100" id="create-schedule-btn" onclick="saveBackupSchedule()">
<i class="fas fa-plus"></i> Create Schedule
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Active Schedules List -->
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Schedule Name</th>
<th>Frequency</th>
<th>Time</th>
<th>Type</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="schedules-list">
<tr>
<td colspan="8" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No scheduled backups configured</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Backup Management Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-save"></i> Backup Management
</h5>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Backup Information:</strong> Create a complete backup of your database including structure and data. Backups are stored on the server.
</div>
<!-- Quick Actions -->
<div class="mb-4">
<div class="row g-2">
<div class="col-md-6">
<button type="button" class="btn btn-success w-100" id="backup-full-btn" data-bs-toggle="modal" data-bs-target="#backupModal" data-type="full">
<i class="fas fa-database"></i> Full Database Backup
</button>
<small class="text-muted d-block mt-2">Includes structure and all data</small>
</div>
<div class="col-md-6">
<button type="button" class="btn btn-info w-100" id="backup-data-btn" data-bs-toggle="modal" data-bs-target="#backupModal" data-type="data">
<i class="fas fa-box"></i> Data Only Backup
</button>
<small class="text-muted d-block mt-2">Data without table structure</small>
</div>
</div>
</div>
<!-- Storage Info -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card bg-light border-secondary">
<div class="card-body">
<h6 class="card-title"><i class="fas fa-hdd"></i> Database Size</h6>
<div class="display-6" id="db-size">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light border-secondary">
<div class="card-body">
<h6 class="card-title"><i class="fas fa-clock"></i> Last Backup</h6>
<div class="display-6 small" id="last-backup">Loading...</div>
</div>
</div>
</div>
</div>
<!-- Recent Backups List -->
<h6 class="mb-3">Recent Backups</h6>
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th>Date Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="backups-list">
<tr>
<td colspan="5" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>Loading backups...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Restore Management Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-undo"></i> Restore Database
</h5>
</div>
<div class="card-body">
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> Restoring a backup will <strong>overwrite</strong> your current database. This action cannot be undone. Always verify you're restoring the correct backup!
</div>
<div class="mb-3">
<label for="restore-backup-select" class="form-label">Select Backup to Restore:</label>
<select class="form-select" id="restore-backup-select">
<option value="">-- Select a backup --</option>
</select>
</div>
<div id="restore-info" style="display: none;" class="alert alert-info mb-3">
<p class="mb-2"><strong>Backup Details:</strong></p>
<p class="mb-0"><small id="restore-info-text"></small></p>
</div>
<button type="button" class="btn btn-warning" id="restore-btn" disabled>
<i class="fas fa-undo"></i> Restore from Backup
</button>
</div>
</div>
<!-- Table Truncate Section -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-broom"></i> Clear Table Data
</h5>
</div>
<div class="card-body">
<div class="alert alert-danger mb-4">
<i class="fas fa-exclamation-circle"></i>
<strong>Caution:</strong> Clearing a table will <strong>delete all data</strong> from the selected table while preserving its structure. This action cannot be undone!
</div>
<div class="row">
<div class="col-md-6">
<label for="truncate-table-select" class="form-label">Select Table:</label>
<select class="form-select" id="truncate-table-select">
<option value="">-- Select a table --</option>
{% for table in tables %}
<option value="{{ table.name }}">{{ table.name }} ({{ table.rows }} rows)</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label d-block">&nbsp;</label>
<button type="button" class="btn btn-danger w-100" id="truncate-btn" disabled data-bs-toggle="modal" data-bs-target="#confirmTruncateModal">
<i class="fas fa-trash"></i> Clear Selected Table
</button>
</div>
</div>
<div id="truncate-info" style="display: none;" class="alert alert-info mt-3 mb-0">
<p class="mb-2"><strong>Table Information:</strong></p>
<p class="mb-1"><strong>Name:</strong> <span id="truncate-table-name"></span></p>
<p class="mb-0"><strong>Rows to Delete:</strong> <span id="truncate-row-count" class="badge bg-danger"></span></p>
</div>
</div>
</div>
<!-- Import Data Section -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-file-import"></i> Upload Backup File
</h5>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Upload:</strong> Upload SQL backup files (.sql) to store them alongside your automatic backups. You can then restore them using the Restore Database section.
</div>
<div class="mb-3">
<label for="import-file" class="form-label">Choose SQL File to Upload:</label>
<input type="file" class="form-control" id="import-file" accept=".sql" required>
<small class="text-muted d-block mt-2">Supported format: .sql files (e.g., from database exports)</small>
</div>
<button type="button" class="btn btn-primary" id="upload-backup-btn" onclick="uploadBackupFile()">
<i class="fas fa-upload"></i> Upload Backup File
</button>
<div id="upload-status" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Backup Modal -->
<div class="modal fade" id="backupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="backup-form" method="POST">
<div class="mb-3">
<label for="backup-name" class="form-label">Backup Name (Optional):</label>
<input type="text" class="form-control" id="backup-name" name="backup_name" placeholder="e.g., Pre-Migration Backup">
<small class="text-muted">If empty, a timestamp will be used</small>
</div>
<input type="hidden" name="backup_type" id="backup-type-input" value="">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="backup-form" class="btn btn-success">
<i class="fas fa-save"></i> Create Backup
</button>
</div>
</div>
</div>
</div>
<!-- Confirm Truncate Modal -->
<div class="modal fade" id="confirmTruncateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="fas fa-exclamation-triangle"></i> Confirm Table Clear</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-2">You are about to <strong>permanently delete all data</strong> from:</p>
<p class="bg-light p-2 rounded"><strong id="confirm-table-name"></strong></p>
<p class="text-danger"><strong>This action cannot be undone!</strong></p>
<p class="small text-muted mb-0">Please ensure you have a backup before proceeding.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-truncate-btn">
<i class="fas fa-trash"></i> Yes, Clear Table
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load backups list
loadBackupsList();
// Load schedules list
loadBackupSchedules();
// Cleanup old backups handler
const cleanupBtn = document.getElementById('cleanup-old-backups-btn');
if (cleanupBtn) {
cleanupBtn.addEventListener('click', function() {
if (confirm('This will delete all backups older than the retention period. Continue?')) {
fetch('{{ url_for("settings.cleanup_old_backups") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Cleanup completed! ' + (data.deleted_count || 0) + ' old backups deleted.');
loadBackupsList();
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Error cleaning up backups: ' + error);
});
}
});
}
// Backup button handlers
document.getElementById('backup-full-btn').addEventListener('click', function() {
document.getElementById('backup-type-input').value = 'full';
});
document.getElementById('backup-data-btn').addEventListener('click', function() {
document.getElementById('backup-type-input').value = 'data';
});
// Backup form submission
document.getElementById('backup-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('{{ url_for("settings.create_backup") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Backup created successfully: ' + data.file);
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating backup: ' + error);
});
const modal = bootstrap.Modal.getInstance(document.getElementById('backupModal'));
if (modal) modal.hide();
});
// Truncate table handler
const truncateSelect = document.getElementById('truncate-table-select');
const truncateBtn = document.getElementById('truncate-btn');
console.log('Initializing truncate handler...');
console.log('truncateSelect element:', truncateSelect);
console.log('truncateBtn element:', truncateBtn);
console.log('truncateBtn.disabled initial value:', truncateBtn ? truncateBtn.disabled : 'N/A');
if (truncateSelect) {
truncateSelect.addEventListener('change', function() {
const table = this.value;
const option = this.options[this.selectedIndex];
console.log('=== TRUNCATE HANDLER FIRED ===');
console.log('Selected value:', table);
console.log('Selected option text:', option.text);
console.log('Button disabled before:', truncateBtn.disabled);
if (table) {
console.log('Table selected - enabling button');
document.getElementById('truncate-info').style.display = 'block';
document.getElementById('truncate-table-name').textContent = table;
document.getElementById('truncate-row-count').textContent = option.text.match(/\((\d+)\s*rows\)/)?.[1] || '0';
truncateBtn.disabled = false;
document.getElementById('confirm-table-name').textContent = table;
console.log('Button disabled after setting to false:', truncateBtn.disabled);
} else {
console.log('No table selected - disabling button');
document.getElementById('truncate-info').style.display = 'none';
truncateBtn.disabled = true;
console.log('Button disabled after setting to true:', truncateBtn.disabled);
}
});
console.log('✓ Change event listener registered on truncate-table-select');
} else {
console.error('✗ truncate-table-select element not found!');
}
// Confirm truncate
document.getElementById('confirm-truncate-btn').addEventListener('click', function() {
const table = document.getElementById('truncate-table-select').value;
// Disable button to prevent multiple clicks
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Clearing...';
fetch('{{ url_for("settings.truncate_table") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ table: table })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmTruncateModal'));
if (modal) modal.hide();
// Show success message
alert('Table cleared successfully! Refreshing page...');
// Refresh the page after a short delay
setTimeout(() => {
location.reload();
}, 500);
} else {
alert('Error: ' + data.error);
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear Table';
}
})
.catch(error => {
console.error('Error:', error);
alert('Error clearing table: ' + error);
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear Table';
});
});
// Restore backup handler
document.getElementById('restore-backup-select').addEventListener('change', function() {
const backup = this.value;
if (backup) {
document.getElementById('restore-info').style.display = 'block';
document.getElementById('restore-info-text').textContent = 'Selected: ' + backup;
document.getElementById('restore-btn').disabled = false;
} else {
document.getElementById('restore-info').style.display = 'none';
document.getElementById('restore-btn').disabled = true;
}
});
document.getElementById('restore-btn').addEventListener('click', function() {
if (confirm('Are you sure? This will overwrite your current database!')) {
const backup = document.getElementById('restore-backup-select').value;
fetch('{{ url_for("settings.restore_database") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ backup: backup })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Database restored successfully!');
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error restoring database: ' + error);
});
}
});
});
function loadBackupsList() {
fetch('{{ url_for("settings.get_backups_list") }}')
.then(response => response.json())
.then(data => {
if (data.backups && data.backups.length > 0) {
const tbody = document.getElementById('backups-list');
tbody.innerHTML = '';
data.backups.forEach(backup => {
const row = document.createElement('tr');
row.innerHTML = `
<td><code>${backup.name}</code></td>
<td><span class="badge bg-info">${backup.type}</span></td>
<td>${backup.size}</td>
<td><small>${backup.date}</small></td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="downloadBackup('${backup.name}')">
<i class="fas fa-download"></i> Download
</button>
</td>
`;
tbody.appendChild(row);
});
// Update restore select
const select = document.getElementById('restore-backup-select');
select.innerHTML = '<option value="">-- Select a backup --</option>';
data.backups.forEach(backup => {
const option = document.createElement('option');
option.value = backup.name;
option.textContent = backup.name + ' (' + backup.size + ')';
select.appendChild(option);
});
// Update last backup info
if (data.backups.length > 0) {
document.getElementById('last-backup').textContent = data.backups[0].date;
}
} else {
document.getElementById('backups-list').innerHTML = `
<tr>
<td colspan="5" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No backups found. Create one now!</span>
</div>
</td>
</tr>
`;
}
// Update DB size
if (data.db_size) {
document.getElementById('db-size').textContent = data.db_size;
}
})
.catch(error => {
console.error('Error loading backups:', error);
document.getElementById('backups-list').innerHTML = `
<tr>
<td colspan="5" class="text-center py-4 text-danger">
<i class="fas fa-exclamation-circle"></i> Error loading backups
</td>
</tr>
`;
});
}
function downloadBackup(filename) {
window.location.href = '{{ url_for("settings.download_backup") }}?file=' + encodeURIComponent(filename);
}
// Schedule management functions
function toggleDayOfWeek() {
const frequency = document.getElementById('schedule-frequency').value;
const dayContainer = document.getElementById('day-of-week-container');
dayContainer.style.display = frequency === 'weekly' ? 'block' : 'none';
}
function loadBackupSchedules() {
fetch('{{ url_for("settings.get_backup_schedules") }}')
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('schedules-list');
if (data.schedules && data.schedules.length > 0) {
tbody.innerHTML = '';
data.schedules.forEach(schedule => {
const lastRun = schedule.last_run ? new Date(schedule.last_run).toLocaleString() : 'Never';
const nextRun = schedule.next_run ? new Date(schedule.next_run).toLocaleString() : 'Calculating...';
const statusBadge = schedule.is_active ?
'<span class="badge bg-success">Active</span>' :
'<span class="badge bg-secondary">Inactive</span>';
const frequencyDisplay = schedule.frequency === 'daily' ? 'Daily' :
'Weekly (' + (schedule.day_of_week || 'Not set') + ')';
const typeDisplay = schedule.backup_type === 'full' ? 'Full Database' : 'Data Only';
const row = document.createElement('tr');
row.innerHTML = `
<td>${schedule.schedule_name}</td>
<td>${frequencyDisplay}</td>
<td>${schedule.time_of_day}</td>
<td><span class="badge bg-secondary">${typeDisplay}</span></td>
<td><small>${lastRun}</small></td>
<td><small>${nextRun}</small></td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-sm btn-outline-warning" onclick="toggleSchedule(${schedule.id})" title="Enable/Disable">
<i class="fas fa-power-off"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSchedule(${schedule.id})">
<i class="fas fa-trash"></i>
</button>
</td>
`;
tbody.appendChild(row);
});
} else {
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No scheduled backups configured</span>
</div>
</td>
</tr>
`;
}
})
.catch(error => {
console.error('Error loading schedules:', error);
});
}
function saveBackupSchedule() {
const name = document.getElementById('schedule-name').value.trim();
const frequency = document.getElementById('schedule-frequency').value;
const time = document.getElementById('schedule-time').value;
const dayOfWeek = document.getElementById('schedule-day').value;
const type = document.getElementById('schedule-type').value;
if (!name || !time) {
alert('Please fill in all required fields');
return;
}
if (frequency === 'weekly' && !dayOfWeek) {
alert('Please select a day for weekly schedules');
return;
}
const payload = {
schedule_name: name,
frequency: frequency,
day_of_week: frequency === 'weekly' ? dayOfWeek : null,
time_of_day: time,
backup_type: type
};
fetch('{{ url_for("settings.save_backup_schedule") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Backup schedule created successfully!');
document.getElementById('schedule-form').reset();
loadBackupSchedules();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error creating schedule: ' + error);
});
}
function deleteSchedule(scheduleId) {
if (confirm('Are you sure you want to delete this schedule?')) {
fetch('{{ url_for("settings.delete_backup_schedule", schedule_id=0) }}'.replace('0', scheduleId), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Schedule deleted successfully!');
loadBackupSchedules();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting schedule: ' + error);
});
}
}
function toggleSchedule(scheduleId) {
fetch('{{ url_for("settings.toggle_backup_schedule", schedule_id=0) }}'.replace('0', scheduleId), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
loadBackupSchedules();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error toggling schedule: ' + error);
});
}
function uploadBackupFile() {
const fileInput = document.getElementById('import-file');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file to upload');
return;
}
if (!file.name.toLowerCase().endsWith('.sql')) {
alert('Only .sql files are supported');
return;
}
const formData = new FormData();
formData.append('file', file);
const statusDiv = document.getElementById('upload-status');
statusDiv.innerHTML = '<div class="alert alert-info"><i class="fas fa-spinner fa-spin"></i> Uploading...</div>';
fetch('{{ url_for("settings.upload_backup_file") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusDiv.innerHTML = '<div class="alert alert-success"><i class="fas fa-check-circle"></i> File uploaded successfully: ' + data.filename + ' (' + data.size + ' bytes)</div>';
fileInput.value = '';
loadBackupsList();
} else {
statusDiv.innerHTML = '<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> Upload failed: ' + data.error + '</div>';
}
})
.catch(error => {
console.error('Error:', error);
statusDiv.innerHTML = '<div class="alert alert-danger"><i class="fas fa-exclamation-circle"></i> Upload error: ' + error + '</div>';
});
}
// Load schedules on page load
</script>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}General Settings{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-sliders-h"></i> General Settings
</h1>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">General Application Settings</h5>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle"></i> {{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle"></i> {{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="POST" action="{{ url_for('settings.general_settings') }}">
<div class="mb-3">
<label for="app_name" class="form-label">Application Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="app_name" name="app_name" value="{{ app_name }}" required>
<small class="form-text text-muted">This name appears in the header and browser title</small>
</div>
<div class="mb-3">
<label for="app_version" class="form-label">Version</label>
<input type="text" class="form-control" id="app_version" name="app_version" value="{{ app_version }}" disabled>
<small class="form-text text-muted">Version cannot be changed</small>
</div>
<div class="mb-3">
<label for="session_timeout" class="form-label">Session Timeout (minutes)</label>
<input type="number" class="form-control" id="session_timeout" name="session_timeout" value="{{ session_timeout }}" min="1" required>
<small class="form-text text-muted">Time before user session expires</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Settings
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Settings Module{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-cog"></i> Settings
</h1>
<p class="text-muted">Configure application settings and preferences</p>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Settings Overview</h5>
</div>
<div class="card-body">
<p class="text-muted">
Select a settings category from the left menu to configure.
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}{% if user %}Edit User{% else %}Create User{% endif %}{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-user-plus"></i> {% if user %}Edit User{% else %}Create New User{% endif %}
</h1>
<p class="text-muted mb-0">Manage user account details and permissions</p>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database
</a>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">{% if user %}Edit User Account{% else %}New User Account{% endif %}</h5>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle"></i> {{ error }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if success %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle"></i> {{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="POST" action="{% if user %}{{ url_for('settings.edit_user', user_id=user.id) }}{% else %}{{ url_for('settings.create_user') }}{% endif %}" novalidate>
<div class="row">
<div class="col-md-6 mb-3">
<label for="username" class="form-label">Username <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="username" name="username"
value="{{ user.username if user else '' }}"
{% if user %}readonly{% endif %}
required>
<small class="form-text text-muted">{% if user %}Username cannot be changed{% else %}Unique username for login{% endif %}</small>
</div>
<div class="col-md-6 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
value="{{ user.email if user else '' }}">
<small class="form-text text-muted">User's email address</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="full_name" class="form-label">Full Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="full_name" name="full_name"
value="{{ user.full_name if user else '' }}"
required>
<small class="form-text text-muted">User's display name</small>
</div>
<div class="col-md-6 mb-3">
<label for="role" class="form-label">Role <span class="text-danger">*</span></label>
<select class="form-select" id="role" name="role" required>
<option value="">-- Select a role --</option>
{% for role in roles %}
<option value="{{ role.name }}"
{% if user and user.role == role.name %}selected{% endif %}>
{{ role.name | capitalize }} (Level {{ role.level }})
</option>
{% endfor %}
</select>
<small class="form-text text-muted">User's access level</small>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="password" class="form-label">
Password
{% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
{% if not user %}required{% else %}placeholder="Leave blank to keep current password"{% endif %}>
<small class="form-text text-muted">
{% if user %}Leave blank to keep current password{% else %}Minimum 8 characters{% endif %}
</small>
</div>
<div class="col-md-6 mb-3">
<label for="confirm_password" class="form-label">
Confirm Password
{% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password"
{% if not user %}required{% endif %}>
<small class="form-text text-muted">Re-enter password to confirm</small>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="modules" class="form-label">Module Access <span class="text-danger">*</span></label>
<div class="card bg-light">
<div class="card-body">
{% for module in available_modules %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="module_{{ module }}"
name="modules" value="{{ module }}"
{% if user and module in user_modules %}checked{% endif %}>
<label class="form-check-label" for="module_{{ module }}">
<i class="fas fa-{% if module == 'quality' %}check-square{% elif module == 'settings' %}sliders-h{% else %}cube{% endif %}"></i>
{{ module | capitalize }} Module
</label>
</div>
{% endfor %}
</div>
</div>
<small class="form-text text-muted">Select which modules this user can access</small>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="is_active" class="form-check-label">
<input type="checkbox" class="form-check-input" id="is_active" name="is_active"
{% if not user or user.is_active %}checked{% endif %}>
<span class="ms-2">Active Account</span>
</label>
<small class="form-text text-muted d-block">Disabled accounts cannot log in</small>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {% if user %}Update User{% else %}Create User{% endif %}
</button>
<a href="{{ url_for('settings.user_management') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
{% if user %}
<button type="button" class="btn btn-danger ms-auto" onclick="confirmDelete()">
<i class="fas fa-trash"></i> Delete User
</button>
{% endif %}
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% if user %}
<script>
function confirmDelete() {
if (confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("settings.delete_user", user_id=user.id) }}';
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_method';
input.value = 'DELETE';
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
}
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}User Management{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-2">
<i class="fas fa-users"></i> User Management
</h1>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="list-group">
<a href="{{ url_for('settings.general_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-sliders-h"></i> General Settings
</a>
<a href="{{ url_for('settings.user_management') }}" class="list-group-item list-group-item-action active">
<i class="fas fa-users"></i> User Management
</a>
<a href="{{ url_for('settings.app_keys') }}" class="list-group-item list-group-item-action">
<i class="fas fa-key"></i> App Keys
</a>
<a href="{{ url_for('settings.database_management') }}" class="list-group-item list-group-item-action">
<i class="fas fa-cogs"></i> Database Management
</a>
<a href="{{ url_for('settings.database_settings') }}" class="list-group-item list-group-item-action">
<i class="fas fa-database"></i> Database Info
</a>
</div>
</div>
<div class="col-md-9">
<div class="card shadow-sm">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">User Accounts</h5>
<a href="{{ url_for('settings.create_user') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Create User
</a>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead>
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if users %}
{% for user in users %}
<tr>
<td><code>{{ user.username }}</code></td>
<td>{{ user.full_name }}</td>
<td>{% if user.email %}<small>{{ user.email }}</small>{% else %}<small class="text-muted">N/A</small>{% endif %}</td>
<td>
<span class="badge bg-info text-dark">
{{ user.role | capitalize }}
</span>
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('settings.edit_user', user_id=user.id) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-edit"></i> Edit
</a>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="text-center py-4">
<div class="empty-state-message">
<i class="fas fa-inbox" style="font-size: 24px; margin-right: 10px;"></i>
<span>No users found. <a href="{{ url_for('settings.create_user') }}">Create one</a></span>
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}User Profile - Quality App v2{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h3 class="mb-0">
<i class="fas fa-user-circle"></i> User Profile
</h3>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-3 text-center">
<div class="avatar mb-3">
<i class="fas fa-user-circle" style="font-size: 80px; color: #007bff;"></i>
</div>
</div>
<div class="col-md-9">
<table class="table table-borderless">
<tr>
<th>Username:</th>
<td>{{ user.username }}</td>
</tr>
<tr>
<th>Full Name:</th>
<td>{{ user.full_name }}</td>
</tr>
<tr>
<th>Email:</th>
<td>{{ user.email }}</td>
</tr>
<tr>
<th>Role:</th>
<td>
<span class="badge bg-info">{{ user.role.upper() }}</span>
</td>
</tr>
</table>
</div>
</div>
<hr>
<div class="mt-4">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}