Initial commit: Quality App v2 - FG Scan Module with Reports
This commit is contained in:
183
app/__init__.py
Normal file
183
app/__init__.py
Normal 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
279
app/access_control.py
Normal 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
129
app/auth.py
Normal 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
82
app/config.py
Normal 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
141
app/database.py
Normal 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
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models Package
|
||||
1
app/modules/quality/__init__.py
Normal file
1
app/modules/quality/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Quality Module Package
|
||||
341
app/modules/quality/quality.py
Normal file
341
app/modules/quality/quality.py
Normal 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}
|
||||
187
app/modules/quality/routes.py
Normal file
187
app/modules/quality/routes.py
Normal 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
|
||||
|
||||
1
app/modules/settings/__init__.py
Normal file
1
app/modules/settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Settings Module Package
|
||||
1256
app/modules/settings/routes.py
Normal file
1256
app/modules/settings/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
115
app/routes.py
Normal file
115
app/routes.py
Normal 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
288
app/scheduler.py
Normal 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
352
app/static/css/base.css
Normal 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
565
app/static/css/fg_scan.css
Normal 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
143
app/static/css/login.css
Normal 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
68
app/static/css/scan.css
Normal 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
480
app/static/css/theme.css
Normal 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
137
app/static/js/base.js
Normal 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
94
app/static/js/theme.js
Normal 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
120
app/templates/base.html
Normal 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 }} © 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>
|
||||
114
app/templates/dashboard.html
Normal file
114
app/templates/dashboard.html
Normal 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 %}
|
||||
20
app/templates/errors/403.html
Normal file
20
app/templates/errors/403.html
Normal 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 %}
|
||||
20
app/templates/errors/404.html
Normal file
20
app/templates/errors/404.html
Normal 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 %}
|
||||
20
app/templates/errors/500.html
Normal file
20
app/templates/errors/500.html
Normal 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
71
app/templates/login.html
Normal 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 %}
|
||||
866
app/templates/modules/quality/fg_reports.html
Normal file
866
app/templates/modules/quality/fg_reports.html
Normal 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 %}
|
||||
910
app/templates/modules/quality/fg_scan.html
Normal file
910
app/templates/modules/quality/fg_scan.html
Normal 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">×</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 %}
|
||||
52
app/templates/modules/quality/index.html
Normal file
52
app/templates/modules/quality/index.html
Normal 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 %}
|
||||
88
app/templates/modules/quality/inspections.html
Normal file
88
app/templates/modules/quality/inspections.html
Normal 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 %}
|
||||
57
app/templates/modules/quality/reports.html
Normal file
57
app/templates/modules/quality/reports.html
Normal 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 %}
|
||||
266
app/templates/modules/settings/app_keys.html
Normal file
266
app/templates/modules/settings/app_keys.html
Normal 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"> </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"> </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 %}
|
||||
75
app/templates/modules/settings/database.html
Normal file
75
app/templates/modules/settings/database.html
Normal 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 %}
|
||||
901
app/templates/modules/settings/database_management.html
Normal file
901
app/templates/modules/settings/database_management.html
Normal 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"> </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"> </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 %}
|
||||
80
app/templates/modules/settings/general.html
Normal file
80
app/templates/modules/settings/general.html
Normal 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 %}
|
||||
50
app/templates/modules/settings/index.html
Normal file
50
app/templates/modules/settings/index.html
Normal 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 %}
|
||||
190
app/templates/modules/settings/user_form.html
Normal file
190
app/templates/modules/settings/user_form.html
Normal 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 %}
|
||||
104
app/templates/modules/settings/users.html
Normal file
104
app/templates/modules/settings/users.html
Normal 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 %}
|
||||
58
app/templates/profile.html
Normal file
58
app/templates/profile.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user